mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 05:25:42 -04:00
test(server): add test harness and auth coverage for every procedure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+266
-2
No files matched your search
@@ -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<unknown>): Promise<unknown> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user