diff --git a/src/lib/server/browser-session.test.ts b/src/lib/server/browser-session.test.ts new file mode 100644 index 0000000..dff78a6 --- /dev/null +++ b/src/lib/server/browser-session.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { getBrowserSessionUser } from "./browser-session"; + +const ORIGINAL_FETCH = global.fetch; + +function makeRequest(authHeader: string | null): NextRequest { + const headers = new Headers(); + if (authHeader !== null) headers.set("authorization", authHeader); + return new NextRequest("http://localhost:4001/api/v1/rep-dashboard", { + headers, + }); +} + +describe("getBrowserSessionUser", () => { + beforeEach(() => { + global.fetch = ORIGINAL_FETCH; + }); + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + }); + + it("throws a 401-tagged error when no token is present", async () => { + await expect(getBrowserSessionUser(makeRequest(null))).rejects.toMatchObject({ + status: 401, + }); + }); + + it("throws a 401-tagged error when PB rejects the token", async () => { + global.fetch = vi.fn(async () => new Response("", { status: 401 })) as unknown as typeof fetch; + await expect( + getBrowserSessionUser(makeRequest("Bearer bad-token")), + ).rejects.toMatchObject({ status: 401 }); + }); + + it("returns the resolved user's id and user_type on success", async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + token: "refreshed-token", + record: { id: "user123", user_type: ["REP"], name: "Test Rep" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + const result = await getBrowserSessionUser(makeRequest("Bearer good-token")); + expect(result).toEqual({ id: "user123", user_type: ["REP"] }); + }); + + it("defaults user_type to null when the record omits it", async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ token: "t", record: { id: "user456" } }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + const result = await getBrowserSessionUser(makeRequest("Bearer good-token")); + expect(result).toEqual({ id: "user456", user_type: null }); + }); +}); \ No newline at end of file diff --git a/src/lib/server/browser-session.ts b/src/lib/server/browser-session.ts index da16f7d..b3e7584 100644 --- a/src/lib/server/browser-session.ts +++ b/src/lib/server/browser-session.ts @@ -35,3 +35,55 @@ export async function assertBrowserSession(req: NextRequest): Promise { }); } } + +export interface BrowserSessionUser { + id: string; + user_type: string[] | null; +} + +/** + * Same validation as `assertBrowserSession`, but returns the resolved + * PB user record instead of discarding it — for routes that need to + * scope queries to "whoever is logged in" (e.g. the rep dashboard). + */ +export async function getBrowserSessionUser( + req: NextRequest, +): Promise { + const authorization = req.headers.get("authorization") || ""; + const token = authorization.startsWith("Bearer ") + ? authorization.slice(7) + : authorization; + + if (!token) { + throw Object.assign(new Error("Not authenticated."), { status: 401 }); + } + + const pbUrl = + process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; + const response = await fetch( + `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`, + { + method: "POST", + headers: { Authorization: token }, + signal: AbortSignal.timeout(5_000), + }, + ); + + if (!response.ok) { + throw Object.assign(new Error("Invalid or expired session."), { + status: 401, + }); + } + + const body = (await response.json()) as { + record?: { id?: string; user_type?: string[] | null }; + }; + const id = body.record?.id; + if (!id) { + throw Object.assign(new Error("Invalid or expired session."), { + status: 401, + }); + } + + return { id, user_type: body.record?.user_type ?? null }; +}