Skip to content

Commit a77aa90

Browse files
committed
test(bb-auth-cognito): unit tests for auth.admin (mock behavior + CDK grant)
admin.test.ts (13): runtime gate throw, group add/remove + GroupNotFound + UserNotFound, listGroups/listUsersInGroup, lifecycle create/get/delete (+ group cleanup), disable/enable (asserts existing signIn disabled-guard), setUserPassword, scan, revokeUserSessions. index.cdk.test.ts (+4): no-admin grants zero Admin* actions (least privilege); admin:{} grants both slices; actions:['groups'|'lifecycle'] grant only that slice. Full package suite: 224 tests, 0 fail.
1 parent 7bc20b3 commit a77aa90

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { test, describe, beforeEach, afterEach } from 'node:test';
5+
import assert from 'node:assert';
6+
import { rmSync } from 'node:fs';
7+
import { isBlocksError } from '@aws-blocks/core';
8+
import type { BlocksContext } from '@aws-blocks/core';
9+
import { AuthCognito, AuthCognitoErrors } from './index.js';
10+
11+
// ── Harness (mirrors index.test.ts) ──────────────────────────────────────────
12+
13+
const ROOT = { id: 'test-app' } as any;
14+
15+
function freshContext(): BlocksContext {
16+
const req = new Headers();
17+
const res = new Headers();
18+
let status = 200;
19+
const ctx: BlocksContext = {
20+
request: {
21+
headers: req, body: null,
22+
json: async () => ({}), text: async () => '',
23+
url: new URL('http://localhost:3000/'), params: {},
24+
},
25+
response: {
26+
headers: res,
27+
get status() { return status; },
28+
set status(v) { status = v; },
29+
send: () => {},
30+
} as any,
31+
};
32+
const origSet = res.set.bind(res);
33+
res.set = (name: string, value: string) => {
34+
if (name.toLowerCase() === 'set-cookie') {
35+
req.set('cookie', value.split(';')[0]);
36+
}
37+
origSet(name, value);
38+
};
39+
return ctx;
40+
}
41+
42+
function unique(prefix = 'admin') {
43+
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
44+
}
45+
46+
/** Sign a user up + confirm so they exist and can sign in. */
47+
async function signUpAndConfirm(auth: AuthCognito<any>, username: string) {
48+
let code = '';
49+
(auth as any).options.codeDelivery = async (_u: string, c: string) => { code = c; };
50+
await auth.signUp(username, 'Password!1', { attributes: { email: `${username}@x.com` } });
51+
await auth.confirmSignUp(username, code);
52+
}
53+
54+
beforeEach(() => {
55+
try { rmSync('.bb-data', { recursive: true, force: true }); } catch { /* ignore */ }
56+
});
57+
afterEach(() => {
58+
try { rmSync('.bb-data', { recursive: true, force: true }); } catch { /* ignore */ }
59+
});
60+
61+
// ── Gate (runtime) ───────────────────────────────────────────────────────────
62+
63+
describe('auth.admin runtime gate', () => {
64+
test('throws a named error when admin is not enabled', () => {
65+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'] });
66+
// Untyped JS caller reaching past the compile-time gate.
67+
assert.throws(() => (auth as any).admin, /admin not enabled/);
68+
});
69+
70+
test('returns the surface when admin is enabled', () => {
71+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
72+
assert.strictEqual(typeof auth.admin.addUserToGroup, 'function');
73+
assert.strictEqual(typeof auth.admin.createUser, 'function');
74+
});
75+
});
76+
77+
// ── Group membership ─────────────────────────────────────────────────────────
78+
79+
describe('auth.admin group membership', () => {
80+
test('addUserToGroup is visible to the same instance requireRole', async () => {
81+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
82+
await signUpAndConfirm(auth, 'alice');
83+
84+
await auth.admin.addUserToGroup('alice', 'admins');
85+
assert.deepStrictEqual(await auth.admin.listGroupsForUser('alice'), ['admins']);
86+
87+
const ctx = freshContext();
88+
await auth.signIn('alice', 'Password!1', ctx);
89+
const user = await auth.requireRole(ctx, 'admins'); // would 403 without the membership
90+
assert.strictEqual(user.username, 'alice');
91+
});
92+
93+
test('addUserToGroup to an unseeded group throws GroupNotFound', async () => {
94+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
95+
await signUpAndConfirm(auth, 'bob');
96+
await assert.rejects(
97+
// Cast past the narrowed group union to exercise the runtime guard
98+
// (a real unseeded group; `const O` would reject this at compile time).
99+
() => auth.admin.addUserToGroup('bob', 'ghosts' as 'admins'),
100+
(e: unknown) => isBlocksError(e, AuthCognitoErrors.GroupNotFound),
101+
);
102+
});
103+
104+
test('addUserToGroup for a missing user throws UserNotFound', async () => {
105+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
106+
await assert.rejects(
107+
() => auth.admin.addUserToGroup('nobody', 'admins'),
108+
(e: unknown) => isBlocksError(e, AuthCognitoErrors.UserNotFound),
109+
);
110+
});
111+
112+
test('removeUserFromGroup filters membership', async () => {
113+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
114+
await signUpAndConfirm(auth, 'carol');
115+
await auth.admin.addUserToGroup('carol', 'admins');
116+
await auth.admin.removeUserFromGroup('carol', 'admins');
117+
assert.deepStrictEqual(await auth.admin.listGroupsForUser('carol'), []);
118+
});
119+
120+
test('listUsersInGroup returns members', async () => {
121+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
122+
await signUpAndConfirm(auth, 'dave');
123+
await auth.admin.addUserToGroup('dave', 'admins');
124+
const members = await auth.admin.listUsersInGroup('admins');
125+
assert.deepStrictEqual(members.map((m) => m.username), ['dave']);
126+
});
127+
});
128+
129+
// ── User lifecycle ───────────────────────────────────────────────────────────
130+
131+
describe('auth.admin user lifecycle', () => {
132+
test('createUser then getUser round-trips; createUser twice conflicts', async () => {
133+
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
134+
const created = await auth.admin.createUser('erin', { attributes: { email: 'erin@x.com' } });
135+
assert.strictEqual(created.username, 'erin');
136+
assert.strictEqual(created.enabled, true);
137+
138+
const fetched = await auth.admin.getUser('erin');
139+
assert.strictEqual(fetched?.username, 'erin');
140+
assert.strictEqual(await auth.admin.getUser('ghost'), null);
141+
142+
await assert.rejects(
143+
() => auth.admin.createUser('erin'),
144+
(e: unknown) => isBlocksError(e, AuthCognitoErrors.UserAlreadyExists),
145+
);
146+
});
147+
148+
test('deleteUser removes the record and strips group membership', async () => {
149+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: {} });
150+
await signUpAndConfirm(auth, 'frank');
151+
await auth.admin.addUserToGroup('frank', 'admins');
152+
await auth.admin.deleteUser('frank');
153+
assert.strictEqual(await auth.admin.getUser('frank'), null);
154+
assert.deepStrictEqual(await auth.admin.listUsersInGroup('admins'), []);
155+
});
156+
157+
test('disableUser blocks sign-in; enableUser restores it (existing signIn guard)', async () => {
158+
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
159+
await signUpAndConfirm(auth, 'gina');
160+
161+
await auth.admin.disableUser('gina');
162+
await assert.rejects(
163+
() => auth.signIn('gina', 'Password!1', freshContext()),
164+
(e: unknown) => isBlocksError(e, AuthCognitoErrors.NotAuthorized),
165+
);
166+
167+
await auth.admin.enableUser('gina');
168+
const r = await auth.signIn('gina', 'Password!1', freshContext());
169+
assert.strictEqual(r.status, 'signedIn');
170+
});
171+
172+
test('setUserPassword(permanent) lets the user sign in with the new password', async () => {
173+
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
174+
await signUpAndConfirm(auth, 'hank');
175+
await auth.admin.setUserPassword('hank', 'NewPass!2', true);
176+
const r = await auth.signIn('hank', 'NewPass!2', freshContext());
177+
assert.strictEqual(r.status, 'signedIn');
178+
});
179+
180+
test('scan yields all users', async () => {
181+
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
182+
await auth.admin.createUser('ida');
183+
await auth.admin.createUser('jack');
184+
const seen: string[] = [];
185+
for await (const u of auth.admin.scan()) seen.push(u.username);
186+
assert.deepStrictEqual(seen.sort(), ['ida', 'jack']);
187+
});
188+
189+
test('revokeUserSessions deletes the user session (forces re-auth)', async () => {
190+
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
191+
await signUpAndConfirm(auth, 'kara');
192+
const ctx = freshContext();
193+
await auth.signIn('kara', 'Password!1', ctx);
194+
assert.strictEqual(await auth.checkAuth(ctx), true);
195+
196+
await auth.admin.revokeUserSessions('kara');
197+
assert.strictEqual(await auth.checkAuth(ctx), false);
198+
});
199+
});

packages/bb-auth-cognito/src/index.cdk.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,3 +536,78 @@ describe('AuthCognito (CDK) — enablePasskeys', () => {
536536
}
537537
});
538538
});
539+
540+
// ─── Admin surface IAM grant (opt-in) ─────────────────────────────────────────
541+
542+
/** Collect every cognito-idp IAM action granted to any role in the template. */
543+
function grantedActions(template: Template): Set<string> {
544+
const policies = template.findResources('AWS::IAM::Policy');
545+
const statements = Object.values(policies).flatMap(
546+
(p) => ((p as { Properties?: { PolicyDocument?: { Statement?: unknown[] } } })
547+
.Properties?.PolicyDocument?.Statement ?? []) as { Action?: unknown }[],
548+
);
549+
const flat = new Set<string>();
550+
for (const s of statements) {
551+
const actions = Array.isArray(s.Action) ? s.Action : s.Action ? [s.Action] : [];
552+
for (const a of actions) if (typeof a === 'string') flat.add(a);
553+
}
554+
return flat;
555+
}
556+
557+
const GROUP_ADMIN_ACTIONS = [
558+
'cognito-idp:AdminAddUserToGroup',
559+
'cognito-idp:AdminRemoveUserFromGroup',
560+
'cognito-idp:AdminListGroupsForUser',
561+
'cognito-idp:ListUsersInGroup',
562+
];
563+
const LIFECYCLE_ADMIN_ACTIONS = [
564+
'cognito-idp:AdminCreateUser',
565+
'cognito-idp:AdminDeleteUser',
566+
'cognito-idp:AdminEnableUser',
567+
'cognito-idp:AdminDisableUser',
568+
'cognito-idp:AdminResetUserPassword',
569+
'cognito-idp:AdminSetUserPassword',
570+
'cognito-idp:AdminGetUser',
571+
'cognito-idp:ListUsers',
572+
'cognito-idp:AdminUserGlobalSignOut',
573+
];
574+
575+
describe('AuthCognito (CDK) — admin IAM grant', () => {
576+
test('no admin option → NO Admin* actions granted (least privilege)', () => {
577+
const template = synth((stack) => {
578+
new AuthCognito(scope(stack), 'auth', { groups: ['admins'] });
579+
});
580+
const actions = grantedActions(template);
581+
for (const a of [...GROUP_ADMIN_ACTIONS, ...LIFECYCLE_ADMIN_ACTIONS]) {
582+
assert.ok(!actions.has(a), `unexpected admin action granted without opt-in: ${a}`);
583+
}
584+
});
585+
586+
test("admin: {} (no actions) → grants both group + lifecycle actions", () => {
587+
const template = synth((stack) => {
588+
new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: {} });
589+
});
590+
const actions = grantedActions(template);
591+
for (const a of [...GROUP_ADMIN_ACTIONS, ...LIFECYCLE_ADMIN_ACTIONS]) {
592+
assert.ok(actions.has(a), `missing admin action ${a}`);
593+
}
594+
});
595+
596+
test("actions: ['groups'] → grants group actions only", () => {
597+
const template = synth((stack) => {
598+
new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['groups'] } });
599+
});
600+
const actions = grantedActions(template);
601+
for (const a of GROUP_ADMIN_ACTIONS) assert.ok(actions.has(a), `missing group action ${a}`);
602+
for (const a of LIFECYCLE_ADMIN_ACTIONS) assert.ok(!actions.has(a), `unexpected lifecycle action ${a}`);
603+
});
604+
605+
test("actions: ['lifecycle'] → grants lifecycle actions only", () => {
606+
const template = synth((stack) => {
607+
new AuthCognito(scope(stack), 'auth', { groups: ['admins'], admin: { actions: ['lifecycle'] } });
608+
});
609+
const actions = grantedActions(template);
610+
for (const a of LIFECYCLE_ADMIN_ACTIONS) assert.ok(actions.has(a), `missing lifecycle action ${a}`);
611+
for (const a of GROUP_ADMIN_ACTIONS) assert.ok(!actions.has(a), `unexpected group action ${a}`);
612+
});
613+
});

0 commit comments

Comments
 (0)