Skip to content

Commit a339472

Browse files
committed
test(comprehensive): live e2e suite for the auth.admin surface
Wire authC with admin: {} plus authCAdmin* routes (createUser, setPassword, addToGroup, listGroupsForUser, disable/enable, delete, revokeSessions), and add sandbox-admin-surface-e2e.ts driving them through the deployed Lambda over JSON-RPC. Covers: createUser, setUserPassword→signIn, addUserToGroup→requireRole on a fresh token, revokeUserSessions drops the live session, disable/enable gates signIn, deleteUser. Requires a deployed bb-test-* sandbox (sandbox-gated; not in the unit run). Typechecks clean.
1 parent c64f681 commit a339472

2 files changed

Lines changed: 232 additions & 0 deletions

File tree

test-apps/comprehensive/aws-blocks/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ const authC = new AuthCognito(scope, 'authC', {
8383
passwordPolicy: { minLength: 8, requireDigits: true },
8484
userAttributes: [{ name: 'department' }],
8585
groups: ['admins', 'readers'],
86+
// Enable the opt-in admin surface (auth.admin) for the sandbox admin e2e
87+
// suite. Grants the Admin*/List* IAM on this pool's handler role.
88+
admin: {},
8689
mfa: 'off',
8790
mfaTypes: ['SMS', 'TOTP', 'EMAIL'],
8891
selfSignUp: true,
@@ -959,6 +962,41 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({
959962
return user;
960963
},
961964

965+
// ── Admin surface (auth.admin) — exercised by sandbox-admin-e2e ────────────
966+
// In a real app these would be gated by `requireRole`; the e2e harness drives
967+
// them directly to verify the admin grant + behavior end-to-end.
968+
async authCAdminCreateUser(username: string, temporaryPassword: string) {
969+
const u = await authC.admin.createUser(username, { temporaryPassword });
970+
return { username: u.username, enabled: u.enabled };
971+
},
972+
async authCAdminSetPassword(username: string, password: string) {
973+
await authC.admin.setUserPassword(username, password, true);
974+
return { success: true };
975+
},
976+
async authCAdminAddToGroup(username: string, group: string) {
977+
await authC.admin.addUserToGroup(username, group as Parameters<typeof authC.admin.addUserToGroup>[1]);
978+
return { success: true };
979+
},
980+
async authCAdminListGroupsForUser(username: string) {
981+
return await authC.admin.listGroupsForUser(username);
982+
},
983+
async authCAdminDisableUser(username: string) {
984+
await authC.admin.disableUser(username);
985+
return { success: true };
986+
},
987+
async authCAdminEnableUser(username: string) {
988+
await authC.admin.enableUser(username);
989+
return { success: true };
990+
},
991+
async authCAdminDeleteUser(username: string) {
992+
await authC.admin.deleteUser(username);
993+
return { success: true };
994+
},
995+
async authCAdminRevokeSessions(username: string) {
996+
await authC.admin.revokeUserSessions(username);
997+
return { success: true };
998+
},
999+
9621000
async authCFetchUserAttributes() {
9631001
return await authC.fetchUserAttributes(context);
9641002
},
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Live AWS e2e suite for the opt-in `auth.admin` surface on AuthCognito.
6+
*
7+
* Unlike `sandbox-admin-e2e.ts` (which provisions users via the raw Cognito
8+
* SDK to test the *client* methods), this suite drives the BB's own
9+
* `auth.admin.*` operations through the deployed Lambda backend over HTTP —
10+
* the routes wired in `aws-blocks/index.ts` (`authCAdmin*`). It verifies the
11+
* admin IAM grant is present and the surface behaves end-to-end.
12+
*
13+
* Prerequisites (same as sandbox-admin-e2e):
14+
* - A deployed `bb-test-*` sandbox with the comprehensive backend, built
15+
* with `admin: {}` on the `authC` pool (already set in aws-blocks/index.ts).
16+
* - `AWS_PROFILE` with Cognito admin + CFN-describe permissions.
17+
* - Run from `test-apps/comprehensive` (reads `.blocks-sandbox/outputs.json`).
18+
*
19+
* Run: node --import tsx test/sandbox-admin-surface-e2e.ts
20+
* (NOT part of the unit-test run — requires a live deployment.)
21+
*/
22+
23+
import { readFileSync } from 'node:fs';
24+
import { join, dirname } from 'node:path';
25+
import { fileURLToPath } from 'node:url';
26+
import {
27+
CloudFormationClient,
28+
DescribeStackResourcesCommand,
29+
} from '@aws-sdk/client-cloudformation';
30+
31+
const SANDBOX_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '.blocks-sandbox');
32+
const REGION = process.env.AWS_REGION || 'us-east-1';
33+
const STACK_NAME_ENV = process.env.BLOCKS_STACK_NAME;
34+
35+
const results: { name: string; status: 'pass' | 'fail'; detail?: string }[] = [];
36+
37+
async function runTest(name: string, fn: () => Promise<void>) {
38+
process.stdout.write(`• ${name} ... `);
39+
try {
40+
await fn();
41+
console.log('PASS');
42+
results.push({ name, status: 'pass' });
43+
} catch (e: any) {
44+
const detail = e?.message ?? String(e);
45+
console.log(`FAIL — ${detail}`);
46+
results.push({ name, status: 'fail', detail });
47+
}
48+
}
49+
50+
function assert(cond: unknown, msg: string): asserts cond {
51+
if (!cond) throw new Error(msg);
52+
}
53+
54+
async function discoverApiUrl(): Promise<string> {
55+
const outputs = JSON.parse(readFileSync(join(SANDBOX_DIR, 'outputs.json'), 'utf-8'));
56+
const stackName = STACK_NAME_ENV ?? Object.keys(outputs)[0];
57+
if (!stackName) throw new Error('No stack in outputs.json');
58+
const apiUrl = (outputs[stackName] as Record<string, string>).ApiUrl;
59+
if (!apiUrl) throw new Error(`No ApiUrl for ${stackName}`);
60+
// Touch CFN so a misconfigured profile fails loudly here, not mid-suite.
61+
await new CloudFormationClient({ region: REGION })
62+
.send(new DescribeStackResourcesCommand({ StackName: stackName }));
63+
return apiUrl;
64+
}
65+
66+
/** Minimal JSON-RPC client with cookie jar (mirrors sandbox-admin-e2e). */
67+
class ApiSession {
68+
private cookies = new Map<string, string>();
69+
constructor(private apiUrl: string, private namespace = 'api') {}
70+
reset() { this.cookies.clear(); }
71+
private cookieHeader(): string | undefined {
72+
if (this.cookies.size === 0) return undefined;
73+
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
74+
}
75+
private absorb(headers: Headers) {
76+
const raw = (headers as any).getSetCookie?.() ??
77+
(headers.get('set-cookie') ? [headers.get('set-cookie')!] : []);
78+
for (const v of raw) {
79+
const kv = v.slice(0, v.indexOf(';') === -1 ? undefined : v.indexOf(';'));
80+
const eq = kv.indexOf('=');
81+
if (eq < 0) continue;
82+
const name = kv.slice(0, eq).trim();
83+
const value = kv.slice(eq + 1).trim();
84+
if (!value) this.cookies.delete(name); else this.cookies.set(name, value);
85+
}
86+
}
87+
async call<T = any>(method: string, args: any[] = []): Promise<T> {
88+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
89+
const ch = this.cookieHeader();
90+
if (ch) headers['Cookie'] = ch;
91+
const res = await fetch(this.apiUrl, {
92+
method: 'POST', headers,
93+
body: JSON.stringify({ jsonrpc: '2.0', method: `${this.namespace}.${method}`, params: args, id: 1 }),
94+
});
95+
this.absorb(res.headers);
96+
const text = await res.text();
97+
let body: any; try { body = JSON.parse(text); } catch { body = text; }
98+
if (!res.ok || body?.error) {
99+
const p = body?.error ?? {};
100+
const err: any = new Error(p.message ?? body?.error ?? res.statusText);
101+
err.status = p.code && p.code > 0 ? p.code : res.status;
102+
err.name = p.data?.name ?? body?.name ?? err.name;
103+
throw err;
104+
}
105+
return body.result as T;
106+
}
107+
}
108+
109+
async function main() {
110+
console.log('─── auth.admin surface e2e ───');
111+
const apiUrl = await discoverApiUrl();
112+
console.log(` API URL: ${apiUrl}`);
113+
114+
const s = new ApiSession(apiUrl);
115+
const uniq = (process.env.BLOCKS_TEST_SEED ?? String(process.hrtime.bigint())).slice(-8);
116+
const alice = `admin-e2e-alice-${uniq}`;
117+
const bob = `admin-e2e-bob-${uniq}`;
118+
const PW = 'AdminE2e!1';
119+
120+
await runTest('admin.createUser creates a real user', async () => {
121+
const r = await s.call('authCAdminCreateUser', [alice, PW]);
122+
assert(r.username === alice, `expected ${alice}, got ${r.username}`);
123+
assert(r.enabled === true, 'new user should be enabled');
124+
});
125+
126+
await runTest('admin.setUserPassword(permanent) lets the user sign in', async () => {
127+
await s.call('authCAdminSetPassword', [alice, PW]);
128+
const r = await s.call('authCSignIn', [alice, PW]);
129+
assert(r.status === 'signedIn', `expected signedIn, got ${r.status}`);
130+
s.reset();
131+
});
132+
133+
await runTest('admin.addUserToGroup → requireRole(admins) succeeds on fresh token', async () => {
134+
await s.call('authCAdminAddToGroup', [alice, 'admins']);
135+
const groups = await s.call('authCAdminListGroupsForUser', [alice]);
136+
assert(Array.isArray(groups) && groups.includes('admins'), `groups missing admins: ${JSON.stringify(groups)}`);
137+
await s.call('authCSignIn', [alice, PW]); // fresh sign-in → claim carries the group
138+
const user = await s.call('authCRequireRole', ['admins']);
139+
assert(user.username === alice, 'requireRole should return the user');
140+
s.reset();
141+
});
142+
143+
await runTest('revokeUserSessions forces re-auth (existing session dropped)', async () => {
144+
await s.call('authCSignIn', [alice, PW]);
145+
assert((await s.call('authCCheckAuth', [])) === true, 'should be signed in');
146+
await s.call('authCAdminRevokeSessions', [alice]);
147+
// Same cookie jar — server-side session is gone, so checkAuth is false.
148+
assert((await s.call('authCCheckAuth', [])) === false, 'session should be revoked');
149+
s.reset();
150+
});
151+
152+
await runTest('admin.disableUser blocks sign-in; enableUser restores it', async () => {
153+
await s.call('authCAdminDisableUser', [alice]);
154+
let threw = false;
155+
try { await s.call('authCSignIn', [alice, PW]); } catch (e: any) {
156+
threw = true;
157+
assert(e.name === 'NotAuthorizedException', `expected NotAuthorized, got ${e.name}`);
158+
}
159+
assert(threw, 'disabled user sign-in should throw');
160+
await s.call('authCAdminEnableUser', [alice]);
161+
const r = await s.call('authCSignIn', [alice, PW]);
162+
assert(r.status === 'signedIn', 'enabled user should sign in');
163+
s.reset();
164+
});
165+
166+
await runTest('admin.deleteUser removes the user and group membership', async () => {
167+
await s.call('authCAdminCreateUser', [bob, PW]);
168+
await s.call('authCAdminSetPassword', [bob, PW]);
169+
await s.call('authCAdminAddToGroup', [bob, 'readers']);
170+
await s.call('authCAdminDeleteUser', [bob]);
171+
let threw = false;
172+
try { await s.call('authCSignIn', [bob, PW]); } catch { threw = true; }
173+
assert(threw, 'deleted user should not sign in');
174+
});
175+
176+
// Cleanup
177+
await runTest('cleanup: delete alice', async () => {
178+
await s.call('authCAdminDeleteUser', [alice]);
179+
});
180+
181+
const pass = results.filter((r) => r.status === 'pass').length;
182+
const fail = results.filter((r) => r.status === 'fail').length;
183+
console.log(`\n=== Summary: ${pass} pass · ${fail} fail (${results.length} total) ===`);
184+
if (fail > 0) {
185+
console.log('\nFailures:');
186+
for (const r of results.filter((r) => r.status === 'fail')) console.log(` ✗ ${r.name}: ${r.detail}`);
187+
}
188+
process.exit(fail > 0 ? 1 : 0);
189+
}
190+
191+
main().catch((e) => {
192+
console.error(e);
193+
process.exit(2);
194+
});

0 commit comments

Comments
 (0)