import {createHmac} from 'node:crypto' import {describe, expect, test} from 'bun:test' import { AdminAddUserToGroupCommand, AdminConfirmSignUpCommand, AdminDisableUserCommand, AdminListGroupsForUserCommand, AdminRemoveUserFromGroupCommand, AdminUserGlobalSignOutCommand, CreateGroupCommand, DeleteGroupCommand, DescribeUserPoolClientCommand, InitiateAuthCommand, ListGroupsCommand, AdminCreateUserCommand, AdminDeleteUserCommand, AdminGetUserCommand, AdminSetUserPasswordCommand, CreateUserPoolClientCommand, CreateUserPoolCommand, DeleteUserPoolCommand, DescribeUserPoolCommand, ListUserPoolClientsCommand, ListUserPoolsCommand, ListUsersCommand, type CognitoIdentityProviderClient, } from '@aws-sdk/client-cognito-identity-provider' import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter' import {ConflictError, ValidationError} from '../cloud-spi/errors' type SendResult = Record function stubCognito(handler: (command: object) => SendResult | Promise) { const sent: object[] = [] const client = { async send(command: object) { sent.push(command) return handler(command) }, } as unknown as CognitoIdentityProviderClient return {client, sent} } function awsError(name: string): Error { return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}}) } const POOL_ID = 'us-east-1_8e65d229a' const CREATED = new Date('2026-10-23T16:01:22.000Z') const pool = { Id: POOL_ID, Name: 'orders-users', Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`, Status: 'Enabled', CreationDate: CREATED, UsernameAttributes: ['email'], EstimatedNumberOfUsers: 1, MfaConfiguration: 'OFF', DeletionProtection: 'INACTIVE', Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}}, } const alice = { Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', Attributes: [ {Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'}, {Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}, ], UserCreateDate: CREATED, UserLastModifiedDate: CREATED, Enabled: true, UserStatus: 'CONFIRMED', } function runtimeStub() { return stubCognito((command) => { if (command instanceof CreateUserPoolCommand) return {UserPool: pool} if (command instanceof DescribeUserPoolCommand) return {UserPool: pool} if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]} if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}} if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes} return {} }) } describe('AwsUserPoolsAdapter', () => { test('lists every page of user pools and filters by name or id', async () => { const {client, sent} = stubCognito((command) => { if (!(command instanceof ListUserPoolsCommand)) return {} return command.input.NextToken ? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]} : {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'} }) const adapter = new AwsUserPoolsAdapter(client) expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second']) expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2) expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing']) expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()}) }) test('lists pools with the details the inspector shows', async () => { const {client} = stubCognito((command) => { if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]} if (command instanceof DescribeUserPoolCommand) return {UserPool: pool} if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]} return {} }) const [listed] = await new AwsUserPoolsAdapter(client).list() expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}) }) test('describes a pool with its sign-in method, password policy and app clients', async () => { const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID) expect(resource?.metadata).toEqual({ arn: pool.Arn, signInWith: 'email', passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol', estimatedNumberOfUsers: 1, userCount: 0, mfaConfiguration: 'OFF', deletionProtection: 'INACTIVE', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}], }) }) test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => { const {client} = stubCognito((command) => { if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}} if (command instanceof ListUsersCommand) return command.input.PaginationToken ? {Users: [alice]} : {Users: [alice, alice], PaginationToken: 'page-2'} return {} }) const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID) expect(resource?.metadata.userCount).toBe(3) expect(resource?.metadata.estimatedNumberOfUsers).toBe(0) }) test('summarises a relaxed password policy without character rules', async () => { const {client} = stubCognito((command) => { if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}} return {} }) expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters') }) test('returns null for a pool the runtime does not know', async () => { const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') }) expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull() }) test('creates an email pool with a public app client by default', async () => { const {client, sent} = runtimeStub() await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}}) const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']}) const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand expect(createClient.input).toEqual({ UserPoolId: POOL_ID, ClientName: 'orders-users-client', GenerateSecret: false, ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'], }) }) test('creates a username pool with a relaxed password policy and no app client', async () => { const {client, sent} = runtimeStub() await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}}) const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand expect(createPool.input).toEqual({ PoolName: 'testers', Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}, }) expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false) }) test('removes the new pool when its app client cannot be created', async () => { const {client, sent} = stubCognito((command) => { if (command instanceof CreateUserPoolCommand) return {UserPool: pool} if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException') return {} }) await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException') expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID}) }) test('rejects invalid create input before calling the runtime', async () => { const {client, sent} = runtimeStub() const adapter = new AwsUserPoolsAdapter(client) await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError) expect(sent).toHaveLength(0) }) test('deletes a pool by id', async () => { const {client, sent} = runtimeStub() await new AwsUserPoolsAdapter(client).delete(POOL_ID) expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand) expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID}) }) }) describe('AwsUserPoolsAdapter users', () => { test('lists users as items keyed by username with their email and status', async () => { const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'})) const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'}) expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'}) expect(page.nextCursor).toBe('next-page') expect(page.items).toEqual([{ id: alice.Username, collectionId: null, timestamp: CREATED.toISOString(), body: { username: alice.Username, email: 'alice@example.com', emailVerified: true, status: 'CONFIRMED', enabled: true, lastModifiedAt: CREATED.toISOString(), attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'}, }, metadata: {}, }]) }) test('creates a user whose password is permanent so it can sign in at once', async () => { const {client, sent} = runtimeStub() const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '}) expect((sent[0] as AdminCreateUserCommand).input).toEqual({ UserPoolId: POOL_ID, Username: 'alice@example.com', UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}], TemporaryPassword: ' Passw0rd! ', MessageAction: 'SUPPRESS', }) expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true}) expect(user.body.status).toBe('CONFIRMED') }) test('leaves the password temporary when asked', async () => { const {client, sent} = runtimeStub() await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false}) expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob') expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([]) expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false) }) test('rejects a user without a name, password or valid email', async () => { const {client, sent} = runtimeStub() const users = new AwsUserPoolsAdapter(client).items await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address') await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required') await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address') await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false') expect(sent).toHaveLength(0) }) test('returns null for a missing user and deletes by username', async () => { const missing = stubCognito(() => { throw awsError('UserNotFoundException') }) expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull() const {client, sent} = runtimeStub() await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username) expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username}) }) }) function jwt(claims: Record): string { return ['{"alg":"RS256"}', JSON.stringify(claims), 'sig'].map((part) => Buffer.from(part).toString('base64url')).join('.') } describe('AwsUserPoolsAdapter user actions and groups', () => { test('reads a user with the groups it belongs to', async () => { const {client, sent} = stubCognito((command) => { if (command instanceof AdminGetUserCommand) return {Username: alice.Username, UserAttributes: alice.Attributes, UserStatus: 'CONFIRMED', Enabled: true, UserCreateDate: CREATED} if (command instanceof AdminListGroupsForUserCommand) return {Groups: [{GroupName: 'admins'}]} return {} }) const user = await new AwsUserPoolsAdapter(client).items.getItem!(POOL_ID, alice.Username) expect(user?.body.groups).toEqual(['admins']) expect((sent[1] as AdminListGroupsForUserCommand).input).toMatchObject({UserPoolId: POOL_ID, Username: alice.Username}) }) test('runs each user action as its Admin call', async () => { const {client, sent} = stubCognito(() => ({})) const adapter = new AwsUserPoolsAdapter(client) await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'disable'}) await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'}) await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'signOut'}) await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword', password: ' Passw0rd! '}) expect(sent[0]).toBeInstanceOf(AdminDisableUserCommand) expect(sent[1]).toBeInstanceOf(AdminConfirmSignUpCommand) expect(sent[2]).toBeInstanceOf(AdminUserGlobalSignOutCommand) expect((sent[3] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: 'bob', Password: ' Passw0rd! ', Permanent: true}) }) test('rejects unknown actions and reports a refused one as a conflict, never a 401', async () => { const {client} = stubCognito(() => { throw awsError('NotAuthorizedException') }) const adapter = new AwsUserPoolsAdapter(client) await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'explode'} as never)).rejects.toBeInstanceOf(ValidationError) await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword'} as never)).rejects.toBeInstanceOf(ValidationError) await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})).rejects.toBeInstanceOf(ConflictError) }) test('sets a user\'s groups by adding and removing only the difference', async () => { const {client, sent} = stubCognito((command) => command instanceof AdminListGroupsForUserCommand ? {Groups: [{GroupName: 'staff'}, {GroupName: 'beta'}]} : {}) await new AwsUserPoolsAdapter(client).setUserPoolUserGroups(POOL_ID, 'bob', ['staff', 'admins']) const changes = sent.slice(1) expect(changes).toHaveLength(2) expect(changes[0]).toBeInstanceOf(AdminAddUserToGroupCommand) expect((changes[0] as AdminAddUserToGroupCommand).input.GroupName).toBe('admins') expect(changes[1]).toBeInstanceOf(AdminRemoveUserFromGroupCommand) expect((changes[1] as AdminRemoveUserFromGroupCommand).input.GroupName).toBe('beta') }) test('lists, creates and deletes groups', async () => { const {client, sent} = stubCognito((command) => { if (command instanceof ListGroupsCommand) return {Groups: [{GroupName: 'admins', Description: 'Full access', Precedence: 0, CreationDate: CREATED}]} if (command instanceof CreateGroupCommand) return {Group: {GroupName: command.input.GroupName, Precedence: command.input.Precedence, CreationDate: CREATED}} return {} }) const adapter = new AwsUserPoolsAdapter(client) expect(await adapter.listUserPoolGroups(POOL_ID)).toEqual([{name: 'admins', description: 'Full access', precedence: 0, createdAt: CREATED.toISOString()}]) expect(await adapter.createUserPoolGroup(POOL_ID, {name: 'editors', precedence: 5})).toMatchObject({name: 'editors', precedence: 5}) await adapter.deleteUserPoolGroup(POOL_ID, 'editors') expect((sent[1] as CreateGroupCommand).input).toEqual({UserPoolId: POOL_ID, GroupName: 'editors', Precedence: 5}) expect(sent[2]).toBeInstanceOf(DeleteGroupCommand) }) test('validates a group before creating it', async () => { const {client, sent} = stubCognito(() => ({})) const adapter = new AwsUserPoolsAdapter(client) await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'has space'})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'ok', precedence: -1})).rejects.toBeInstanceOf(ValidationError) expect(sent).toHaveLength(0) }) test('test sign-in returns decoded claims, adding SECRET_HASH for a client with a secret', async () => { const {client, sent} = stubCognito((command) => { if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: {ClientId: 'client-1', ClientSecret: 'shh'}} if (command instanceof InitiateAuthCommand) return {AuthenticationResult: {ExpiresIn: 3600, IdToken: jwt({sub: 's-1', email: 'alice@example.com', token_use: 'id'}), AccessToken: jwt({sub: 's-1', token_use: 'access'})}} return {} }) const result = await new AwsUserPoolsAdapter(client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'alice@example.com', password: 'Passw0rd!'}) expect(result).toEqual({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: 's-1', email: 'alice@example.com', token_use: 'id'}, accessToken: {sub: 's-1', token_use: 'access'}}) const auth = (sent[1] as InitiateAuthCommand).input expect(auth.AuthFlow).toBe('USER_PASSWORD_AUTH') expect(auth.AuthParameters?.SECRET_HASH).toBe(createHmac('sha256', 'shh').update('alice@example.comclient-1').digest('base64')) }) test('test sign-in reports a wrong password or a challenge as its outcome', async () => { const refusing = stubCognito((command) => { if (command instanceof InitiateAuthCommand) throw Object.assign(awsError('NotAuthorizedException'), {message: 'Incorrect username or password.'}) return {UserPoolClient: {ClientId: 'client-1'}} }) expect(await new AwsUserPoolsAdapter(refusing.client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'a', password: 'x'})) .toEqual({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}) expect((refusing.sent[1] as InitiateAuthCommand).input.AuthParameters?.SECRET_HASH).toBeUndefined() const challenging = stubCognito((command) => command instanceof InitiateAuthCommand ? {ChallengeName: 'NEW_PASSWORD_REQUIRED'} : {UserPoolClient: {}}) expect(await new AwsUserPoolsAdapter(challenging.client).testUserPoolSignIn(POOL_ID, {clientId: 'c', username: 'a', password: 'x'})) .toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'}) }) })