Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
224
packages/core/tests/unit/auth/api-tokens.test.ts
Normal file
224
packages/core/tests/unit/auth/api-tokens.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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<string>();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user