mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-01 19:53:17 +00:00
99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
|
|
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<typeof import('../src/sync-state.js')>();
|
||
|
|
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<ReturnType<typeof handler>>) {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|