From d376d586fea9f33a6286af5f75dcb89e70b933d7 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 7 Mar 2026 20:06:13 +0100 Subject: [PATCH] fix(keys): add DB fallback to updateEmailByCustomer, updateKeyEmail, and recover route (BUG-108, BUG-109, BUG-110) - updateEmailByCustomer: DB fallback when stripe_customer_id not in cache - updateKeyEmail: DB fallback when key not in cache - POST /v1/recover: DB fallback when email not in cache (was only on verify) - 6 TDD tests added (keys-email-update.test.ts, recover-initial-db-fallback.test.ts) - 547 tests total, all passing --- src/__tests__/keys-email-update.test.ts | 109 ++++++++++++++++++ .../recover-initial-db-fallback.test.ts | 105 +++++++++++++++++ src/routes/recover.ts | 12 ++ src/services/keys.ts | 64 +++++++++- 4 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/keys-email-update.test.ts create mode 100644 src/__tests__/recover-initial-db-fallback.test.ts diff --git a/src/__tests__/keys-email-update.test.ts b/src/__tests__/keys-email-update.test.ts new file mode 100644 index 0000000..bdc4192 --- /dev/null +++ b/src/__tests__/keys-email-update.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Override the global setup.ts mock for keys — we need the REAL implementation +vi.unmock("../services/keys.js"); + +vi.mock("../services/db.js", () => ({ + default: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + pool: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + queryWithRetry: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connectWithRetry: vi.fn().mockResolvedValue(undefined), + initDatabase: vi.fn().mockResolvedValue(undefined), + cleanupStaleData: vi.fn(), + isTransientError: vi.fn(), +})); + +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import { queryWithRetry } from "../services/db.js"; +import { updateEmailByCustomer, updateKeyEmail } from "../services/keys.js"; + +const mockQuery = vi.mocked(queryWithRetry); + +describe("updateEmailByCustomer DB fallback (BUG-108)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true and updates DB when key is NOT in cache but IS in DB", async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [{ + key: "df_pro_abc123", + tier: "pro", + email: "old@example.com", + created_at: "2026-01-01T00:00:00.000Z", + stripe_customer_id: "cus_456", + }], + rowCount: 1, + } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); // UPDATE + + const result = await updateEmailByCustomer("cus_456", "new@example.com"); + + expect(result).toBe(true); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("SELECT"), + expect.arrayContaining(["cus_456"]) + ); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("UPDATE"), + expect.arrayContaining(["new@example.com", "cus_456"]) + ); + }); + + it("returns false when key is NOT in cache AND NOT in DB", async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + + const result = await updateEmailByCustomer("cus_nonexistent", "new@example.com"); + + expect(result).toBe(false); + const updateCalls = mockQuery.mock.calls.filter(c => (c[0] as string).includes("UPDATE")); + expect(updateCalls).toHaveLength(0); + }); +}); + +describe("updateKeyEmail DB fallback (BUG-109)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true and updates DB when key is NOT in cache but IS in DB", async () => { + mockQuery + .mockResolvedValueOnce({ + rows: [{ + key: "df_pro_xyz789", + tier: "pro", + email: "old@example.com", + created_at: "2026-01-01T00:00:00.000Z", + stripe_customer_id: "cus_789", + }], + rowCount: 1, + } as any) + .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); // UPDATE + + const result = await updateKeyEmail("df_pro_xyz789", "new@example.com"); + + expect(result).toBe(true); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("SELECT"), + expect.arrayContaining(["df_pro_xyz789"]) + ); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("UPDATE"), + expect.arrayContaining(["new@example.com", "df_pro_xyz789"]) + ); + }); + + it("returns false when key is NOT in cache AND NOT in DB", async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + + const result = await updateKeyEmail("df_pro_nonexistent", "new@example.com"); + + expect(result).toBe(false); + const updateCalls = mockQuery.mock.calls.filter(c => (c[0] as string).includes("UPDATE")); + expect(updateCalls).toHaveLength(0); + }); +}); diff --git a/src/__tests__/recover-initial-db-fallback.test.ts b/src/__tests__/recover-initial-db-fallback.test.ts new file mode 100644 index 0000000..bcb5951 --- /dev/null +++ b/src/__tests__/recover-initial-db-fallback.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; +import express from "express"; + +// Mock dependencies +vi.mock("../services/keys.js", () => ({ + getAllKeys: vi.fn().mockReturnValue([]), + isValidKey: vi.fn(), + getKeyInfo: vi.fn(), + isProKey: vi.fn(), + createFreeKey: vi.fn(), + createProKey: vi.fn(), + downgradeByCustomer: vi.fn(), + findKeyByCustomerId: vi.fn(), + loadKeys: vi.fn(), + updateKeyEmail: vi.fn(), + updateEmailByCustomer: vi.fn(), +})); + +vi.mock("../services/db.js", () => ({ + default: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + pool: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + queryWithRetry: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connectWithRetry: vi.fn().mockResolvedValue(undefined), + initDatabase: vi.fn().mockResolvedValue(undefined), + cleanupStaleData: vi.fn(), + isTransientError: vi.fn(), +})); + +vi.mock("../services/verification.js", () => ({ + createPendingVerification: vi.fn().mockResolvedValue({ code: "123456" }), + verifyCode: vi.fn(), +})); + +vi.mock("../services/email.js", () => ({ + sendVerificationEmail: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import { getAllKeys } from "../services/keys.js"; +import { queryWithRetry } from "../services/db.js"; +import { createPendingVerification } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { recoverRouter } from "../routes/recover.js"; + +const mockGetAllKeys = vi.mocked(getAllKeys); +const mockQuery = vi.mocked(queryWithRetry); +const mockCreatePending = vi.mocked(createPendingVerification); +const mockSendEmail = vi.mocked(sendVerificationEmail); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use("/v1/recover", recoverRouter); + return app; +} + +describe("POST /v1/recover DB fallback (BUG-110)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAllKeys.mockReturnValue([]); + mockCreatePending.mockResolvedValue({ code: "123456" } as any); + mockSendEmail.mockResolvedValue(undefined); + }); + + it("sends verification email via DB fallback when key not in cache but exists in DB", async () => { + mockGetAllKeys.mockReturnValue([]); // empty cache + mockQuery.mockResolvedValueOnce({ + rows: [{ key: "df_pro_abc123" }], + rowCount: 1, + } as any); + + const app = createApp(); + const res = await request(app) + .post("/v1/recover") + .send({ email: "user@example.com" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovery_sent"); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("SELECT"), + expect.arrayContaining(["user@example.com"]) + ); + expect(mockCreatePending).toHaveBeenCalledWith("user@example.com"); + expect(mockSendEmail).toHaveBeenCalled(); + }); + + it("does NOT send verification email when key not in cache AND not in DB", async () => { + mockGetAllKeys.mockReturnValue([]); + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + + const app = createApp(); + const res = await request(app) + .post("/v1/recover") + .send({ email: "nobody@example.com" }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("recovery_sent"); + expect(mockCreatePending).not.toHaveBeenCalled(); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/recover.ts b/src/routes/recover.ts index b1f4841..d861f46 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -69,6 +69,18 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => { const userKey = keys.find(k => k.email === cleanEmail); if (!userKey) { + // DB fallback: cache may be stale in multi-replica setups + const dbResult = await queryWithRetry( + "SELECT key FROM api_keys WHERE email = $1 LIMIT 1", + [cleanEmail] + ); + if (dbResult.rows.length > 0) { + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); + }); + logger.info({ email: cleanEmail }, "recover: cache miss, sent recovery via DB fallback"); + } res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); return; } diff --git a/src/services/keys.ts b/src/services/keys.ts index 79cc841..9de3063 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -186,16 +186,72 @@ export function getAllKeys(): ApiKey[] { export async function updateKeyEmail(apiKey: string, newEmail: string): Promise { const entry = keysCache.find((k) => k.key === apiKey); - if (!entry) return false; - entry.email = newEmail; + if (entry) { + entry.email = newEmail; + await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); + return true; + } + + // DB fallback: key may exist on another pod's cache or after a restart + logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: cache miss, falling back to DB"); + const result = await queryWithRetry( + "SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE key = $1 LIMIT 1", + [apiKey] + ); + if (result.rows.length === 0) { + logger.warn({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: key not found in cache or DB"); + return false; + } + + const row = result.rows[0]; await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); + + // Hydrate local cache + const cached: ApiKey = { + key: row.key, + tier: row.tier as "free" | "pro", + email: newEmail, + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + stripeCustomerId: row.stripe_customer_id || undefined, + }; + keysCache.push(cached); + + logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: updated via DB fallback"); return true; } export async function updateEmailByCustomer(stripeCustomerId: string, newEmail: string): Promise { const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId); - if (!entry) return false; - entry.email = newEmail; + if (entry) { + entry.email = newEmail; + await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]); + return true; + } + + // DB fallback: key may exist on another pod's cache or after a restart + logger.info({ stripeCustomerId }, "updateEmailByCustomer: cache miss, falling back to DB"); + const result = await queryWithRetry( + "SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1", + [stripeCustomerId] + ); + if (result.rows.length === 0) { + logger.warn({ stripeCustomerId }, "updateEmailByCustomer: customer not found in cache or DB"); + return false; + } + + const row = result.rows[0]; await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]); + + // Hydrate local cache + const cached: ApiKey = { + key: row.key, + tier: row.tier as "free" | "pro", + email: newEmail, + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + stripeCustomerId: row.stripe_customer_id || undefined, + }; + keysCache.push(cached); + + logger.info({ stripeCustomerId, key: row.key }, "updateEmailByCustomer: updated via DB fallback"); return true; }