/** * TASK-0129 — unit: the OAuth scope domain (scopes.ts). * * Pure functions only (no Nest/Prisma/Redis): * - PKCE S256 against the RFC 7636 Appendix B test vector; * - oauthScopeCap (the permission cap a scope set exercises); * - missingOAuthScope (the scope a 403 SCOPE_DENIED detail must name); * - parse/serialize round-trip (the wire format). */ import { describe, expect, it } from "vitest"; import { OAUTH_SCOPES, OAUTH_SCOPE_DESCRIPTIONS, missingOAuthScope, oauthScopeCap, parseScopeList, pkceChallenge, scopeResource, serializeScopeList } from "../scopes.js"; describe("pkceChallenge (S256, RFC 7636)", () => { it("matches the RFC 7636 Appendix B test vector", () => { // Canonical vector from the RFC (ASCII verifier → BASE64URL(SHA256)): expect(pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); }); it("is deterministic and verifier-sensitive", () => { const a = pkceChallenge("verifier-one"); expect(pkceChallenge("verifier-one")).toBe(a); expect(pkceChallenge("verifier-two")).not.toBe(a); }); it("produces URL-safe base64 without padding", () => { for (const v of ["a", "abc", "longer-than-43-characters-XXXXXXXXXXXXXXXXXXXXXXXXX", "sp3c1a1!chars??"]) { const c = pkceChallenge(v); expect(c).not.toMatch(/[+/=]/); expect(c.length).toBeGreaterThan(0); } }); }); describe("oauthScopeCap", () => { it("no scopes → NONE", () => { expect(oauthScopeCap([])).toBe("NONE"); }); it("read-only scopes → VIEW", () => { expect(oauthScopeCap(["pages:read"])).toBe("VIEW"); expect(oauthScopeCap(["pages:read", "databases:read", "files:read"])).toBe("VIEW"); }); it("any write scope → EDIT", () => { expect(oauthScopeCap(["files:write"])).toBe("EDIT"); expect(oauthScopeCap(["pages:read", "databases:write"])).toBe("EDIT"); }); it("never reaches FULL_ACCESS (no scope is admin) — C-13.7", () => { expect(oauthScopeCap([...OAUTH_SCOPES])).toBe("EDIT"); }); it("scopes without a read/write action → NONE (the catalog is validated upstream — only catalog scopes reach the cap)", () => { expect(oauthScopeCap(["pages", "other"])).toBe("NONE"); }); }); describe("scopeResource", () => { it("maps each known resource", () => { expect(scopeResource("pages:read")).toBe("pages"); expect(scopeResource("databases:write")).toBe("databases"); expect(scopeResource("files:read")).toBe("files"); }); it("null for unknown / malformed", () => { expect(scopeResource("bogus:read")).toBeNull(); expect(scopeResource("pages")).toBeNull(); expect(scopeResource("")).toBeNull(); }); }); describe("missingOAuthScope", () => { it("names :write for write-level needs (resource from the asserted resource)", () => { expect(missingOAuthScope("EDIT", ["pages:read"], { pageId: "p1" })).toBe("pages:write"); expect(missingOAuthScope("EDIT", ["databases:read"], { databaseId: "db1" })).toBe("databases:write"); expect(missingOAuthScope("EDIT", ["files:read"], { fileId: "f1" })).toBe("files:write"); }); it("names :read for read-level needs", () => { expect(missingOAuthScope("VIEW", [], { pageId: "p1" })).toBe("pages:read"); expect(missingOAuthScope("NONE", [], { databaseId: "db1" })).toBe("databases:read"); }); it("falls back to the token's first recognized scope's resource", () => { expect(missingOAuthScope("EDIT", ["files:read", "pages:read"], {})).toBe("files:write"); }); it("falls back to pages for unknown scopes", () => { expect(missingOAuthScope("VIEW", ["weird"], {})).toBe("pages:read"); expect(missingOAuthScope("EDIT", [], {})).toBe("pages:write"); }); it("FULL_ACCESS need is treated as write (owner/admin routes are unreachable by OAuth)", () => { expect(missingOAuthScope("FULL_ACCESS", ["pages:read"], { pageId: "p1" })).toBe("pages:write"); }); }); describe("parseScopeList / serializeScopeList", () => { it("parse trims, drops empties, de-dupes, preserves order", () => { expect(parseScopeList("pages:read pages:read databases:read ")).toEqual(["pages:read", "databases:read"]); expect(parseScopeList("")).toEqual([]); expect(parseScopeList(null)).toEqual([]); expect(parseScopeList(undefined)).toEqual([]); }); it("serialize emits the CATALOG order (not input order), drops unknown scopes", () => { expect(serializeScopeList(["files:read", "pages:write"])).toBe("pages:write files:read"); expect(serializeScopeList(["databases:write", "databases:read", "pages:read"])).toBe("pages:read databases:read databases:write"); expect(serializeScopeList(["unknown:x", "pages:read"])).toBe("pages:read"); expect(serializeScopeList([])).toBe(""); }); it("round-trips a valid wire value", () => { const wire = "files:write pages:read"; expect(serializeScopeList(parseScopeList(wire))).toBe("pages:read files:write"); }); }); describe("OAUTH_SCOPES catalog", () => { it("has exactly the six documented scopes (C-13.7b — 6 over the task's 5)", () => { expect(OAUTH_SCOPES).toEqual(["pages:read", "pages:write", "databases:read", "databases:write", "files:read", "files:write"]); }); it("every scope has a static consent-screen description", () => { for (const s of OAUTH_SCOPES) { expect(OAUTH_SCOPE_DESCRIPTIONS[s as keyof typeof OAUTH_SCOPE_DESCRIPTIONS]).toBeTruthy(); } }); });