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>
70 lines
2.4 KiB
TypeScript
70 lines
2.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { TERMS_VERSION } from "./legal";
|
|
|
|
const mockGetAuthenticatedUser = vi.fn();
|
|
|
|
vi.mock("./oauth.server.ts", () => ({
|
|
getAuthenticatedUser: mockGetAuthenticatedUser,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("requireApiUser", () => {
|
|
it("returns 401 when unauthenticated", async () => {
|
|
mockGetAuthenticatedUser.mockResolvedValue(null);
|
|
const { requireApiUser } = await import("./api-guard.server.ts");
|
|
|
|
try {
|
|
await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
|
expect.fail("should throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(Response);
|
|
expect((err as Response).status).toBe(401);
|
|
const body = await (err as Response).json();
|
|
expect(body.code).toBe("UNAUTHORIZED");
|
|
}
|
|
});
|
|
|
|
it("returns the user when termsVersion matches", async () => {
|
|
const user = { id: "u1", termsVersion: TERMS_VERSION };
|
|
mockGetAuthenticatedUser.mockResolvedValue(user);
|
|
const { requireApiUser } = await import("./api-guard.server.ts");
|
|
|
|
const result = await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
|
expect(result).toBe(user);
|
|
});
|
|
|
|
it("returns 403 TERMS_OUTDATED when termsVersion is stale", async () => {
|
|
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: "2020-01-01" });
|
|
const { requireApiUser } = await import("./api-guard.server.ts");
|
|
|
|
try {
|
|
await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
|
expect.fail("should throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(Response);
|
|
expect((err as Response).status).toBe(403);
|
|
const body = await (err as Response).json();
|
|
expect(body.code).toBe("TERMS_OUTDATED");
|
|
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
|
|
}
|
|
});
|
|
|
|
it("returns 403 TERMS_OUTDATED when termsVersion is null", async () => {
|
|
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: null });
|
|
const { requireApiUser } = await import("./api-guard.server.ts");
|
|
|
|
try {
|
|
await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
|
expect.fail("should throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(Response);
|
|
expect((err as Response).status).toBe(403);
|
|
const body = await (err as Response).json();
|
|
expect(body.code).toBe("TERMS_OUTDATED");
|
|
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
|
|
}
|
|
});
|
|
});
|