diff --git a/src/__tests__/portalClientsFromAuth.test.ts b/src/__tests__/portalClientsFromAuth.test.ts new file mode 100644 index 0000000..dd2e899 --- /dev/null +++ b/src/__tests__/portalClientsFromAuth.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const NEW_USER_EMAIL = "new-sso-user@example.com"; +const NEW_USER_NAME = "New SSO User"; +const NEW_USER_ID = "11111111-2222-3333-4444-555555555555"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-new", + email: NEW_USER_EMAIL, + name: NEW_USER_NAME, + }, + session: { + id: "ba-session-new", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let existingClientRow: Record | null = null; +let insertedClientValues: Record | null = null; +let insertShouldThrow: { code?: string } | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(existingClientRow ? [existingClientRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => { + if (insertShouldThrow) { + const err = new Error("unique violation") as Error & { code?: string }; + err.code = insertShouldThrow.code; + throw err; + } + return { + returning: () => { + if (table._name === "clients") { + insertedClientValues = { id: NEW_USER_ID, ...vals }; + return [insertedClientValues]; + } + return []; + }, + }; + }, + }), + }), + clients, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/clients-from-auth (GRO-2359)", () => { + beforeEach(() => { + existingClientRow = null; + insertedClientValues = null; + insertShouldThrow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session is present", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test User" }), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when body fails zod validation (empty name)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + expect(res.status).toBe(400); + }); + + it("creates a new client row bound to the auth user's email and returns 201", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: " New SSO User ", + phone: "555-1234", + address: "1 Main St", + notes: "test note", + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + id: NEW_USER_ID, + name: "New SSO User", + email: NEW_USER_EMAIL, + }); + // Trim must be applied to the persisted values. + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).name).toBe("New SSO User"); + expect((insertedClientValues as Record).email).toBe(NEW_USER_EMAIL); + expect((insertedClientValues as Record).phone).toBe("555-1234"); + }); + + it("normalizes empty optional fields to null on insert", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test", phone: "", address: " " }), + }); + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).phone).toBeNull(); + expect((insertedClientValues as Record).address).toBeNull(); + }); + + it("returns 409 when a client row already exists for this email", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already exists/i); + expect(insertedClientValues).toBeNull(); + }); + + it("returns 409 on unique constraint race (23505)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + insertShouldThrow = { code: "23505" }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(503); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 487861d..17425a3 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -147,6 +147,114 @@ portalRouter.post("/session-from-auth", async (c) => { ); }); +// GRO-2359 — register a brand-new SSO user. The post-auth handler in the +// web portal redirects here when `session-from-auth` returns 404, so the +// OOBE can complete a customer record for the new user. Auth is via the +// Better Auth session (same shape as `session-from-auth`), so this is +// registered BEFORE the `validatePortalSession` middleware. +// +// Contract: +// POST /api/portal/clients-from-auth +// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null } +// 201: { id, name, email } +// 400: invalid body (zod failure) +// 401: no Better Auth session +// 409: a `clients` row already exists for this email (portal selection case) +// 500: insert failed +// +// We do NOT auto-link the user's auth account to the new client row; the +// existing `session-from-auth` endpoint re-resolves the row by email on the +// next call, so the OOBE's success path just navigates the user back to +// `/` and lets the bridge mint a portal session. +const createClientFromAuthSchema = z.object({ + name: z.string().min(1).max(200), + phone: z.string().max(50).nullish(), + address: z.string().max(500).nullish(), + notes: z.string().max(2000).nullish(), +}); + +portalRouter.post( + "/clients-from-auth", + zValidator("json", createClientFromAuthSchema), + async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = c.req.valid("json"); + const db = getDb(); + + // Pre-check: if a client already exists for this email, return 409 so + // the OOBE can render the "portal selection" message (the user needs + // to contact their groomer to link the new SSO identity to the + // pre-existing customer record). We don't return the existing row to + // avoid leaking PII about other accounts. + const [existing] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (existing) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + + let row; + try { + [row] = await db + .insert(clients) + .values({ + name: body.name.trim(), + email: session.user.email, + phone: body.phone?.trim() || null, + address: body.address?.trim() || null, + notes: body.notes?.trim() || null, + }) + .returning(); + } catch (err) { + // Concurrent insert from a parallel OOBE submit — treat as 409. + if ( + err instanceof Error && + "code" in err && + (err as { code?: string }).code === "23505" + ) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + throw err; + } + + if (!row) { + return c.json({ error: "Failed to create client" }, 500); + } + + return c.json( + { + id: row.id, + name: row.name, + email: row.email, + }, + 201, + ); + }, +); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit);