/** * Unit tests for roles route validation and role management logic. * * Tests the role name validation, builtin role protection, * permission validation, and user reassignment on role deletion. */ import { describe, expect, it } from "vitest"; // ── Reproduce validation logic from roles.ts ──────────────────────────── type Permission = | "tools:use" | "files:own" | "files:all" | "apikeys:own" | "apikeys:all" | "pipelines:own" | "pipelines:all" | "settings:read" | "settings:write" | "users:manage" | "teams:manage" | "features:manage" | "system:health" | "audit:read"; const ALL_PERMISSIONS: Permission[] = [ "tools:use", "files:own", "files:all", "apikeys:own", "apikeys:all", "pipelines:own", "pipelines:all", "settings:read", "settings:write", "users:manage", "teams:manage", "features:manage", "system:health", "audit:read", ]; const ROLE_NAME_PATTERN = /^[a-z0-9_-]+$/; function validateRoleName(raw: string): { success: boolean; name?: string; error?: string } { const trimmed = raw.trim().toLowerCase(); if (trimmed.length < 2 || trimmed.length > 30) { return { success: false, error: "Role name must be 2-30 characters" }; } if (!ROLE_NAME_PATTERN.test(trimmed)) { return { success: false, error: "Role name can only contain lowercase letters, numbers, hyphens, and underscores", }; } return { success: true, name: trimmed }; } function validateCreateRole(body: { name?: string; description?: string; permissions?: string[]; }): { success: boolean; error?: string } { if (!body.name) return { success: false, error: "name is required" }; const nameResult = validateRoleName(body.name); if (!nameResult.success) return nameResult; if (!body.permissions || body.permissions.length === 0) { return { success: false, error: "At least one permission is required" }; } if (body.description !== undefined && body.description.length > 500) { return { success: false, error: "Description too long" }; } return { success: true }; } function validateUpdateRole(body: { name?: string; description?: string; permissions?: string[]; }): { success: boolean; error?: string } { if (body.name !== undefined) { const nameResult = validateRoleName(body.name); if (!nameResult.success) return nameResult; } if (body.description !== undefined && body.description.length > 500) { return { success: false, error: "Description too long" }; } return { success: true }; } function validatePermissions(permissions: string[]): string[] { return permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); } // ── Tests ─────────────────────────────────────────────────────────────── describe("roles route logic", () => { describe("role name validation", () => { it("accepts valid lowercase name", () => { const result = validateRoleName("viewer"); expect(result.success).toBe(true); expect(result.name).toBe("viewer"); }); it("converts to lowercase", () => { const result = validateRoleName("Viewer"); expect(result.success).toBe(true); expect(result.name).toBe("viewer"); }); it("trims whitespace and lowercases", () => { const result = validateRoleName(" Manager "); expect(result.success).toBe(true); expect(result.name).toBe("manager"); }); it("allows hyphens", () => { const result = validateRoleName("team-lead"); expect(result.success).toBe(true); }); it("allows underscores", () => { const result = validateRoleName("team_lead"); expect(result.success).toBe(true); }); it("allows numbers", () => { const result = validateRoleName("admin2"); expect(result.success).toBe(true); }); it("rejects single character", () => { const result = validateRoleName("a"); expect(result.success).toBe(false); }); it("rejects names longer than 30 characters", () => { const result = validateRoleName("a".repeat(31)); expect(result.success).toBe(false); }); it("accepts name exactly 30 characters", () => { const result = validateRoleName("a".repeat(30)); expect(result.success).toBe(true); }); it("accepts name exactly 2 characters", () => { const result = validateRoleName("ab"); expect(result.success).toBe(true); }); it("rejects spaces in name", () => { const result = validateRoleName("team lead"); expect(result.success).toBe(false); }); it("rejects special characters", () => { const result = validateRoleName("admin@role"); expect(result.success).toBe(false); }); it("rejects dots in name", () => { const result = validateRoleName("admin.role"); expect(result.success).toBe(false); }); }); describe("create role validation", () => { it("accepts valid create payload", () => { const result = validateCreateRole({ name: "reviewer", description: "Can review files", permissions: ["tools:use", "files:own"], }); expect(result.success).toBe(true); }); it("description is optional", () => { const result = validateCreateRole({ name: "reviewer", permissions: ["tools:use"], }); expect(result.success).toBe(true); }); it("requires at least one permission", () => { const result = validateCreateRole({ name: "reviewer", permissions: [], }); expect(result.success).toBe(false); }); it("rejects missing permissions", () => { const result = validateCreateRole({ name: "reviewer", }); expect(result.success).toBe(false); }); it("rejects description longer than 500 characters", () => { const result = validateCreateRole({ name: "reviewer", permissions: ["tools:use"], description: "x".repeat(501), }); expect(result.success).toBe(false); }); }); describe("update role validation", () => { it("accepts partial update with just name", () => { const result = validateUpdateRole({ name: "new-name" }); expect(result.success).toBe(true); }); it("accepts partial update with just description", () => { const result = validateUpdateRole({ description: "Updated description" }); expect(result.success).toBe(true); }); it("accepts partial update with just permissions", () => { const result = validateUpdateRole({ permissions: ["tools:use", "files:own"] }); expect(result.success).toBe(true); }); it("accepts empty update body", () => { const result = validateUpdateRole({}); expect(result.success).toBe(true); }); it("accepts all fields together", () => { const result = validateUpdateRole({ name: "updated-role", description: "New desc", permissions: ["tools:use"], }); expect(result.success).toBe(true); }); }); describe("permission validation", () => { it("returns empty array for valid permissions", () => { const invalid = validatePermissions(["tools:use", "files:own"]); expect(invalid).toHaveLength(0); }); it("returns invalid permission strings", () => { const invalid = validatePermissions(["tools:use", "invalid:perm", "also:bad"]); expect(invalid).toEqual(["invalid:perm", "also:bad"]); }); it("returns all permissions if all are invalid", () => { const invalid = validatePermissions(["foo", "bar"]); expect(invalid).toEqual(["foo", "bar"]); }); it("handles empty array", () => { const invalid = validatePermissions([]); expect(invalid).toHaveLength(0); }); }); describe("ALL_PERMISSIONS constant", () => { it("contains exactly 14 permissions", () => { expect(ALL_PERMISSIONS).toHaveLength(14); }); it("contains all expected permissions", () => { expect(ALL_PERMISSIONS).toContain("tools:use"); expect(ALL_PERMISSIONS).toContain("files:own"); expect(ALL_PERMISSIONS).toContain("files:all"); expect(ALL_PERMISSIONS).toContain("apikeys:own"); expect(ALL_PERMISSIONS).toContain("apikeys:all"); expect(ALL_PERMISSIONS).toContain("settings:read"); expect(ALL_PERMISSIONS).toContain("settings:write"); expect(ALL_PERMISSIONS).toContain("users:manage"); expect(ALL_PERMISSIONS).toContain("teams:manage"); expect(ALL_PERMISSIONS).toContain("features:manage"); expect(ALL_PERMISSIONS).toContain("system:health"); expect(ALL_PERMISSIONS).toContain("audit:read"); }); }); describe("builtin role protection", () => { it("blocks modification of builtin roles", () => { const role = { isBuiltin: true, name: "admin" }; expect(role.isBuiltin).toBe(true); }); it("allows modification of custom roles", () => { const role = { isBuiltin: false, name: "reviewer" }; expect(role.isBuiltin).toBe(false); }); it("blocks deletion of builtin roles", () => { const role = { isBuiltin: true, name: "user" }; expect(role.isBuiltin).toBe(true); }); }); describe("user reassignment on role deletion", () => { it("reassigns users to 'user' role when custom role is deleted", () => { const usersOnRole = [ { id: "u1", role: "reviewer" }, { id: "u2", role: "reviewer" }, ]; const updatedUsers = usersOnRole.map((u) => ({ ...u, role: "user" })); expect(updatedUsers.every((u) => u.role === "user")).toBe(true); }); }); describe("role name rename cascading", () => { it("updates all users when role name changes", () => { const users = [ { id: "u1", role: "old-name" }, { id: "u2", role: "old-name" }, ]; const newName = "new-name"; const updated = users.map((u) => ({ ...u, role: newName })); expect(updated.every((u) => u.role === "new-name")).toBe(true); }); }); });