5363e1d5dc
The OOBE flow on the web portal calls this endpoint to create a fresh
`clients` row bound to the Better Auth user's email when the SSO
bridge returns 404. Returns 201 on success, 409 if a client with that
email already exists (portal-selection case), 401/503 on auth issues,
400 on invalid body.
The OOBE success path navigates the user back to `/` and lets the
existing `session-from-auth` re-bridge; the new client is now
resolvable by email, so the bridge mints a real portal session.
Tests cover: 401 (no session), 400 (zod), 201 + persisted values
(name trimmed, optional fields normalized to null), 409 (existing
client or unique-constraint race), 503 (auth not configured).
Paired with the web PR on `feature/2357-p2-sso-to-oobe-routing`.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit cdeebec021)
202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
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<typeof vi.fn>;
|
|
let mockGetSession: ReturnType<typeof vi.fn>;
|
|
let existingClientRow: Record<string, unknown> | null = null;
|
|
let insertedClientValues: Record<string, unknown> | 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<string, unknown>) => {
|
|
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<string, unknown>).name).toBe("New SSO User");
|
|
expect((insertedClientValues as Record<string, unknown>).email).toBe(NEW_USER_EMAIL);
|
|
expect((insertedClientValues as Record<string, unknown>).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<string, unknown>).phone).toBeNull();
|
|
expect((insertedClientValues as Record<string, unknown>).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);
|
|
});
|
|
});
|