mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 08:53:18 +00:00
197 lines
7.5 KiB
TypeScript
197 lines
7.5 KiB
TypeScript
|
|
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<string, number> = { 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)',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|