feat(api): add Cognito user pools to the cloud explorer
Adds a userpools catalog entry, schema and AwsUserPoolsAdapter, verified against Floci core: - list/get/create/delete user pools; create can set email or username sign-in, a relaxed password policy for local test users, and a public app client (on by default) whose id is surfaced in the pool metadata - users as the pool's child items: list (paged), get, create and delete; passwords are set permanent by default so users can sign in at once - map Cognito's HTTP-400 error names (UserNotFoundException, UsernameExistsException, AliasExistsException, InvalidPasswordException) - link Floci's cognito-idp service to the new page on the diagnostics page
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
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 {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubCognito(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
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,
|
||||
mfaConfiguration: 'OFF',
|
||||
deletionProtection: 'INACTIVE',
|
||||
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
|
||||
})
|
||||
})
|
||||
|
||||
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})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user