diff --git a/packages/hono-kit/docs/api-root.md b/packages/hono-kit/docs/api-root.md index 7562aec..817be67 100644 --- a/packages/hono-kit/docs/api-root.md +++ b/packages/hono-kit/docs/api-root.md @@ -19,10 +19,10 @@ re-export: retaining it would make MySQL runtime and declaration resolution mand | `getAuthenticationSecret(options, secretId)` / `AwsSecretsOptions` | Fetch a secret from AWS Secrets Manager (SigV4 `fetch`, per-isolate cache). | | `getTemporaryCredentials(options)` / `GetTemporaryCredentialsOptions` / `StsCredentials` | STS `AssumeRole` via SigV4 `fetch` (global `sts.amazonaws.com`); returns temporary credentials for browser S3 uploads. | | `getCloudFrontSignedUrl(url, privateKeyPem, keyPairId, dateLessThan)` | CloudFront signed URL (canned policy, RSA-SHA1, URL-safe base64) — Web Crypto reimpl of `@aws-sdk/cloudfront-signer`, byte-identical query order. | -| `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `deleteUser`). | +| `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` / `FirebaseUserRecord` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `getUsers`, `getUserByEmail`, `deleteUser`); account records include optional email and disabled status. | | `createRemoteFirebaseVerifier(projectId)` | Convenience factory: production verifier with a cached remote JWKS (verification only). | -| `createServiceAccountVerifier(serviceAccountJson)` | Cached verifier built from a service-account JSON, **with `IdentityToolkit`** (getUser/deleteUser). One per isolate, re-created only when the SA JSON changes. | -| `IdentityToolkit` / `ServiceAccount` / `SECURETOKEN_JWK_URL` | Identity Toolkit REST client + constants for `getUser` / `deleteUser`. | +| `createServiceAccountVerifier(serviceAccountJson)` | Cached verifier built from a service-account JSON, **with `IdentityToolkit`** (`getUser`, `getUsers`, `getUserByEmail`, `deleteUser`). One per isolate, re-created only when the SA JSON changes. | +| `IdentityToolkit` / `ServiceAccount` / `SECURETOKEN_JWK_URL` | Identity Toolkit REST client + constants for UID/email account lookup and deletion. | | `getUserProtocol(c)` / `IUserProtocol` | Read client IP / UA (`CF-Connecting-IP` → `X-Forwarded-For`). | | `getAppInfo(c)` / `AppInfo` | Read `x-amz-meta-version` / `x-amz-meta-uuid`. | | `resolveAppEnv(env)` / `isProductionEnv(env)` / `AppEnv` | Resolve `'development'` / `'production'` from `env.APP_ENV` (defaults to `'production'` for safety). | diff --git a/packages/hono-kit/docs/api-testing.md b/packages/hono-kit/docs/api-testing.md index 9c8ad83..0d6f592 100644 --- a/packages/hono-kit/docs/api-testing.md +++ b/packages/hono-kit/docs/api-testing.md @@ -29,7 +29,7 @@ For candidate tarball installation, see [Development](./development.md). | Export | Description | | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `FakeFirebaseVerifier` | In-memory `FirebaseVerifier` for offline route tests (`register` / `verifyIdToken` / `getUser` / `deleteUser`). | +| `FakeFirebaseVerifier` | In-memory token verification and account lookup, including `getUserByEmail` and configurable `users`. | | `authHeaders(token, opts?)` | Build interceptor-compatible auth headers for requests. | | `registerFirebaseToken(firebase, uid, record?, token?)` | Register a token in a `FakeFirebaseVerifier` (no DB). | | `provisionUser(pool, firebase, opts)` | Register a token and provision a conventional `users(id, firebase_uid, agree)` row; returns the user id (idempotent). | @@ -37,6 +37,8 @@ For candidate tarball installation, see [Development](./development.md). | `fakeApiList` / `fakePaymentIntent` / `fakeStripeEvent` / `fakeCheckoutSession` / `fakeCustomer` / `fakePrice` / `fakeSubscription` | Stripe object fixtures with sensible defaults, overridable per test. | | `fakeKv()` / `fakeQueue()` / `FakeQueue` | In-memory Workers KV / Queues producer doubles (`sent` + `batchCount` on queues for subrequest-bound assertions). | +`FakeFirebaseVerifier.register()` seeds an account only when its UID is not already configured. Update `users` directly to model account changes without changing issued tokens. For compatibility, UID lookups return `{ uid }` for unconfigured UIDs; `deleteUser()` only records calls in `deleted`. + ## Next step Return to [Testing and Operations](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/testing-operations), or see diff --git a/packages/hono-kit/docs/http-auth.md b/packages/hono-kit/docs/http-auth.md index bb573a7..e0ec050 100644 --- a/packages/hono-kit/docs/http-auth.md +++ b/packages/hono-kit/docs/http-auth.md @@ -22,7 +22,9 @@ app.get('/items', validate('query', querySchema), async (c) => { ## Authentication -`createAuthMiddleware()` reads a token header, verifies a Firebase ID token, optionally resolves the application user ID, and stores the result on the Hono context. Use `createRemoteFirebaseVerifier(projectId)` for cached remote JWKS verification or `createServiceAccountVerifier()` when Identity Toolkit `getUser` and `deleteUser` operations are required. +`createAuthMiddleware()` reads a token header, verifies a Firebase ID token, optionally resolves the application user ID, and stores the result on the Hono context. Use `createRemoteFirebaseVerifier(projectId)` for cached remote JWKS verification or `createServiceAccountVerifier()` when Identity Toolkit `getUser`, `getUsers`, `getUserByEmail`, and `deleteUser` operations are required. + +`getUser`, `getUsers`, and `getUserByEmail` return `FirebaseUserRecord` (`uid`, optional `email` and `disabled`). The kit returns disabled accounts; callers decide whether to reject them. Account creation time is not fetched. Email lookup uses the primary email and shares the OAuth token cache with UID lookups. Missing users return `null` (or are omitted from batches), while provider HTTP failures throw, including UID lookups that previously returned an empty result on failure. Keep identity, reauthentication, and feature credential failures distinct with the stable auth-failure body helpers. diff --git a/packages/hono-kit/src/firebase/firebase-verifier.ts b/packages/hono-kit/src/firebase/firebase-verifier.ts index dad8f81..a83d28a 100644 --- a/packages/hono-kit/src/firebase/firebase-verifier.ts +++ b/packages/hono-kit/src/firebase/firebase-verifier.ts @@ -19,6 +19,16 @@ export interface DecodedIdToken { [claim: string]: unknown; } +/** Current account attributes returned by Firebase Authentication lookup. */ +export interface FirebaseUserRecord { + /** Stable Firebase account identifier. */ + uid: string; + /** Current primary email, when configured. */ + email?: string; + /** Whether sign-in is disabled for this account. */ + disabled?: boolean; +} + /** * Abstract authentication boundary that replaces the `firebase-admin` Auth surface * (`verifyIdToken` / `getUser` / `deleteUser`) for environments where the Node SDK cannot @@ -41,6 +51,9 @@ export interface FirebaseVerifier { * @throws If the token signature, issuer, audience, expiry, or other required claims are invalid. */ verifyIdToken(idToken: string): Promise; + /** Look up an account by primary email; return null if absent and throw on provider failures. */ + getUserByEmail(email: string): Promise; + /** * Look up a user record by uid. * @@ -50,7 +63,7 @@ export interface FirebaseVerifier { * @returns The user's `uid` and optional `email`, or `null` when the user does not exist. * @throws If the backing user-management service is not configured or the lookup fails. */ - getUser(uid: string): Promise<{ uid: string; email?: string } | null>; + getUser(uid: string): Promise; /** * Look up multiple user records by uid in as few requests as the backing service allows. * @@ -62,7 +75,7 @@ export interface FirebaseVerifier { * simply absent from the result (never `null` entries). * @throws If the backing user-management service is not configured or the lookup fails. */ - getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]>; + getUsers(uids: string[]): Promise; /** * Delete a user by uid. * diff --git a/packages/hono-kit/src/firebase/identity-toolkit.spec.ts b/packages/hono-kit/src/firebase/identity-toolkit.spec.ts index 2d6592b..2027341 100644 --- a/packages/hono-kit/src/firebase/identity-toolkit.spec.ts +++ b/packages/hono-kit/src/firebase/identity-toolkit.spec.ts @@ -28,6 +28,86 @@ describe('IdentityToolkit', () => { vi.restoreAllMocks(); }); + it('lookupByEmail uses the primary email and shares the OAuth token cache with UID lookups', async () => { + const request = stubFetch((url) => + url === TOKEN_URL + ? tokenResponse() + : Response.json({ + users: [ + { localId: 'other', email: 'other@example.com' }, + { localId: 'uid1', email: 'User@example.com' }, + ], + }), + ); + const client = new IdentityToolkit(await makeServiceAccount()); + await expect(client.lookupByEmail(' user@example.com ', NOW)).resolves.toEqual({ + uid: 'uid1', + email: 'User@example.com', + }); + const call = request.mock.calls.find(([url]) => url.endsWith('accounts:lookup')); + expect(JSON.parse(call?.[1]?.body as string)).toEqual({ email: ['user@example.com'] }); + await client.lookup('uid1', NOW + 1); + expect(request.mock.calls.filter(([url]) => url === TOKEN_URL)).toHaveLength(1); + }); + + it('lookupByEmail returns null for missing or invalid addresses', async () => { + const client = new IdentityToolkit(await makeServiceAccount()); + const request = stubFetch((url) => (url === TOKEN_URL ? tokenResponse() : Response.json({ users: [] }))); + await expect(client.lookupByEmail(' ', NOW)).resolves.toBeNull(); + expect(request).not.toHaveBeenCalled(); + await expect(client.lookupByEmail('missing@example.com', NOW)).resolves.toBeNull(); + stubFetch(() => Response.json({ error: { message: 'INVALID_EMAIL' } }, { status: 400 })); + await expect(client.lookupByEmail('invalid', NOW)).resolves.toBeNull(); + }); + + it.each([400, 403, 429, 500])('lookupByEmail throws for provider failures (%i)', async (status) => { + stubFetch((url) => + url === TOKEN_URL + ? tokenResponse() + : Response.json({ error: { message: 'CONFIGURATION_NOT_FOUND' } }, { status }), + ); + const client = new IdentityToolkit(await makeServiceAccount()); + await expect(client.lookupByEmail('user@example.com', NOW)).rejects.toThrow( + `Identity Toolkit lookup failed: ${status}`, + ); + }); + + it('lookupByEmail rejects malformed records without including account values', async () => { + stubFetch((url) => + url === TOKEN_URL ? tokenResponse() : Response.json({ users: [{ email: 'private@example.com' }] }), + ); + const client = new IdentityToolkit(await makeServiceAccount()); + await expect(client.lookupByEmail('private@example.com', NOW)).rejects.toThrow( + 'Invalid Identity Toolkit lookup response', + ); + }); + + it('returns disabled metadata for UID, batch and email lookups', async () => { + stubFetch((url) => + url === TOKEN_URL + ? tokenResponse() + : Response.json({ + users: [{ localId: 'uid1', email: 'a@b.c', disabled: true }], + }), + ); + const client = new IdentityToolkit(await makeServiceAccount()); + const expected = { uid: 'uid1', email: 'a@b.c', disabled: true }; + await expect(client.lookup('uid1', NOW)).resolves.toEqual(expected); + await expect(client.lookupMany(['uid1', 'uid1'], NOW)).resolves.toEqual([expected]); + await expect(client.lookupByEmail('a@b.c', NOW)).resolves.toEqual(expected); + }); + + it('uses global fetch without a client receiver in Workers', async () => { + vi.stubGlobal('fetch', async function (this: unknown, input: Parameters[0]) { + if (this !== undefined && this !== globalThis) { + throw new TypeError('Illegal invocation'); + } + return input === TOKEN_URL ? tokenResponse() : Response.json({ users: [] }); + }); + const client = new IdentityToolkit(await makeServiceAccount()); + await expect(client.lookup('missing', NOW)).resolves.toBeNull(); + }); + it('lookup はアクセストークンを取得してユーザーを返す', async () => { const fetchMock = stubFetch((url) => { if (url === TOKEN_URL) { @@ -52,10 +132,10 @@ describe('IdentityToolkit', () => { await expect(client.lookup('missing', NOW)).resolves.toBeNull(); }); - it('lookup が 4xx なら null', async () => { + it('lookup throws on provider failure instead of treating it as absence', async () => { stubFetch((url) => (url === TOKEN_URL ? tokenResponse() : new Response('forbidden', { status: 403 }))); const client = new IdentityToolkit(await makeServiceAccount()); - await expect(client.lookup('uid1', NOW)).resolves.toBeNull(); + await expect(client.lookup('uid1', NOW)).rejects.toThrow('Identity Toolkit lookup failed: 403'); }); it('アクセストークンを isolate 内でキャッシュする(token 交換は 1 回)', async () => { diff --git a/packages/hono-kit/src/firebase/identity-toolkit.ts b/packages/hono-kit/src/firebase/identity-toolkit.ts index 82b75d2..b56f74b 100644 --- a/packages/hono-kit/src/firebase/identity-toolkit.ts +++ b/packages/hono-kit/src/firebase/identity-toolkit.ts @@ -1,4 +1,6 @@ import { SignJWT, importPKCS8 } from 'jose'; +import { z } from 'zod'; +import type { FirebaseUserRecord } from './firebase-verifier.js'; /** * Minimal service-account credential consumed by {@link IdentityToolkit}. @@ -90,28 +92,60 @@ export class IdentityToolkit { return json.access_token; } - /** - * Call the `accounts:lookup` endpoint for a single chunk of `localId`s. - * - * @param localIds - Up to {@link LOOKUP_CHUNK_SIZE} `localId`s to look up in one request. - * @param nowSeconds - The current Unix time in seconds, used for access-token caching. - * @returns The raw `users` entries returned by the endpoint (empty when the request is - * unsuccessful or no matching users are returned). - * @throws If acquiring an access token fails. - * @internal - */ - private async lookupChunk(localIds: string[], nowSeconds: number): Promise<{ localId: string; email?: string }[]> { + /** Fetch account attributes; provider failures are distinct from absent users. */ + private async lookupAccounts( + criteria: { localId: string[] } | { email: string[] }, + nowSeconds: number, + ): Promise { const token = await this.getAccessToken(nowSeconds); const res = await fetch(`${IDENTITY_TOOLKIT}/projects/${this.sa.project_id}/accounts:lookup`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, - body: JSON.stringify({ localId: localIds }), + body: JSON.stringify(criteria), }); if (!res.ok) { - return []; + if (res.status === 400 && 'email' in criteria) { + const error = z + .object({ error: z.object({ message: z.string() }) }) + .safeParse(await res.json().catch(() => undefined)); + if (error.success && error.data.error.message === 'INVALID_EMAIL') { + return []; + } + } + throw new Error(`Identity Toolkit lookup failed: ${res.status}`); } - const json = (await res.json()) as { users?: { localId: string; email?: string }[] }; - return json.users ?? []; + const parsed = z + .object({ + users: z + .array( + z.object({ + localId: z.string().min(1).max(128), + email: z.string().optional(), + disabled: z.boolean().optional(), + }), + ) + .optional(), + }) + .safeParse(await res.json().catch(() => undefined)); + if (!parsed.success) { + throw new Error('Invalid Identity Toolkit lookup response'); + } + return (parsed.data.users ?? []).map(({ localId, ...attributes }) => ({ uid: localId, ...attributes })); + } + + /** + * Look up a primary email (case-insensitive). Missing/invalid emails return null. + * @param email - Primary email; surrounding whitespace is ignored. + * @param nowSeconds - Current Unix time used for the shared OAuth token cache. + * @returns Account attributes, including disabled; provider failures throw. + */ + async lookupByEmail(email: string, nowSeconds: number): Promise { + const normalized = email.trim(); + if (!normalized) { + return null; + } + const users = await this.lookupAccounts({ email: [normalized] }, nowSeconds); + return users.find((user) => user.email?.toLowerCase() === normalized.toLowerCase()) ?? null; } /** @@ -119,13 +153,11 @@ export class IdentityToolkit { * * @param uid - The user's unique id (`localId`). * @param nowSeconds - The current Unix time in seconds, used for access-token caching. - * @returns The user's `uid` and optional `email`, or `null` when the request is unsuccessful - * or no matching user is returned. - * @throws If acquiring an access token fails. + * @returns The user's `uid` and optional `email`, or `null` when no matching user is returned. + * @throws If acquiring an access token or looking up accounts fails. */ - async lookup(uid: string, nowSeconds: number): Promise<{ uid: string; email?: string } | null> { - const users = await this.lookupChunk([uid], nowSeconds); - return users.length > 0 ? { uid: users[0].localId, email: users[0].email } : null; + async lookup(uid: string, nowSeconds: number): Promise { + return (await this.lookupMany([uid], nowSeconds))[0] ?? null; } /** @@ -140,19 +172,18 @@ export class IdentityToolkit { * @returns The `uid`/`email` of every matching user. Uids Firebase does not recognize are * simply absent from the result (never `null` entries), so callers can treat "missing from * the result" as "not found/invalid". - * @throws If acquiring an access token fails. + * @throws If acquiring an access token or looking up accounts fails. */ - async lookupMany(uids: string[], nowSeconds: number): Promise<{ uid: string; email?: string }[]> { + async lookupMany(uids: string[], nowSeconds: number): Promise { if (uids.length === 0) { return []; } - const results: { uid: string; email?: string }[] = []; - for (let i = 0; i < uids.length; i += LOOKUP_CHUNK_SIZE) { - const chunk = uids.slice(i, i + LOOKUP_CHUNK_SIZE); - const users = await this.lookupChunk(chunk, nowSeconds); - for (const user of users) { - results.push({ uid: user.localId, email: user.email }); - } + const results: FirebaseUserRecord[] = []; + const unique = [...new Set(uids)]; + for (let i = 0; i < unique.length; i += LOOKUP_CHUNK_SIZE) { + const chunk = unique.slice(i, i + LOOKUP_CHUNK_SIZE); + const users = await this.lookupAccounts({ localId: chunk }, nowSeconds); + results.push(...users.filter((user) => chunk.includes(user.uid))); } return results; } diff --git a/packages/hono-kit/src/firebase/jose-firebase-verifier.spec.ts b/packages/hono-kit/src/firebase/jose-firebase-verifier.spec.ts index 320dd53..acfbc66 100644 --- a/packages/hono-kit/src/firebase/jose-firebase-verifier.spec.ts +++ b/packages/hono-kit/src/firebase/jose-firebase-verifier.spec.ts @@ -192,6 +192,19 @@ describe('JoseFirebaseVerifier (Firebase ID-token validation requirements)', () }); }); + describe('getUserByEmail', () => { + it('requires an Identity Toolkit client', async () => { + const { verifier } = await makeVerifier(); + await expect(verifier.getUserByEmail('a@b.c')).rejects.toThrow('Identity Toolkit not configured'); + }); + it('delegates email lookup with the verifier clock', async () => { + const lookupByEmail = vi.fn(async () => ({ uid: 'uid1', email: 'a@b.c' })); + const { verifier } = await makeVerifier({ lookupByEmail } as unknown as IdentityToolkit); + await expect(verifier.getUserByEmail('a@b.c')).resolves.toEqual({ uid: 'uid1', email: 'a@b.c' }); + expect(lookupByEmail).toHaveBeenCalledWith('a@b.c', expect.any(Number)); + }); + }); + describe('getUsers', () => { it('throws when no Identity Toolkit is configured', async () => { const { verifier } = await makeVerifier(); diff --git a/packages/hono-kit/src/firebase/jose-firebase-verifier.ts b/packages/hono-kit/src/firebase/jose-firebase-verifier.ts index 61cf5b7..303ce66 100644 --- a/packages/hono-kit/src/firebase/jose-firebase-verifier.ts +++ b/packages/hono-kit/src/firebase/jose-firebase-verifier.ts @@ -1,6 +1,6 @@ import { jwtVerify } from 'jose'; import type { CryptoKey, JWK, JWTVerifyGetKey, KeyObject } from 'jose'; -import type { DecodedIdToken, FirebaseVerifier } from './firebase-verifier.js'; +import type { DecodedIdToken, FirebaseVerifier, FirebaseUserRecord } from './firebase-verifier.js'; import type { IdentityToolkit } from './identity-toolkit.js'; /** @@ -131,6 +131,18 @@ export class JoseFirebaseVerifier implements FirebaseVerifier { return { ...payload, uid: payload.sub, email: payload.email as string | undefined }; } + /** + * Look up an account by its primary email using the shared Identity Toolkit client. + * @param email - Primary email address. + * @returns uid/email or null if absent; provider failures throw. + */ + async getUserByEmail(email: string): Promise { + if (!this.opts.identity) { + throw new Error('Identity Toolkit not configured'); + } + return this.opts.identity.lookupByEmail(email, this.nowSeconds()); + } + /** * Look up a user record by uid via the Identity Toolkit REST API. * @@ -138,7 +150,7 @@ export class JoseFirebaseVerifier implements FirebaseVerifier { * @returns The user's `uid` and optional `email`, or `null` when the user does not exist. * @throws If no Identity Toolkit client was configured on this verifier. */ - async getUser(uid: string): Promise<{ uid: string; email?: string } | null> { + async getUser(uid: string): Promise { if (!this.opts.identity) { throw new Error('Identity Toolkit not configured'); } @@ -156,7 +168,7 @@ export class JoseFirebaseVerifier implements FirebaseVerifier { * simply absent from the result (never `null` entries). * @throws If no Identity Toolkit client was configured on this verifier. */ - async getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]> { + async getUsers(uids: string[]): Promise { if (!this.opts.identity) { throw new Error('Identity Toolkit not configured'); } diff --git a/packages/hono-kit/src/index.ts b/packages/hono-kit/src/index.ts index b07de68..784481a 100644 --- a/packages/hono-kit/src/index.ts +++ b/packages/hono-kit/src/index.ts @@ -227,7 +227,7 @@ export { getTemporaryCredentials } from './aws/sts.js'; export type { GetTemporaryCredentialsOptions, StsCredentials } from './aws/sts.js'; // firebase -export type { DecodedIdToken, FirebaseVerifier } from './firebase/firebase-verifier.js'; +export type { DecodedIdToken, FirebaseVerifier, FirebaseUserRecord } from './firebase/firebase-verifier.js'; export { FirebaseIdTokenValidationError, JoseFirebaseVerifier, diff --git a/packages/hono-kit/src/testing/fakes.spec.ts b/packages/hono-kit/src/testing/fakes.spec.ts index c766602..259df49 100644 --- a/packages/hono-kit/src/testing/fakes.spec.ts +++ b/packages/hono-kit/src/testing/fakes.spec.ts @@ -13,6 +13,29 @@ describe('FakeFirebaseVerifier', () => { await expect(fb.verifyIdToken('nope')).rejects.toThrow('invalid firebase id token'); }); + it('looks up current email and disabled status independently of stale token claims', async () => { + const fb = new FakeFirebaseVerifier(); + fb.register('old-token', { uid: 'u1', email: 'old@example.com' }); + await expect(fb.getUserByEmail('old@example.com')).resolves.toMatchObject({ uid: 'u1' }); + const current = { uid: 'u1', email: 'new@example.com', disabled: true }; + fb.users.set('u1', current); + fb.register('another-old-token', { uid: 'u1', email: 'old@example.com' }); + + await expect(fb.verifyIdToken('old-token')).resolves.toMatchObject({ email: 'old@example.com' }); + await expect(fb.getUserByEmail('old@example.com')).resolves.toBeNull(); + await expect(fb.getUserByEmail(' NEW@example.com ')).resolves.toEqual(current); + await expect(fb.getUser('u1')).resolves.toEqual(current); + await expect(fb.getUsers(['u1'])).resolves.toEqual([current]); + }); + + it('supports account lookup without registering a token', async () => { + const fb = new FakeFirebaseVerifier(); + fb.users.set('u1', { uid: 'u1', email: 'current@example.com' }); + await expect(fb.getUserByEmail('current@example.com')).resolves.toMatchObject({ uid: 'u1' }); + await expect(fb.getUserByEmail('missing@example.com')).resolves.toBeNull(); + await expect(fb.getUser('unconfigured')).resolves.toEqual({ uid: 'unconfigured' }); + }); + it('deleteUser は deleted に記録する', async () => { const fb = new FakeFirebaseVerifier(); await fb.deleteUser('u1'); diff --git a/packages/hono-kit/src/testing/fakes.ts b/packages/hono-kit/src/testing/fakes.ts index 7c59961..edb1fe5 100644 --- a/packages/hono-kit/src/testing/fakes.ts +++ b/packages/hono-kit/src/testing/fakes.ts @@ -3,7 +3,7 @@ import { createPoolDatabase as createPoolDatabaseCanonical, } from '@rdlabo/workers-mysql/testing'; import type { CreatePoolDatabaseOptions as CreatePoolDatabaseOptionsCanonical } from '@rdlabo/workers-mysql/testing'; -import type { DecodedIdToken, FirebaseVerifier } from '../firebase/firebase-verifier.js'; +import type { DecodedIdToken, FirebaseUserRecord, FirebaseVerifier } from '../firebase/firebase-verifier.js'; /** @deprecated Import from `@rdlabo/workers-mysql/testing` instead. */ export const createPoolDatabase: typeof createPoolDatabaseCanonical = createPoolDatabaseCanonical; @@ -16,12 +16,17 @@ export type CreatePoolDatabaseOptions = CreatePoolDatabaseOptionsCanon /** In-memory Firebase verifier for route tests. */ export class FakeFirebaseVerifier implements FirebaseVerifier { private readonly tokens = new Map(); + /** Current account attributes; update independently of registered token claims. */ + readonly users = new Map(); /** UIDs passed to {@link deleteUser}, in call order. */ readonly deleted: string[] = []; - /** Register a decoded token for later verification. */ + /** Register a decoded token, seeding the account only if it is not already configured. */ register(token: string, record: DecodedIdToken): void { this.tokens.set(token, record); + if (!this.users.has(record.uid)) { + this.users.set(record.uid, { uid: record.uid, email: record.email }); + } } /** Resolve a registered token. */ @@ -33,14 +38,20 @@ export class FakeFirebaseVerifier implements FirebaseVerifier { return record; } - /** Return a minimal fake user. */ - async getUser(uid: string): Promise<{ uid: string; email?: string } | null> { - return { uid }; + /** Find a current fake account by its primary email, including disabled metadata. */ + async getUserByEmail(email: string): Promise { + const normalized = email.trim().toLowerCase(); + return [...this.users.values()].find((user) => user.email?.toLowerCase() === normalized) ?? null; + } + + /** Return current attributes, falling back to a minimal user for legacy route tests. */ + async getUser(uid: string): Promise { + return this.users.get(uid) ?? { uid }; } - /** Return one minimal fake user per UID. */ - async getUsers(uids: string[]): Promise<{ uid: string; email?: string }[]> { - return uids.map((uid) => ({ uid })); + /** Return current attributes or a minimal fake user for each UID. */ + async getUsers(uids: string[]): Promise { + return uids.map((uid) => this.users.get(uid) ?? { uid }); } /** Record a fake user deletion. */