1
10import { describe, expect, it } from "vitest";
11import {
12 OAUTH_SCOPES,
13 OAUTH_SCOPE_DESCRIPTIONS,
14 missingOAuthScope,
15 oauthScopeCap,
16 parseScopeList,
17 pkceChallenge,
18 scopeResource,
19 serializeScopeList
20} from "../scopes.js";
21
22describe("pkceChallenge (S256, RFC 7636)", () => {
23 it("matches the RFC 7636 Appendix B test vector", () => {
24
25 expect(pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
26 });
27
28 it("is deterministic and verifier-sensitive", () => {
29 const a = pkceChallenge("verifier-one");
30 expect(pkceChallenge("verifier-one")).toBe(a);
31 expect(pkceChallenge("verifier-two")).not.toBe(a);
32 });
33
34 it("produces URL-safe base64 without padding", () => {
35 for (const v of ["a", "abc", "longer-than-43-characters-XXXXXXXXXXXXXXXXXXXXXXXXX", "sp3c1a1!chars??"]) {
36 const c = pkceChallenge(v);
37 expect(c).not.toMatch(/[+/=]/);
38 expect(c.length).toBeGreaterThan(0);
39 }
40 });
41});
42
43describe("oauthScopeCap", () => {
44 it("no scopes → NONE", () => {
45 expect(oauthScopeCap([])).toBe("NONE");
46 });
47
48 it("read-only scopes → VIEW", () => {
49 expect(oauthScopeCap(["pages:read"])).toBe("VIEW");
50 expect(oauthScopeCap(["pages:read", "databases:read", "files:read"])).toBe("VIEW");
51 });
52
53 it("any write scope → EDIT", () => {
54 expect(oauthScopeCap(["files:write"])).toBe("EDIT");
55 expect(oauthScopeCap(["pages:read", "databases:write"])).toBe("EDIT");
56 });
57
58 it("never reaches FULL_ACCESS (no scope is admin) — C-13.7", () => {
59 expect(oauthScopeCap([...OAUTH_SCOPES])).toBe("EDIT");
60 });
61
62 it("scopes without a read/write action → NONE (the catalog is validated upstream — only catalog scopes reach the cap)", () => {
63 expect(oauthScopeCap(["pages", "other"])).toBe("NONE");
64 });
65});
66
67describe("scopeResource", () => {
68 it("maps each known resource", () => {
69 expect(scopeResource("pages:read")).toBe("pages");
70 expect(scopeResource("databases:write")).toBe("databases");
71 expect(scopeResource("files:read")).toBe("files");
72 });
73
74 it("null for unknown / malformed", () => {
75 expect(scopeResource("bogus:read")).toBeNull();
76 expect(scopeResource("pages")).toBeNull();
77 expect(scopeResource("")).toBeNull();
78 });
79});
80
81describe("missingOAuthScope", () => {
82 it("names <resource>:write for write-level needs (resource from the asserted resource)", () => {
83 expect(missingOAuthScope("EDIT", ["pages:read"], { pageId: "p1" })).toBe("pages:write");
84 expect(missingOAuthScope("EDIT", ["databases:read"], { databaseId: "db1" })).toBe("databases:write");
85 expect(missingOAuthScope("EDIT", ["files:read"], { fileId: "f1" })).toBe("files:write");
86 });
87
88 it("names <resource>:read for read-level needs", () => {
89 expect(missingOAuthScope("VIEW", [], { pageId: "p1" })).toBe("pages:read");
90 expect(missingOAuthScope("NONE", [], { databaseId: "db1" })).toBe("databases:read");
91 });
92
93 it("falls back to the token's first recognized scope's resource", () => {
94 expect(missingOAuthScope("EDIT", ["files:read", "pages:read"], {})).toBe("files:write");
95 });
96
97 it("falls back to pages for unknown scopes", () => {
98 expect(missingOAuthScope("VIEW", ["weird"], {})).toBe("pages:read");
99 expect(missingOAuthScope("EDIT", [], {})).toBe("pages:write");
100 });
101
102 it("FULL_ACCESS need is treated as write (owner/admin routes are unreachable by OAuth)", () => {
103 expect(missingOAuthScope("FULL_ACCESS", ["pages:read"], { pageId: "p1" })).toBe("pages:write");
104 });
105});
106
107describe("parseScopeList / serializeScopeList", () => {
108 it("parse trims, drops empties, de-dupes, preserves order", () => {
109 expect(parseScopeList("pages:read pages:read databases:read ")).toEqual(["pages:read", "databases:read"]);
110 expect(parseScopeList("")).toEqual([]);
111 expect(parseScopeList(null)).toEqual([]);
112 expect(parseScopeList(undefined)).toEqual([]);
113 });
114
115 it("serialize emits the CATALOG order (not input order), drops unknown scopes", () => {
116 expect(serializeScopeList(["files:read", "pages:write"])).toBe("pages:write files:read");
117 expect(serializeScopeList(["databases:write", "databases:read", "pages:read"])).toBe("pages:read databases:read databases:write");
118 expect(serializeScopeList(["unknown:x", "pages:read"])).toBe("pages:read");
119 expect(serializeScopeList([])).toBe("");
120 });
121
122 it("round-trips a valid wire value", () => {
123 const wire = "files:write pages:read";
124 expect(serializeScopeList(parseScopeList(wire))).toBe("pages:read files:write");
125 });
126});
127
128describe("OAUTH_SCOPES catalog", () => {
129 it("has exactly the six documented scopes (C-13.7b — 6 over the task's 5)", () => {
130 expect(OAUTH_SCOPES).toEqual(["pages:read", "pages:write", "databases:read", "databases:write", "files:read", "files:write"]);
131 });
132
133 it("every scope has a static consent-screen description", () => {
134 for (const s of OAUTH_SCOPES) {
135 expect(OAUTH_SCOPE_DESCRIPTIONS[s as keyof typeof OAUTH_SCOPE_DESCRIPTIONS]).toBeTruthy();
136 }
137 });
138});
139