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:
Zakaria
2026-10-23 12:12:40 -04:00
parent ece36e4a34
commit 4156afccfe
12 changed files with 930 additions and 5 deletions
@@ -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})
})
})