|
| 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