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 "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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user