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:
parent
e1ba1da2de
commit
d236196476
89
apps/web/src/lib/auth.test.ts
Normal file
89
apps/web/src/lib/auth.test.ts
Normal 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
66
apps/web/src/lib/auth.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user