Files
EpicNext-Cms/src/lib/bearer-route-abilities.test.ts
T
openhands 8638e81444
CI / check (push) Successful in 4m10s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m4s
refactor(db): typed query helpers, shared test FormData helper
Replace raw db.execute tuple casts with queryRows/rowsFrom/execResult/
affectedRows helpers from lib/db, drop redundant mysql2 casts on typed
query builders, and centralize per-test fakeForm into test/fake-form.
Update db mocks in tests so helpers resolve against mocked execute.
2026-09-17 21:02:57 +02:00

194 lines
5.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
abilities: '["*"]',
queries: [] as string[],
}));
vi.mock("@/lib/db", async () => {
const { createDbHelpers } = await import("@/test/db-helpers");
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
const db = drizzle(async (sql) => {
state.queries.push(sql);
if (
sql.startsWith("select ") &&
sql.includes(" from `personal_access_tokens`")
) {
const token: Record<string, unknown> = {
id: "9",
tokenable_id: "42",
tokenable_type: "App\\Models\\User",
abilities: state.abilities,
};
const columns = sql
.slice(7, sql.indexOf(" from "))
.split(", ")
.map((column) => column.replaceAll("`", ""));
return { rows: [columns.map((column) => token[column])] };
}
return { rows: [] };
});
const mockDb = Object.assign(db, {
execute: async () => [[{ cnt: 0n }], []],
});
return {
...schema,
...createDbHelpers(mockDb.execute),
db: mockDb,
};
});
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "77" } }) }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
vi.mock("next/server", async (original) => ({
...(await original<typeof import("next/server")>()),
connection: async () => {},
}));
vi.mock("@/lib/redis-cache", () => ({
apiCacheKey: (key: string) => key,
cacheSafe: (value: unknown) => value,
redisCache: async () => ({
badgeStats: [],
totalBadges: { entries: [], totalPlayers: 0 },
achievementLevel: { entries: [], totalPlayers: 0 },
rarity: {},
}),
}));
import { POST as articleComment } from "@/app/api/articles/[slug]/comment/route";
import { GET as badgeLeaderboard } from "@/app/api/badges/leaderboard/route";
import { GET as radioPoints } from "@/app/api/radio/points/route";
import { POST as radioShout } from "@/app/api/radio/shouts/route";
import { POST as ticketReply } from "@/app/api/tickets/[id]/reply/route";
import { GET as ticketGet } from "@/app/api/tickets/[id]/route";
import {
GET as ticketsGet,
POST as ticketsPost,
} from "@/app/api/tickets/route";
function request(method: string, bearer = true) {
return new Request("https://hotel.test/api/test", {
method,
headers: bearer
? {
authorization: "Bearer test-token",
"content-type": "application/json",
}
: {},
...(method === "POST" ? { body: "{}" } : {}),
});
}
const protectedRoutes = [
{
name: "GET tickets",
scope: "tickets:read",
method: "GET",
run: ticketsGet,
allowedStatus: 200,
},
{
name: "POST tickets",
scope: "tickets:write",
method: "POST",
run: ticketsPost,
allowedStatus: 400,
},
{
name: "GET ticket detail",
scope: "tickets:read",
method: "GET",
run: (req: Request) =>
ticketGet(req, { params: Promise.resolve({ id: "0" }) }),
allowedStatus: 422,
},
{
name: "POST ticket reply",
scope: "tickets:write",
method: "POST",
run: (req: Request) =>
ticketReply(req, { params: Promise.resolve({ id: "0" }) }),
allowedStatus: 422,
},
{
name: "POST article comment",
scope: "articles:write",
method: "POST",
run: (req: Request) =>
articleComment(req, { params: Promise.resolve({ slug: "article" }) }),
allowedStatus: 422,
},
{
name: "GET radio points",
scope: "radio:read",
method: "GET",
run: radioPoints,
allowedStatus: 200,
},
{
name: "POST radio shout",
scope: "radio:write",
method: "POST",
run: radioShout,
allowedStatus: 422,
},
];
beforeEach(() => {
state.abilities = '["*"]';
state.queries = [];
});
describe("API endpoint token scope boundaries", () => {
it.each(protectedRoutes)(
"$name rejects unrelated scopes before accessing endpoint data",
async ({ scope, method, run }) => {
state.abilities = JSON.stringify([
scope.startsWith("tickets:") ? "radio:read" : "tickets:read",
]);
const response = await run(request(method));
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: "Unauthorized" });
expect(
state.queries.every((sql) => sql.includes("personal_access_tokens")),
).toBe(true);
},
);
it.each(protectedRoutes)(
"$name accepts its documented scope",
async ({ scope, method, run, allowedStatus }) => {
state.abilities = JSON.stringify([scope]);
expect((await run(request(method))).status).toBe(allowedStatus);
},
);
it.each(protectedRoutes)(
"$name preserves existing wildcard tokens",
async ({ method, run, allowedStatus }) => {
expect((await run(request(method))).status).toBe(allowedStatus);
},
);
it("does not let a read-only ticket token create a ticket", async () => {
state.abilities = '["tickets:read"]';
expect((await ticketsPost(request("POST"))).status).toBe(401);
});
it("does not let a read-only radio token post a shout", async () => {
state.abilities = '["radio:read"]';
expect((await radioShout(request("POST"))).status).toBe(401);
});
it("does not use session cookies to bypass a denied bearer scope on the public leaderboard", async () => {
state.abilities = '["tickets:read"]';
const response = await badgeLeaderboard(request("GET"));
expect((await response.json()).viewerUserId).toBe(0);
});
it("personalizes the leaderboard only for the badges scope", async () => {
state.abilities = '["badges:read"]';
expect(
(await (await badgeLeaderboard(request("GET"))).json()).viewerUserId,
).toBe(42);
});
it("preserves session-only leaderboard personalization without a bearer header", async () => {
expect(
(await (await badgeLeaderboard(request("GET", false))).json())
.viewerUserId,
).toBe(77);
});
});