diff --git a/apps/server/package.json b/apps/server/package.json index 7f5a749..08799b9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,7 +9,8 @@ "lint": "oxlint", "format": "oxfmt --config ../../.oxfmtrc.json", "format:check": "oxfmt --check --config ../../.oxfmtrc.json", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@orpc/contract": "catalog:", diff --git a/apps/server/test/middleware.test.ts b/apps/server/test/middleware.test.ts new file mode 100644 index 0000000..ff18533 --- /dev/null +++ b/apps/server/test/middleware.test.ts @@ -0,0 +1,121 @@ +import { call } from "@orpc/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { auth } from "@sofa/auth/server"; +import { importJobs } from "@sofa/db/schema"; +import { clearAllTables, insertUser, testDb } from "@sofa/test/db"; + +import { implementedRouter } from "../src/orpc/router"; + +vi.mock("@sofa/auth/server", () => ({ + auth: { + api: { getSession: vi.fn<() => void>(), updateUser: vi.fn<() => void>() }, + handler: vi.fn<() => void>(), + }, +})); + +const getSession = vi.mocked(auth.api.getSession); +const ctx = { context: { headers: new Headers() } }; + +function sessionFor(userId: string, role: "user" | "admin" = "user") { + return { + session: { id: `session-${userId}`, userId }, + user: { id: userId, role, name: "U", email: `${userId}@example.com` }, + } as never; +} + +function codeOf(error: unknown): unknown { + return (error as { code?: unknown } | undefined)?.code; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +function createJob(userId: string): string { + const id = `job-${Math.random().toString(36).slice(2, 10)}`; + testDb + .insert(importJobs) + .values({ + id, + userId, + source: "trakt", + status: "pending", + payload: "{}", + importWatches: true, + importWatchlist: true, + importRatings: true, + createdAt: new Date(), + }) + .run(); + return id; +} + +beforeEach(() => { + clearAllTables(); + getSession.mockReset(); +}); + +describe("admin middleware", () => { + test("rejects a missing session with UNAUTHORIZED", async () => { + getSession.mockResolvedValue(null); + const error = await rejection(call(implementedRouter.admin.settings.get, undefined, ctx)); + expect(codeOf(error)).toBe("UNAUTHORIZED"); + }); + + test("rejects a non-admin user with FORBIDDEN", async () => { + getSession.mockResolvedValue(sessionFor("user-1", "user")); + const error = await rejection(call(implementedRouter.admin.settings.get, undefined, ctx)); + expect(codeOf(error)).toBe("FORBIDDEN"); + }); + + test("bypasses the cookie cache and lets admins through", async () => { + getSession.mockResolvedValue(sessionFor("admin-1", "admin")); + const error = await rejection(call(implementedRouter.admin.settings.get, undefined, ctx)); + expect(getSession).toHaveBeenCalledWith( + expect.objectContaining({ query: { disableCookieCache: true } }), + ); + // The call may resolve or fail for unrelated reasons; it must not be an auth rejection. + expect(["UNAUTHORIZED", "FORBIDDEN"]).not.toContain(codeOf(error)); + }); +}); + +describe("authed middleware", () => { + test("rejects a missing session with UNAUTHORIZED", async () => { + getSession.mockResolvedValue(null); + const error = await rejection(call(implementedRouter.titles.similar, { id: "anything" }, ctx)); + expect(codeOf(error)).toBe("UNAUTHORIZED"); + }); +}); + +describe("import job ownership", () => { + test("another user's job is FORBIDDEN for getJob and cancelJob", async () => { + insertUser("user-a"); + insertUser("user-b"); + const jobId = createJob("user-a"); + getSession.mockResolvedValue(sessionFor("user-b")); + + const getError = await rejection(call(implementedRouter.imports.getJob, { id: jobId }, ctx)); + expect(codeOf(getError)).toBe("FORBIDDEN"); + + const cancelError = await rejection( + call(implementedRouter.imports.cancelJob, { id: jobId }, ctx), + ); + expect(codeOf(cancelError)).toBe("FORBIDDEN"); + }); + + test("the owner can read their own job", async () => { + insertUser("user-a"); + insertUser("user-b"); + const jobId = createJob("user-a"); + getSession.mockResolvedValue(sessionFor("user-a")); + + const job = await call(implementedRouter.imports.getJob, { id: jobId }, ctx); + expect(job.id).toBe(jobId); + }); +}); diff --git a/apps/server/test/router-auth.test.ts b/apps/server/test/router-auth.test.ts new file mode 100644 index 0000000..a28b8f9 --- /dev/null +++ b/apps/server/test/router-auth.test.ts @@ -0,0 +1,48 @@ +import { isProcedure } from "@orpc/server"; +import { describe, expect, test } from "vitest"; + +import { admin, authed } from "../src/orpc/middleware"; +import { implementedRouter } from "../src/orpc/router"; + +// Procedures that are intentionally callable without a session. +const PUBLIC_PROCEDURES = new Set(["system.publicInfo"]); + +function collectProcedures( + node: unknown, + path: string[] = [], +): { path: string; middlewares: readonly unknown[] }[] { + if (isProcedure(node)) { + return [{ path: path.join("."), middlewares: node["~orpc"].middlewares }]; + } + return Object.entries(node as Record).flatMap(([key, child]) => + collectProcedures(child, [...path, key]), + ); +} + +const procedures = collectProcedures(implementedRouter); + +describe("router authorization", () => { + test("finds every procedure", () => { + expect(procedures.length).toBeGreaterThanOrEqual(50); + }); + + test.each(procedures.filter((p) => !PUBLIC_PROCEDURES.has(p.path)))( + "$path requires a session", + ({ middlewares }) => { + expect(middlewares.includes(authed) || middlewares.includes(admin)).toBe(true); + }, + ); + + test.each(procedures.filter((p) => p.path.startsWith("admin.")))( + "$path requires an admin", + ({ middlewares }) => { + expect(middlewares.includes(admin)).toBe(true); + }, + ); + + test("public procedures have no auth middleware", () => { + for (const proc of procedures.filter((p) => PUBLIC_PROCEDURES.has(p.path))) { + expect(proc.middlewares.includes(authed) || proc.middlewares.includes(admin)).toBe(false); + } + }); +}); diff --git a/apps/server/test/routes-auth.test.ts b/apps/server/test/routes-auth.test.ts new file mode 100644 index 0000000..645fdd8 --- /dev/null +++ b/apps/server/test/routes-auth.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { auth } from "@sofa/auth/server"; +import { clearAllTables } from "@sofa/test/db"; + +import avatars from "../src/routes/avatars"; +import backups from "../src/routes/backups"; +import exportRoute from "../src/routes/export"; +import lists from "../src/routes/lists"; +import webhooks from "../src/routes/webhooks"; + +vi.mock("@sofa/auth/server", () => ({ + auth: { + api: { getSession: vi.fn<() => void>(), updateUser: vi.fn<() => void>() }, + handler: vi.fn<() => void>(), + }, +})); + +const getSession = vi.mocked(auth.api.getSession); + +function userSession(role: "user" | "admin" = "user") { + return { + session: { id: "s1", userId: "user-1" }, + user: { id: "user-1", role, name: "U", email: "u@example.com" }, + } as never; +} + +beforeEach(() => { + clearAllTables(); + getSession.mockReset(); +}); + +describe("backups route", () => { + test("rejects a missing session with 401", async () => { + getSession.mockResolvedValue(null); + const res = await backups.request("/sofa-backup.db"); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + }); + + test("rejects a non-admin with 403 and bypasses the cookie cache", async () => { + getSession.mockResolvedValue(userSession("user")); + const res = await backups.request("/sofa-backup.db"); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Forbidden" }); + expect(getSession).toHaveBeenCalledWith( + expect.objectContaining({ query: { disableCookieCache: true } }), + ); + }); +}); + +describe("export route", () => { + test("rejects a missing session with 401", async () => { + getSession.mockResolvedValue(null); + const res = await exportRoute.request("/user-data"); + expect(res.status).toBe(401); + }); +}); + +describe("avatars route", () => { + test("rejects a missing session with 401", async () => { + getSession.mockResolvedValue(null); + const res = await avatars.request("/user-1"); + expect(res.status).toBe(401); + }); +}); + +describe("webhooks route", () => { + test("returns 200 { ok: true } for an unknown token", async () => { + const res = await webhooks.request("/not-a-real-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); +}); + +describe("lists route", () => { + test("returns 200 [] for an unknown token", async () => { + const res = await lists.request("/not-a-real-token"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 5e4fa20..167cdea 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@sofa/tsconfig/base.json", - "include": ["src"] + "include": ["src", "test"] } diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..f8fdb0d --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + include: ["test/**/*.test.ts"], + setupFiles: ["@sofa/test/setup"], + }, +});