/** * Unit tests for API token generation, hashing, and scope utilities. */ import { Role, scopesForRole, clampScopes } from "@emdash-cms/auth"; import { describe, it, expect } from "vitest"; import { generatePrefixedToken, hashApiToken, validateScopes, hasScope, TOKEN_PREFIXES, VALID_SCOPES, } from "../../../src/auth/api-tokens.js"; // Regex patterns for token validation const PAT_PREFIX_REGEX = /^ec_pat_/; const OAUTH_ACCESS_PREFIX_REGEX = /^ec_oat_/; const OAUTH_REFRESH_PREFIX_REGEX = /^ec_ort_/; const BASE64URL_INVALID_CHARS_REGEX = /[+/=]/; const BASE64URL_VALID_REGEX = /^[A-Za-z0-9_-]+$/; describe("generatePrefixedToken", () => { it("generates a PAT with ec_pat_ prefix", () => { const { raw, hash, prefix } = generatePrefixedToken(TOKEN_PREFIXES.PAT); expect(raw).toMatch(PAT_PREFIX_REGEX); expect(raw.length).toBeGreaterThan(20); expect(hash).toBeTruthy(); expect(hash).not.toBe(raw); expect(prefix).toMatch(PAT_PREFIX_REGEX); expect(prefix.length).toBe(TOKEN_PREFIXES.PAT.length + 4); }); it("generates an OAuth access token with ec_oat_ prefix", () => { const { raw } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS); expect(raw).toMatch(OAUTH_ACCESS_PREFIX_REGEX); }); it("generates an OAuth refresh token with ec_ort_ prefix", () => { const { raw } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_REFRESH); expect(raw).toMatch(OAUTH_REFRESH_PREFIX_REGEX); }); it("generates unique tokens each time", () => { const tokens = new Set(); for (let i = 0; i < 50; i++) { const { raw } = generatePrefixedToken("ec_pat_"); tokens.add(raw); } expect(tokens.size).toBe(50); }); it("generates unique hashes for different tokens", () => { const { hash: hash1 } = generatePrefixedToken("ec_pat_"); const { hash: hash2 } = generatePrefixedToken("ec_pat_"); expect(hash1).not.toBe(hash2); }); }); describe("hashApiToken", () => { it("produces a deterministic hash", () => { const hash1 = hashApiToken("ec_pat_abc123"); const hash2 = hashApiToken("ec_pat_abc123"); expect(hash1).toBe(hash2); }); it("produces different hashes for different tokens", () => { const hash1 = hashApiToken("ec_pat_abc123"); const hash2 = hashApiToken("ec_pat_def456"); expect(hash1).not.toBe(hash2); }); it("hashes the full prefixed token", () => { // Same suffix but different prefix should produce different hashes const hash1 = hashApiToken("ec_pat_abc123"); const hash2 = hashApiToken("ec_oat_abc123"); expect(hash1).not.toBe(hash2); }); it("produces URL-safe base64 output", () => { const hash = hashApiToken("ec_pat_test"); // Should not contain +, /, or = (standard base64 chars) expect(hash).not.toMatch(BASE64URL_INVALID_CHARS_REGEX); // Should only contain base64url chars expect(hash).toMatch(BASE64URL_VALID_REGEX); }); }); describe("validateScopes", () => { it("returns empty array for valid scopes", () => { const invalid = validateScopes(["content:read", "media:write"]); expect(invalid).toEqual([]); }); it("returns invalid scopes", () => { const invalid = validateScopes(["content:read", "invalid:scope", "admin"]); expect(invalid).toEqual(["invalid:scope"]); }); it("handles empty array", () => { expect(validateScopes([])).toEqual([]); }); it("accepts all valid scopes", () => { const invalid = validateScopes([...VALID_SCOPES]); expect(invalid).toEqual([]); }); }); describe("hasScope", () => { it("returns true when scope is present", () => { expect(hasScope(["content:read", "media:write"], "content:read")).toBe(true); }); it("returns false when scope is missing", () => { expect(hasScope(["content:read"], "content:write")).toBe(false); }); it("admin scope grants access to everything", () => { expect(hasScope(["admin"], "content:read")).toBe(true); expect(hasScope(["admin"], "schema:write")).toBe(true); expect(hasScope(["admin"], "media:write")).toBe(true); }); it("handles empty scopes", () => { expect(hasScope([], "content:read")).toBe(false); }); }); // --------------------------------------------------------------------------- // scopesForRole — maps roles to maximum allowed scopes // --------------------------------------------------------------------------- describe("scopesForRole", () => { it("SUBSCRIBER gets only read scopes for content and media", () => { const scopes = scopesForRole(Role.SUBSCRIBER); expect(scopes).toContain("content:read"); expect(scopes).toContain("media:read"); expect(scopes).not.toContain("content:write"); expect(scopes).not.toContain("media:write"); expect(scopes).not.toContain("schema:read"); expect(scopes).not.toContain("schema:write"); expect(scopes).not.toContain("admin"); }); it("CONTRIBUTOR gets content and media read/write", () => { const scopes = scopesForRole(Role.CONTRIBUTOR); expect(scopes).toContain("content:read"); expect(scopes).toContain("content:write"); expect(scopes).toContain("media:read"); expect(scopes).toContain("media:write"); expect(scopes).not.toContain("schema:read"); expect(scopes).not.toContain("schema:write"); expect(scopes).not.toContain("admin"); }); it("EDITOR gets content, media, and schema:read", () => { const scopes = scopesForRole(Role.EDITOR); expect(scopes).toContain("content:read"); expect(scopes).toContain("content:write"); expect(scopes).toContain("media:read"); expect(scopes).toContain("media:write"); expect(scopes).toContain("schema:read"); expect(scopes).not.toContain("schema:write"); expect(scopes).not.toContain("admin"); }); it("ADMIN gets all scopes including admin and schema:write", () => { const scopes = scopesForRole(Role.ADMIN); expect(scopes).toContain("content:read"); expect(scopes).toContain("content:write"); expect(scopes).toContain("media:read"); expect(scopes).toContain("media:write"); expect(scopes).toContain("schema:read"); expect(scopes).toContain("schema:write"); expect(scopes).toContain("admin"); }); }); // --------------------------------------------------------------------------- // clampScopes — intersects requested scopes with role-allowed scopes // --------------------------------------------------------------------------- describe("clampScopes", () => { it("strips admin scope from non-admin role", () => { const result = clampScopes(["content:read", "admin"], Role.CONTRIBUTOR); expect(result).toEqual(["content:read"]); }); it("strips schema:write from editor role", () => { const result = clampScopes(["schema:read", "schema:write"], Role.EDITOR); expect(result).toEqual(["schema:read"]); }); it("preserves all scopes for admin role", () => { const all = [ "content:read", "content:write", "media:read", "media:write", "schema:read", "schema:write", "admin", ]; const result = clampScopes(all, Role.ADMIN); expect(result).toEqual(all); }); it("returns empty array when no scopes survive clamping", () => { const result = clampScopes(["admin", "schema:write"], Role.SUBSCRIBER); expect(result).toEqual([]); }); it("handles empty input", () => { expect(clampScopes([], Role.ADMIN)).toEqual([]); }); it("strips schema:read from contributor role", () => { const result = clampScopes(["content:read", "schema:read"], Role.CONTRIBUTOR); expect(result).toEqual(["content:read"]); }); });