import { describe, it, expect, beforeAll } from 'vitest'; import { App } from 'aws-cdk-lib'; import { Template, Match } from 'aws-cdk-lib/assertions'; import { ShMcpAuthStack } from '../lib/auth-stack.js'; let template: Template; beforeAll(() => { const app = new App(); const stack = new ShMcpAuthStack(app, 'TestAuth', { stackName: 'sh-mcp-auth', env: { account: '328440206208', region: 'us-east-1' }, }); template = Template.fromStack(stack); }); describe('Cognito user pool', () => { it('uses the ESSENTIALS feature plan (required for the V2 pre-token trigger)', () => { template.hasResourceProperties('AWS::Cognito::UserPool', { UserPoolTier: 'ESSENTIALS', }); }); it('attaches the pre-token Lambda as a V2_0 trigger', () => { template.hasResourceProperties('AWS::Cognito::UserPool', { LambdaConfig: { PreTokenGenerationConfig: Match.objectLike({ LambdaVersion: 'V2_0' }), }, }); }); it('defines the four managed groups', () => { for (const g of ['sh-mcp-ops', 'sh-mcp-assistant', 'sh-mcp-finance', 'sh-mcp-admin']) { template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { GroupName: g }); } }); }); describe('app clients — AllowedOAuthScopes is the trust-tier boundary', () => { function clientScopes(name: string): string[] { const clients = template.findResources('AWS::Cognito::UserPoolClient'); const entry = Object.values(clients).find((c) => c.Properties?.ClientName === name); expect(entry, `client ${name} exists`).toBeDefined(); return (entry!.Properties.AllowedOAuthScopes as unknown[]).map((s) => typeof s === 'string' ? s : JSON.stringify(s), ); } it('finance client carries finance:read ONLY — no ops/gmail/finance:admin', () => { const joined = JSON.stringify(clientScopes('sh-agentforce-finance')); expect(joined).toContain('finance:read'); expect(joined).not.toContain('finance:admin'); expect(joined).not.toContain('ops:read'); expect(joined).not.toContain('gmail:self'); }); it('exec client has ops + gmail/calendar but NEVER finance (lethal-trifecta separation)', () => { const joined = JSON.stringify(clientScopes('sh-agentforce-exec')); expect(joined).toContain('ops:read'); expect(joined).toContain('gmail:self'); expect(joined).toContain('calendar:self'); expect(joined).not.toContain('finance:read'); expect(joined).not.toContain('finance:admin'); }); it('ops client has ops:read + ops:tasks, no finance/gmail', () => { const joined = JSON.stringify(clientScopes('sh-agentforce-ops')); expect(joined).toContain('ops:read'); expect(joined).toContain('ops:tasks'); expect(joined).not.toContain('finance'); expect(joined).not.toContain('gmail:self'); }); it('finance client has a 15-minute access token TTL', () => { const clients = template.findResources('AWS::Cognito::UserPoolClient'); const fin = Object.values(clients).find( (c) => c.Properties?.ClientName === 'sh-agentforce-finance', ); expect(fin!.Properties.AccessTokenValidity).toBe(15); expect(fin!.Properties.TokenValidityUnits.AccessToken).toBe('minutes'); }); it('finance client has a short refresh window (≤24h, not the 30-day default)', () => { const toMinutes: Record = { minutes: 1, hours: 60, days: 1440 }; const refreshMinutes = (name: string): number => { const clients = template.findResources('AWS::Cognito::UserPoolClient'); const c = Object.values(clients).find((x) => x.Properties?.ClientName === name)!; return ( c.Properties.RefreshTokenValidity * toMinutes[c.Properties.TokenValidityUnits.RefreshToken] ); }; // A stolen finance refresh token must die in hours; ops/exec keep 30 days. expect(refreshMinutes('sh-agentforce-finance')).toBeLessThanOrEqual(24 * 60); expect(refreshMinutes('sh-agentforce-ops')).toBe(30 * 1440); expect(refreshMinutes('sh-agentforce-exec')).toBe(30 * 1440); }); }); describe('DynamoDB tables', () => { it('pins stable logical IDs (overrideLogicalId) so refactors cannot replace them', () => { const tables = template.findResources('AWS::DynamoDB::Table'); expect(Object.keys(tables)).toEqual( expect.arrayContaining(['SyncStateTable', 'DenyListTable']), ); }); it('deny-list has a TTL attribute for auto-expiring revocations', () => { template.hasResourceProperties('AWS::DynamoDB::Table', { TableName: 'sh-mcp-deny-list', TimeToLiveSpecification: { AttributeName: 'expiresAt', Enabled: true }, }); }); it('both tables RETAIN on stack delete', () => { const tables = template.findResources('AWS::DynamoDB::Table'); for (const t of Object.values(tables)) expect(t.DeletionPolicy).toBe('Retain'); }); }); describe('Lambdas', () => { it('run on arm64 with explicit 60-day log retention', () => { template.allResourcesProperties('AWS::Lambda::Function', { Architectures: ['arm64'], }); template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 60 }); }); it('pre-token Lambda is wired to the deny-list (env var) for hard revocation', () => { const fns = template.findResources('AWS::Lambda::Function'); const pre = Object.values(fns).find( (f) => f.Properties?.FunctionName === 'sh-mcp-pre-token-gen', ); expect(pre!.Properties.Environment.Variables.DENY_LIST_TABLE).toBeDefined(); }); }); describe('IAM least privilege', () => { it('no Lambda policy grants a wildcard action or wildcard resource', () => { const policies = template.findResources('AWS::IAM::Policy'); for (const p of Object.values(policies)) { for (const stmt of p.Properties.PolicyDocument.Statement as { Effect: string; Action: unknown; Resource: unknown; }[]) { if (stmt.Effect !== 'Allow') continue; const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action]; for (const a of actions) expect(a, 'no wildcard action').not.toBe('*'); const resources = Array.isArray(stmt.Resource) ? stmt.Resource : [stmt.Resource]; for (const r of resources) expect(r, 'no bare wildcard resource').not.toBe('*'); } } }); it('the Google SA secret grant is scoped to that one secret', () => { template.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ Action: 'secretsmanager:GetSecretValue', Resource: Match.stringLikeRegexp('secret:sh-mcp/google-directory-sa'), }), ]), }, }); }); }); describe('alarms', () => { it('every alarm has an alarm action (CMK-encrypted SNS topic)', () => { const alarms = template.findResources('AWS::CloudWatch::Alarm'); expect(Object.keys(alarms).length).toBeGreaterThanOrEqual(3); for (const a of Object.values(alarms)) { expect(Array.isArray(a.Properties.AlarmActions)).toBe(true); expect(a.Properties.AlarmActions.length).toBeGreaterThanOrEqual(1); // ALARM-state actions only — never OKActions. expect(a.Properties.OKActions).toBeUndefined(); } }); it('the alarm SNS topic is encrypted with a KMS key (not unencrypted)', () => { template.hasResourceProperties('AWS::SNS::Topic', { TopicName: 'sh-mcp-alarms', KmsMasterKeyId: Match.anyValue(), }); }); }); describe('group-sync schedule', () => { it('runs every 5 minutes', () => { template.hasResourceProperties('AWS::Events::Rule', { ScheduleExpression: 'rate(5 minutes)', }); }); });