import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock the sync-state + deny-list DDB reads so the handler test exercises pure // orchestration. vi.hoisted keeps the mock fns available to the hoisted factories. const { readMock, denyMock } = vi.hoisted(() => ({ readMock: vi.fn(), denyMock: vi.fn() })); vi.mock('../src/sync-state.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readLastSuccessfulSyncMs: readMock }; }); vi.mock('../src/deny-list.js', () => ({ isSubDenied: denyMock })); import { handler } from '../src/index.js'; import { FINANCE_READ, FINANCE_ADMIN, OPS_READ, ALL_TIER_SCOPES } from '../src/scopes.js'; function event(groups: string[], clientId = 'sh-agentforce-finance', sub = 'user-sub-1') { return { callerContext: { clientId }, request: { groupConfiguration: { groupsToOverride: groups }, userAttributes: { sub } }, response: {}, }; } function accessGen(res: Awaited>) { const d = res.response?.['claimsAndScopeOverrideDetails'] as | { accessTokenGeneration?: { scopesToSuppress?: string[]; scopesToAdd?: string[] } } | undefined; return d?.accessTokenGeneration ?? {}; } describe('pre-token handler', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-26T18:00:00Z')); process.env['SYNC_STATE_TABLE'] = 'sh-mcp-sync-state'; process.env['DENY_LIST_TABLE'] = 'sh-mcp-deny-list'; readMock.mockReset(); denyMock.mockReset(); denyMock.mockResolvedValue(false); // not denied unless a test says otherwise }); afterEach(() => vi.useRealTimers()); it('HARD REVOCATION: a deny-listed sub is stripped to NO tier scopes, ignoring groups', async () => { readMock.mockResolvedValue(Date.now()); // fresh sync — irrelevant once denied denyMock.mockResolvedValue(true); const res = await handler(event(['sh-mcp-admin'])); const gen = accessGen(res); expect(gen.scopesToAdd).toBeUndefined(); // Every issued tier scope is suppressed → the principal keeps none. for (const s of ALL_TIER_SCOPES) expect(gen.scopesToSuppress).toContain(s); }); it('deny-list is skipped when DENY_LIST_TABLE is unset (never blocks issuance on missing config)', async () => { delete process.env['DENY_LIST_TABLE']; readMock.mockResolvedValue(Date.now()); const res = await handler(event(['sh-mcp-finance'])); expect(denyMock).not.toHaveBeenCalled(); expect(accessGen(res).scopesToSuppress).not.toContain(FINANCE_READ); }); it('NEVER emits scopesToAdd, on any path', async () => { readMock.mockResolvedValue(Date.now()); // fresh for (const groups of [[], ['sh-mcp-ops'], ['sh-mcp-admin']]) { const res = await handler(event(groups)); expect(accessGen(res).scopesToAdd).toBeUndefined(); } }); it('fresh sync: finance group keeps finance:read, loses finance:admin', async () => { readMock.mockResolvedValue(Date.now() - 60_000); // 1 min old → fresh const res = await handler(event(['sh-mcp-finance'])); const suppress = accessGen(res).scopesToSuppress ?? []; expect(suppress).toContain(FINANCE_ADMIN); expect(suppress).not.toContain(FINANCE_READ); expect(suppress).not.toContain(OPS_READ); }); it('FAIL CLOSED: stale sync drops an admin to base ops:read', async () => { readMock.mockResolvedValue(Date.now() - 60 * 60_000); // 60 min old → stale const res = await handler(event(['sh-mcp-admin'])); const suppress = accessGen(res).scopesToSuppress ?? []; expect(suppress).toContain(FINANCE_READ); expect(suppress).toContain(FINANCE_ADMIN); expect(suppress).not.toContain(OPS_READ); }); it('FAIL CLOSED: missing sync marker (null) drops to base', async () => { readMock.mockResolvedValue(null); const res = await handler(event(['sh-mcp-finance'])); expect(accessGen(res).scopesToSuppress).toContain(FINANCE_READ); }); it('FAIL CLOSED: unset sync-state table never reads DDB and drops to base', async () => { delete process.env['SYNC_STATE_TABLE']; const res = await handler(event(['sh-mcp-admin'])); expect(readMock).not.toHaveBeenCalled(); expect(accessGen(res).scopesToSuppress).toContain(FINANCE_ADMIN); }); });