import { describe, it, expect, vi, beforeEach } from "vitest"; // permission-manager imports the side-effectful DB barrel and the logger at the // top level. Stub both so importing the module does not spin up the real // database / encryption stack. We then drive hasPermission via a spied // getUserPermissions so we test the wildcard-matching logic in isolation. vi.mock("../../database/db/index.js", () => ({ db: {} })); vi.mock("../../utils/logger.js", () => ({ databaseLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), }, })); const accessState = vi.hoisted(() => ({ ownerId: "owner" as string, grant: null as { id: number; permissionLevel: string; expiresAt: string | null; } | null, touched: [] as number[], adminIds: new Set(), rolePermissionCalls: 0, rolePermissions: [] as { permissions: string }[], ownedHostIds: new Set(), visibleGrants: [] as { hostId: number }[], ownedQueryCalls: 0, })); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentHostResolutionRepository: () => ({ isHostOwnedByUser: async (_hostId: number, userId: string) => userId === accessState.ownerId, findHostOwnerId: async () => accessState.ownerId, listOwnedHostIds: async () => { accessState.ownedQueryCalls += 1; return accessState.ownedHostIds; }, }), createCurrentRbacAccessRepository: () => ({ listVisibleHostAccessEntries: async () => accessState.visibleGrants, findActiveHostAccess: async () => accessState.grant, touchHostAccess: async (id: number) => { accessState.touched.push(id); }, deleteExpiredHostAccess: async () => 0, }), createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [], listUserRolePermissions: async () => { accessState.rolePermissionCalls += 1; return accessState.rolePermissions; }, userHasAnyRoleName: async () => false, }), createCurrentUserRepository: () => ({ findById: async (userId: string) => accessState.adminIds.has(userId) ? { id: userId, isAdmin: true } : null, }), })); const { PermissionManager } = await import("../../utils/permission-manager.js"); type PermissionManagerInstance = ReturnType< typeof PermissionManager.getInstance >; describe("PermissionManager.hasPermission wildcard matching", () => { let manager: PermissionManagerInstance; function withPermissions(permissions: string[]) { vi.spyOn(manager, "getUserPermissions").mockResolvedValue(permissions); } beforeEach(() => { manager = PermissionManager.getInstance(); vi.restoreAllMocks(); }); it("grants everything for the global wildcard '*'", async () => { withPermissions(["*"]); expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); expect(await manager.hasPermission("u1", "anything.at.all")).toBe(true); }); it("grants an exact permission match", async () => { withPermissions(["hosts.read", "hosts.write"]); expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); }); it("grants via a prefix wildcard", async () => { withPermissions(["hosts.*"]); expect(await manager.hasPermission("u1", "hosts.read")).toBe(true); expect(await manager.hasPermission("u1", "hosts.write")).toBe(true); }); it("grants via a deep prefix wildcard", async () => { withPermissions(["admin.users.*"]); expect(await manager.hasPermission("u1", "admin.users.delete")).toBe(true); }); it("denies when no exact or wildcard permission matches", async () => { withPermissions(["hosts.read"]); expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); expect(await manager.hasPermission("u1", "credentials.read")).toBe(false); }); it("denies when the user has no permissions", async () => { withPermissions([]); expect(await manager.hasPermission("u1", "hosts.read")).toBe(false); }); it("does not let a narrower wildcard grant a sibling branch", async () => { withPermissions(["hosts.read.*"]); expect(await manager.hasPermission("u1", "hosts.write")).toBe(false); }); }); describe("PermissionManager.canAccessHost level hierarchy", () => { const manager = PermissionManager.getInstance(); const actions = ["connect", "view", "edit", "manage"] as const; const levels = ["connect", "view", "edit", "manage"] as const; const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const; beforeEach(() => { vi.restoreAllMocks(); accessState.ownerId = "owner"; accessState.grant = null; accessState.touched = []; accessState.adminIds = new Set(); }); it("grants the owner every action including delete", async () => { for (const action of [...actions, "delete"] as const) { const info = await manager.canAccessHost("owner", 42, action); expect(info).toMatchObject({ hasAccess: true, isOwner: true }); } }); it("denies everything without a grant", async () => { const info = await manager.canAccessHost("stranger", 42, "connect"); expect(info).toMatchObject({ hasAccess: false, isShared: false }); }); it("enforces the connect < view < edit < manage hierarchy", async () => { for (const level of levels) { accessState.grant = { id: 5, permissionLevel: level, expiresAt: null }; for (const action of actions) { const info = await manager.canAccessHost("recipient", 42, action); expect(info.hasAccess).toBe(rank[level] >= rank[action]); expect(info.permissionLevel).toBe(level); expect(info.isShared).toBe(true); } } }); it("never grants delete to a shared recipient", async () => { accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null }; const info = await manager.canAccessHost("recipient", 42, "delete"); expect(info.hasAccess).toBe(false); }); it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => { accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null }; const connect = await manager.canAccessHost("recipient", 42, "connect"); expect(connect.hasAccess).toBe(true); expect(connect.permissionLevel).toBe("connect"); const view = await manager.canAccessHost("recipient", 42, "view"); expect(view.hasAccess).toBe(false); }); it("only touches the grant timestamp on connect", async () => { accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null }; await manager.canAccessHost("recipient", 42, "manage"); expect(accessState.touched).toEqual([]); await manager.canAccessHost("recipient", 42, "connect"); expect(accessState.touched).toEqual([5]); }); it("grants admins owner-equivalent access to any host via bypass", async () => { accessState.adminIds = new Set(["adminUser"]); for (const action of actions) { const info = await manager.canAccessHost("adminUser", 42, action); expect(info).toMatchObject({ hasAccess: true, isOwner: false, isAdminBypass: true, permissionLevel: "manage", }); } }); it("upgrades an under-privileged admin's share access via bypass", async () => { accessState.adminIds = new Set(["adminUser"]); accessState.grant = { id: 7, permissionLevel: "connect", expiresAt: null }; const info = await manager.canAccessHost("adminUser", 42, "manage"); expect(info).toMatchObject({ hasAccess: true, isAdminBypass: true }); }); it("does not grant a non-admin stranger admin bypass", async () => { const info = await manager.canAccessHost("stranger", 42, "manage"); expect(info.hasAccess).toBe(false); expect(info.isAdminBypass).toBeUndefined(); }); }); describe("PermissionManager.getUserPermissions caching", () => { let manager: PermissionManagerInstance; beforeEach(() => { vi.restoreAllMocks(); manager = PermissionManager.getInstance(); accessState.rolePermissionCalls = 0; accessState.rolePermissions = [{ permissions: '["hosts.read"]' }]; manager.invalidateUserPermissionCache("cache-user"); }); it("serves repeat lookups from cache instead of re-querying roles", async () => { expect(await manager.getUserPermissions("cache-user")).toEqual([ "hosts.read", ]); expect(await manager.getUserPermissions("cache-user")).toEqual([ "hosts.read", ]); expect(accessState.rolePermissionCalls).toBe(1); }); it("re-reads roles after an explicit invalidation", async () => { await manager.getUserPermissions("cache-user"); manager.invalidateUserPermissionCache("cache-user"); accessState.rolePermissions = [{ permissions: '["hosts.write"]' }]; expect(await manager.getUserPermissions("cache-user")).toEqual([ "hosts.write", ]); expect(accessState.rolePermissionCalls).toBe(2); }); it("expires an entry once its own TTL has passed", async () => { vi.useFakeTimers(); try { await manager.getUserPermissions("cache-user"); // Just past the 5 minute TTL. vi.advanceTimersByTime(5 * 60 * 1000 + 1); await manager.getUserPermissions("cache-user"); expect(accessState.rolePermissionCalls).toBe(2); } finally { vi.useRealTimers(); } }); it("keeps a still-fresh entry when the sweep runs", async () => { vi.useFakeTimers(); try { await manager.getUserPermissions("cache-user"); // Fire the periodic sweep without crossing this entry's own TTL. The // old implementation cleared the whole map here, expiring every active // user at once. vi.advanceTimersByTime(5 * 60 * 1000 - 1000); await manager.getUserPermissions("cache-user"); expect(accessState.rolePermissionCalls).toBe(1); } finally { vi.useRealTimers(); } }); it("returns an empty set rather than throwing when role lookup fails", async () => { manager.invalidateUserPermissionCache("boom-user"); accessState.rolePermissions = [{ permissions: "not-json" }]; expect(await manager.getUserPermissions("boom-user")).toEqual([]); }); }); describe("PermissionManager.filterAccessibleHostIds", () => { let manager: PermissionManagerInstance; beforeEach(() => { vi.restoreAllMocks(); manager = PermissionManager.getInstance(); accessState.adminIds = new Set(); accessState.ownedHostIds = new Set(); accessState.visibleGrants = []; accessState.ownedQueryCalls = 0; }); it("keeps hosts the user owns", async () => { accessState.ownedHostIds = new Set([1, 2]); const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]); expect([...allowed].sort()).toEqual([1, 2]); }); it("keeps hosts shared with the user", async () => { accessState.visibleGrants = [{ hostId: 7 }]; const allowed = await manager.filterAccessibleHostIds("u1", [7, 8]); expect([...allowed]).toEqual([7]); }); it("combines owned and shared without duplicating", async () => { accessState.ownedHostIds = new Set([1]); accessState.visibleGrants = [{ hostId: 1 }, { hostId: 2 }]; const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]); expect([...allowed].sort()).toEqual([1, 2]); }); it("excludes another tenant's hosts", async () => { accessState.ownedHostIds = new Set([1]); const allowed = await manager.filterAccessibleHostIds("u1", [1, 99, 100]); expect(allowed.has(99)).toBe(false); expect(allowed.has(100)).toBe(false); }); it("gives an admin every host without per-host lookups", async () => { accessState.adminIds = new Set(["admin1"]); const allowed = await manager.filterAccessibleHostIds( "admin1", [1, 2, 3, 4], ); expect([...allowed].sort()).toEqual([1, 2, 3, 4]); }); it("resolves the whole fleet with a single owned-hosts query", async () => { accessState.ownedHostIds = new Set( Array.from({ length: 500 }, (_, i) => i + 1), ); const ids = Array.from({ length: 500 }, (_, i) => i + 1); const allowed = await manager.filterAccessibleHostIds("u1", ids); expect(allowed.size).toBe(500); // The point of the batch path: cost does not scale with host count. expect(accessState.ownedQueryCalls).toBe(1); }); it("short-circuits an empty list without querying", async () => { const allowed = await manager.filterAccessibleHostIds("u1", []); expect(allowed.size).toBe(0); expect(accessState.ownedQueryCalls).toBe(0); }); it("fails closed when the lookup throws", async () => { accessState.ownedHostIds = null as unknown as Set; const allowed = await manager.filterAccessibleHostIds("u1", [1, 2]); expect(allowed.size).toBe(0); }); });