From d236196476670aa32584be95d219c2fd1b08e2c6 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 17:46:16 +0800 Subject: [PATCH] feat(web): getCurrentUser / requireUser / requireAdmin helpers Reads the session cookie from next/headers, verifies via auth-cookie, loads the operators row, returns the shape every existing call site expects (.id, .defaultTimezone, etc) plus the new .role and .username. getSeededOperator stays as a thin compat shim that delegates to getCurrentUser, so the ~12 tests that mock @/lib/operator keep working without churn. --- apps/web/src/lib/auth.test.ts | 89 +++++++++++++++++++++++++++++++++++ apps/web/src/lib/auth.ts | 66 ++++++++++++++++++++++++++ apps/web/src/lib/operator.ts | 23 +++++---- 3 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/auth.test.ts create mode 100644 apps/web/src/lib/auth.ts diff --git a/apps/web/src/lib/auth.test.ts b/apps/web/src/lib/auth.test.ts new file mode 100644 index 0000000..ef98fa8 --- /dev/null +++ b/apps/web/src/lib/auth.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const cookiesGetMock = vi.fn(); +const findUserMock = vi.fn(); + +vi.mock("next/headers", () => ({ + cookies: async () => ({ get: cookiesGetMock }), +})); +vi.mock("./db", () => ({ + db: { + query: { + operators: { + findFirst: (...a: unknown[]) => findUserMock(...a), + }, + }, + }, +})); + +const SECRET = "test-secret"; +beforeEach(() => { + process.env.AUTH_SECRET = SECRET; + process.env.OPERATOR_TOKEN_VERSION = "1"; + cookiesGetMock.mockReset(); + findUserMock.mockReset(); +}); + +import { signSession } from "./auth-cookie"; +import { getCurrentUser, requireUser, requireAdmin } from "./auth"; + +const NOW_S = Math.floor(Date.now() / 1000); +const ADMIN = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + role: "admin" as const, + displayName: "Admin", + defaultTimezone: "UTC", + passwordHash: null, +}; +const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const }; + +async function makeCookie(role: "admin" | "user"): Promise { + return signSession( + { + userId: role === "admin" ? ADMIN.id : USER.id, + role, + iat: NOW_S, + exp: NOW_S + 3600, + v: 1, + }, + SECRET, + ); +} + +describe("auth helpers", () => { + it("getCurrentUser returns null when no cookie is set", async () => { + cookiesGetMock.mockReturnValue(undefined); + const u = await getCurrentUser(); + expect(u).toBeNull(); + }); + + it("getCurrentUser returns the user row for a valid admin cookie", async () => { + const cookie = await makeCookie("admin"); + cookiesGetMock.mockReturnValue({ value: cookie }); + findUserMock.mockResolvedValue(ADMIN); + const u = await getCurrentUser(); + expect(u?.id).toBe(ADMIN.id); + expect(u?.role).toBe("admin"); + }); + + it("requireUser throws when there is no session", async () => { + cookiesGetMock.mockReturnValue(undefined); + await expect(requireUser()).rejects.toThrow(); + }); + + it("requireAdmin throws when role is 'user'", async () => { + const cookie = await makeCookie("user"); + cookiesGetMock.mockReturnValue({ value: cookie }); + findUserMock.mockResolvedValue(USER); + await expect(requireAdmin()).rejects.toThrow(); + }); + + it("requireAdmin returns the user when role is 'admin'", async () => { + const cookie = await makeCookie("admin"); + cookiesGetMock.mockReturnValue({ value: cookie }); + findUserMock.mockResolvedValue(ADMIN); + const u = await requireAdmin(); + expect(u.role).toBe("admin"); + }); +}); diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..0f0f4d6 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,66 @@ +import "server-only"; +import { cookies } from "next/headers"; +import { db } from "./db"; +import { COOKIE_NAME, verifySession } from "./auth-cookie"; + +export type AuthUser = { + id: string; + username: string; + role: "admin" | "user"; + displayName: string; + defaultTimezone: string; + passwordHash: string | null; +}; + +export class UnauthenticatedError extends Error { + constructor() { + super("Unauthenticated"); + this.name = "UnauthenticatedError"; + } +} +export class ForbiddenError extends Error { + constructor() { + super("Forbidden"); + this.name = "ForbiddenError"; + } +} + +/** + * Returns the operator row whose userId is encoded in the session + * cookie, or null if the cookie is missing / invalid / the row is + * gone. Never throws — call requireUser() if you want a throw. + */ +export async function getCurrentUser(): Promise { + const jar = await cookies(); + const cookie = jar.get(COOKIE_NAME)?.value; + if (!cookie) return null; + const secret = process.env.AUTH_SECRET; + if (!secret) return null; + const payload = await verifySession(cookie, secret); + if (!payload) return null; + const row = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.id, payload.userId), + }); + if (!row) return null; + if (row.role !== "admin" && row.role !== "user") return null; + return { + id: row.id, + username: row.username, + role: row.role, + displayName: row.displayName, + defaultTimezone: row.defaultTimezone, + passwordHash: row.passwordHash, + }; +} + +export async function requireUser(): Promise { + const u = await getCurrentUser(); + if (!u) throw new UnauthenticatedError(); + return u; +} + +export async function requireAdmin(): Promise { + const u = await requireUser(); + if (u.role !== "admin") throw new ForbiddenError(); + return u; +} diff --git a/apps/web/src/lib/operator.ts b/apps/web/src/lib/operator.ts index 8062676..d40893c 100644 --- a/apps/web/src/lib/operator.ts +++ b/apps/web/src/lib/operator.ts @@ -1,16 +1,21 @@ import "server-only"; -import { db } from "./db"; +import { getCurrentUser } from "./auth"; /** - * Returns the single seeded operator row. Since the app has no auth, - * every action is attributed to this operator. + * Compatibility shim. The app used to seed a single operator and + * attribute everything to it; now we have real auth + roles. Existing + * call sites read `.id` and `.defaultTimezone` off the returned + * object — both are still present on the AuthUser shape, so the + * swap is mechanical and existing tests that mock @/lib/operator + * keep working unchanged. + * + * New code should call getCurrentUser / requireUser / requireAdmin + * from @/lib/auth directly. */ export async function getSeededOperator() { - const op = await db.query.operators.findFirst({ - orderBy: (o, { asc }) => [asc(o.createdAt)], - }); - if (!op) { - throw new Error("No operator row seeded. Run scripts/db.sh seed."); + const u = await getCurrentUser(); + if (!u) { + throw new Error("Not authenticated"); } - return op; + return u; }