trails/apps/journal/app/routes/api.v1.routes.test.ts
Ullrich Schäfer 067e2ebd0b
fix: enforce Terms gate on bearer-token API requests
Mobile API requests authenticated via OAuth2 bearer tokens bypassed the
Terms gate that the root loader applies to web cookie sessions. Extend
requireApiUser to compare the user's termsVersion with TERMS_VERSION
and return a structured 403 { code: "TERMS_OUTDATED", currentTermsVersion }
on mismatch so mobile clients can surface their own re-acceptance UI.

Spec delta on journal-auth captures the new requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:59:28 +02:00

167 lines
5.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { TERMS_VERSION } from "~/lib/legal";
const mockUser = { id: "user-1", email: "test@test.com", username: "test", domain: "localhost", displayName: null, bio: null, createdAt: new Date(), termsVersion: TERMS_VERSION };
const mockGetAuthenticatedUser = vi.fn();
const mockListRoutes = vi.fn();
const mockCreateRoute = vi.fn();
const mockGetRouteWithVersions = vi.fn();
const mockDeleteRoute = vi.fn();
vi.mock("~/lib/db", () => ({ getDb: vi.fn() }));
vi.mock("~/lib/auth.server", () => ({ getSessionUser: vi.fn() }));
vi.mock("~/lib/oauth.server", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/lib/oauth.server")>();
return { ...actual, getAuthenticatedUser: mockGetAuthenticatedUser };
});
vi.mock("~/lib/routes.server", () => ({
listRoutes: mockListRoutes,
createRoute: mockCreateRoute,
getRouteWithVersions: mockGetRouteWithVersions,
updateRoute: vi.fn(),
deleteRoute: mockDeleteRoute,
}));
function authRequest(path: string, init?: RequestInit) {
return new Request(`http://localhost:3000${path}`, {
...init,
headers: {
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
...(init?.headers as Record<string, string> ?? {}),
},
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function routeArgs(request: Request, params: Record<string, string>, pattern: string): any {
return { request, params, context: {}, unstable_url: new URL(request.url), unstable_pattern: pattern };
}
beforeEach(() => {
vi.clearAllMocks();
mockGetAuthenticatedUser.mockResolvedValue(mockUser);
});
describe("GET /api/v1/routes", () => {
it("returns paginated routes", async () => {
const now = new Date();
mockListRoutes.mockResolvedValue([
{
id: "r1", name: "Tour", description: "", distance: 42000,
elevationGain: 340, elevationLoss: 320, routingProfile: "fastbike",
dayBreaks: [], geojson: null, ownerId: "user-1", gpx: null,
tags: null, plannerState: null, createdAt: now, updatedAt: now,
},
]);
const { loader } = await import("./api.v1.routes._index.ts");
const resp = await loader(routeArgs(authRequest("/api/v1/routes"), {}, "api/v1/routes")) as Response;
const data = await resp.json();
expect(data.routes).toHaveLength(1);
expect(data.routes[0].id).toBe("r1");
expect(data.nextCursor).toBeNull();
});
it("returns 401 without auth", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { loader } = await import("./api.v1.routes._index.ts");
try {
await loader(routeArgs(new Request("http://localhost:3000/api/v1/routes"), {}, "api/v1/routes"));
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(Response);
expect((err as Response).status).toBe(401);
}
});
});
describe("POST /api/v1/routes", () => {
it("creates a route with valid body", async () => {
mockCreateRoute.mockResolvedValue("new-id");
const { action } = await import("./api.v1.routes._index.ts");
const resp = await action(routeArgs(
authRequest("/api/v1/routes", {
method: "POST",
body: JSON.stringify({ name: "New Route" }),
}), {}, "api/v1/routes",
)) as Response;
expect(resp.status).toBe(201);
const data = await resp.json();
expect(data.id).toBe("new-id");
});
it("returns 400 on validation error", async () => {
const { action } = await import("./api.v1.routes._index.ts");
const resp = await action(routeArgs(
authRequest("/api/v1/routes", {
method: "POST",
body: JSON.stringify({ name: "" }),
}), {}, "api/v1/routes",
)) as Response;
expect(resp.status).toBe(400);
const data = await resp.json();
expect(data.code).toBe("VALIDATION_ERROR");
});
});
describe("GET /api/v1/routes/:id", () => {
it("returns route detail", async () => {
const now = new Date();
mockGetRouteWithVersions.mockResolvedValue({
id: "r1", name: "Tour", description: "", distance: 42000,
elevationGain: 340, elevationLoss: 320, routingProfile: "fastbike",
dayBreaks: [], gpx: "<gpx/>", ownerId: "user-1",
tags: null, plannerState: null, createdAt: now, updatedAt: now,
versions: [{ id: "v1", routeId: "r1", version: 1, gpx: "<gpx/>", createdBy: "user-1", changeDescription: null, createdAt: now }],
});
const { loader } = await import("./api.v1.routes.$id.ts");
const resp = await loader(routeArgs(
authRequest("/api/v1/routes/r1"), { id: "r1" }, "api/v1/routes/:id",
)) as Response;
const data = await resp.json();
expect(data.id).toBe("r1");
expect(data.gpx).toBe("<gpx/>");
expect(data.versions).toHaveLength(1);
});
it("returns 404 for missing route", async () => {
mockGetRouteWithVersions.mockResolvedValue(null);
const { loader } = await import("./api.v1.routes.$id.ts");
const resp = await loader(routeArgs(
authRequest("/api/v1/routes/missing"), { id: "missing" }, "api/v1/routes/:id",
)) as Response;
expect(resp.status).toBe(404);
});
});
describe("DELETE /api/v1/routes/:id", () => {
it("returns 204 on success", async () => {
mockDeleteRoute.mockResolvedValue(true);
const { action } = await import("./api.v1.routes.$id.ts");
const resp = await action(routeArgs(
authRequest("/api/v1/routes/r1", { method: "DELETE" }), { id: "r1" }, "api/v1/routes/:id",
)) as Response;
expect(resp.status).toBe(204);
});
it("returns 404 if not found", async () => {
mockDeleteRoute.mockResolvedValue(false);
const { action } = await import("./api.v1.routes.$id.ts");
const resp = await action(routeArgs(
authRequest("/api/v1/routes/missing", { method: "DELETE" }), { id: "missing" }, "api/v1/routes/:id",
)) as Response;
expect(resp.status).toBe(404);
});
});