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.
This commit is contained in:
yiekheng 2026-05-10 17:46:16 +08:00
parent e1ba1da2de
commit d236196476
3 changed files with 169 additions and 9 deletions

View File

@ -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<string> {
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");
});
});

66
apps/web/src/lib/auth.ts Normal file
View File

@ -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<AuthUser | null> {
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<AuthUser> {
const u = await getCurrentUser();
if (!u) throw new UnauthenticatedError();
return u;
}
export async function requireAdmin(): Promise<AuthUser> {
const u = await requireUser();
if (u.role !== "admin") throw new ForbiddenError();
return u;
}

View File

@ -1,16 +1,21 @@
import "server-only"; import "server-only";
import { db } from "./db"; import { getCurrentUser } from "./auth";
/** /**
* Returns the single seeded operator row. Since the app has no auth, * Compatibility shim. The app used to seed a single operator and
* every action is attributed to this operator. * 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() { export async function getSeededOperator() {
const op = await db.query.operators.findFirst({ const u = await getCurrentUser();
orderBy: (o, { asc }) => [asc(o.createdAt)], if (!u) {
}); throw new Error("Not authenticated");
if (!op) {
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
} }
return op; return u;
} }