trails/apps/journal/app/routes/api.auth.register.test.ts
Ullrich Schäfer 4de6c86d41
fix(journal): architectural audit omnibus
Addresses 8 issues from the Journal architecture audit:

1. DB indexes on routes.ownerId + activities.ownerId. Listing queries on
   these tables were full table scans; adds composite indexes matching
   the order-by columns (updatedAt/startedAt/createdAt).
2. Zod validation on /api/auth/register body. Previously the action
   destructured request.json() with zero schema validation.
3. N+1 GeoJSON batch fetch collapsed to a single ANY($1::text[]) query
   in both routes.server and activities.server.
4. Webhook envelope validation in /api/sync/webhook/:provider.
5. AbortSignal.timeout(30s) on all external fetches (Komoot, Wahoo) via
   a new fetchWithTimeout helper in lib/http.server.ts.
6. .limit(100) on listPublicRoutesForOwner / listPublicActivitiesForOwner.
9. Welcome email moved off fire-and-forget onto a pg-boss job with
   retryLimit: 3 (send-welcome-email).
10. process.env.ORIGIN ?? "http://localhost:3000" centralized into
    lib/config.server.ts::getOrigin() across 14 call sites.

Issues 7 (centralized apiError/auth guards across 60+ route files) and
8 (split .server.ts boundaries across 20+ route files) intentionally
deferred — both are pure refactors that would balloon this PR past
reviewability and warrant their own focused PRs.

Tests added:
- lib/config.server.test.ts (2 cases)
- lib/http.server.test.ts (3 cases — timeout abort, success passthrough,
  caller-signal composition)
- routes/api.sync.webhook.$provider.test.ts (6 cases)
- routes/api.auth.register.test.ts (7 cases — schema rejection paths +
  the new welcome-email enqueue assertion)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:33 +02:00

151 lines
4.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const mocks = vi.hoisted(() => ({
startRegistration: vi.fn(),
finishRegistration: vi.fn(),
registerWithMagicLink: vi.fn(),
addPasskeyStart: vi.fn(),
addPasskeyFinish: vi.fn(),
sendMagicLink: vi.fn(),
enqueueOptional: vi.fn(),
completeAuth: vi.fn(),
}));
const {
startRegistration,
finishRegistration,
registerWithMagicLink,
addPasskeyStart,
addPasskeyFinish,
sendMagicLink,
enqueueOptional,
completeAuth,
} = mocks;
vi.mock("~/lib/auth.server", () => ({
startRegistration: mocks.startRegistration,
finishRegistration: mocks.finishRegistration,
registerWithMagicLink: mocks.registerWithMagicLink,
addPasskeyStart: mocks.addPasskeyStart,
addPasskeyFinish: mocks.addPasskeyFinish,
}));
vi.mock("~/lib/auth/completion.server", () => ({ completeAuth: mocks.completeAuth }));
vi.mock("~/lib/email.server", () => ({ sendMagicLink: mocks.sendMagicLink }));
vi.mock("~/lib/boss.server", () => ({ enqueueOptional: mocks.enqueueOptional }));
import { action } from "./api.auth.register.ts";
function makeRequest(body: unknown): Request {
return new Request("http://test.local/api/auth/register", {
method: "POST",
body: typeof body === "string" ? (body as string) : JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
async function callAction(body: unknown): Promise<{ status: number; json: Record<string, unknown> }> {
const res = await action({
request: makeRequest(body),
params: {},
context: {} as unknown,
} as never);
if (res instanceof Response) {
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
}
const d = res as { data: Record<string, unknown>; init?: { status?: number } };
return { status: d.init?.status ?? 200, json: d.data };
}
describe("POST /api/auth/register — input validation", () => {
beforeEach(() => {
startRegistration.mockReset();
finishRegistration.mockReset();
registerWithMagicLink.mockReset();
addPasskeyStart.mockReset();
addPasskeyFinish.mockReset();
sendMagicLink.mockReset();
enqueueOptional.mockReset();
completeAuth.mockReset();
});
it("rejects malformed JSON with 400", async () => {
const { status, json } = await callAction("not-json{");
expect(status).toBe(400);
expect(json.error).toMatch(/Invalid JSON/);
expect(startRegistration).not.toHaveBeenCalled();
});
it("rejects unknown step with 400", async () => {
const { status } = await callAction({ step: "bogus" });
expect(status).toBe(400);
expect(startRegistration).not.toHaveBeenCalled();
});
it("rejects non-string email with 400 (zod type check)", async () => {
const { status } = await callAction({
step: "start",
email: 12345,
username: "alice",
termsAccepted: true,
termsVersion: "v1",
});
expect(status).toBe(400);
expect(startRegistration).not.toHaveBeenCalled();
});
it("rejects start without termsAccepted with 400", async () => {
const { status, json } = await callAction({
step: "start",
email: "a@b.co",
username: "alice",
});
expect(status).toBe(400);
expect(json.error).toMatch(/Terms of Service/);
expect(startRegistration).not.toHaveBeenCalled();
});
it("rejects start without email with a missing-field error", async () => {
const { status, json } = await callAction({
step: "start",
username: "alice",
termsAccepted: true,
termsVersion: "v1",
});
expect(status).toBe(400);
expect(json.error).toMatch(/email/i);
});
it("accepts a well-formed start payload and calls downstream", async () => {
startRegistration.mockResolvedValue({ options: { foo: 1 }, userId: "u1" });
const { status, json } = await callAction({
step: "start",
email: "a@b.co",
username: "alice",
termsAccepted: true,
termsVersion: "v1",
});
expect(status).toBe(200);
expect(json.step).toBe("challenge");
expect(startRegistration).toHaveBeenCalledWith("a@b.co", "alice");
});
it("enqueues welcome email job on finish (no fire-and-forget)", async () => {
finishRegistration.mockResolvedValue("new-user-id");
completeAuth.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
const { status } = await callAction({
step: "finish",
userId: "u1",
email: "a@b.co",
username: "alice",
challenge: "chal",
response: { id: "x" },
termsAccepted: true,
termsVersion: "v1",
});
expect(status).toBe(200);
expect(enqueueOptional).toHaveBeenCalledWith(
"send-welcome-email",
{ email: "a@b.co", username: "alice" },
expect.objectContaining({ source: "register" }),
);
});
});