import { beforeEach, describe, expect, it, vi } from "vitest"; const h = vi.hoisted(() => ({ granted: new Set(), actor: undefined as string | undefined, access: { hasAccess: true, isOwner: true, isShared: false, } as Record, ownedHosts: [] as Array>, sharedRows: [] as Array>, visibleGrants: [] as Array<{ hostId: number }>, ownerId: "user-1", hostById: null as Record | null, upserts: [] as Array>, created: [] as Array>, updates: [] as Array>, updateResult: null as Record | null, users: [] as Array<{ id: string; username: string }>, activities: [] as Array>, sessions: [] as number[], released: [] as number[], importRows: [] as Array<{ hostId: number; row: Record }>, statusReports: [] as Array>, statusPorts: [] as string[], statusPortsRemoved: [] as string[], userPermissions: new Set(), deleted: [] as Array<{ userId: string; hostId: number }>, roles: [] as Array<{ id: number; name: string; displayName: string | null; isSystem: boolean; }>, })); vi.mock("../../plugins/permissions.js", async () => { const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); return { assertCapability: async ( pluginId: string, capability: string, declared: readonly string[], ) => { if (!declared.includes(capability) || !h.granted.has(capability)) { throw new PluginCapabilityError(pluginId, capability); } }, capabilityRefused: (pluginId: string, capability: string) => new PluginCapabilityError(pluginId, capability), }; }); /** The access check behind trackSession and reportLogin is async. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor })); vi.mock("../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ canAccessHost: async () => h.access, hasPermission: async (_userId: string, permission: string) => h.userPermissions.has(permission), }), }, })); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentHostDefaultsRepository: () => ({ listHosts: async () => [], }), createCurrentHostResolutionRepository: () => ({ findHostsByUserId: async () => h.ownedHosts, listHostRowsForAccessList: async () => h.sharedRows, findHostOwnerId: async () => h.ownerId, findHostById: async () => h.hostById, }), createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [], listRoles: async () => h.roles, }), createCurrentRbacAccessRepository: () => ({ listVisibleHostAccessEntries: async () => h.visibleGrants, upsertHostAccess: async (input: Record) => { h.upserts.push(input); return { id: h.upserts.length, created: true }; }, }), createCurrentUserRepository: () => ({ listAll: async () => h.users, }), createCurrentHostRepository: () => ({ createEncryptedForUser: async ( userId: string, host: Record, ) => { const created = { id: 99, ...host, userId }; h.created.push(created); return created; }, updateEncryptedForUser: async ( userId: string, hostId: number, patch: Record, ) => { h.updates.push({ userId, hostId, patch }); if (!h.updateResult) return null; return { ...h.updateResult, ...patch, id: hostId, userId }; }, listDecryptedByUserId: async () => h.ownedHosts, }), })); vi.mock("../../services/recent-activity.js", () => ({ recordRecentActivity: async ( userId: string, entry: Record, ) => { h.activities.push({ userId, ...entry }); return { status: "logged", id: 1 }; }, })); vi.mock("../../hosts/host-session-status.js", () => ({ hostSessionStatus: { register: (hostId: number) => { h.sessions.push(hostId); return () => h.released.push(hostId); }, }, })); vi.mock("../../hosts/status/host-status-service.js", () => ({ hostStatusService: { get: (hostId: number) => hostId === 4 ? { status: "online", lastChecked: "t" } : null, check: async (hostId: number) => ({ status: "offline", lastChecked: String(hostId), }), reportLogin: (hostId: number, outcome: Record) => h.statusReports.push({ hostId, ...outcome }), registerPort: (connectionType: string) => { h.statusPorts.push(connectionType); return () => h.statusPortsRemoved.push(connectionType); }, }, })); vi.mock("../../database/routes/host-plugin-settings.js", () => ({ loadHostPluginSettings: async (hostIds: number[]) => new Map( hostIds.map((id) => [ id, { fixture: { mine: `own-${id}` }, other: { theirs: true } }, ]), ), checkHostPluginSettingsInput: ( input: Record>, ) => { const errors: string[] = []; const writes: Array<{ manifest: { id: string }; values: Record; }> = []; const skipped: string[] = []; for (const [pluginId, values] of Object.entries(input)) { if (pluginId === "off") { skipped.push(pluginId); continue; } for (const key of Object.keys(values)) { if (key === "undeclared") errors.push(`${pluginId}.${key}`); } writes.push({ manifest: { id: pluginId }, values }); } return { writes, skipped, errors }; }, writeHostPluginSettings: async ( manifest: { id: string }, hostId: number, values: Record, ) => { h.importRows.push({ hostId, row: { [manifest.id]: values } }); }, })); vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ SharedHostSecretsManager: { getInstance: () => ({ snapshotForUser: async () => {}, snapshotForRole: async () => {}, }), }, })); vi.mock("../../hosts/defaults/index.js", () => ({ applyHostDefaultsToWrite: async () => ({ core: [] }), applyDefaultsAfterHostWrite: async () => {}, })); vi.mock("../../hosts/defaults/overrides.js", () => ({ changeHostOverrides: async () => {}, })); vi.mock("../../hosts/delete-host.js", () => ({ deleteOwnedHost: async (userId: string, hostId: number) => { h.deleted.push({ userId, hostId }); return hostId === 404 ? null : { id: hostId, name: "gone" }; }, })); const { createPluginHosts } = await import("../../plugins/ctx-hosts.js"); const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); function manifest(capabilities: string[]) { return { id: "fixture", name: "Fixture", version: "1.0.0", capabilities, } as never; } beforeEach(() => { h.granted = new Set(); h.actor = "user-1"; h.access = { hasAccess: true, isOwner: true, isShared: false }; h.ownedHosts = []; h.sharedRows = []; h.visibleGrants = []; h.ownerId = "user-1"; h.hostById = null; h.upserts = []; h.created = []; h.updates = []; h.updateResult = null; h.users = []; h.roles = []; h.userPermissions = new Set(); h.deleted = []; }); describe("ctx.hosts", () => { it("refuses list/get/checkAccess without hosts:read", async () => { const audit = vi.fn(async () => {}); const hosts = createPluginHosts({ manifest: manifest([]), audit }); await expect(hosts.list()).rejects.toBeInstanceOf(PluginCapabilityError); await expect(hosts.get(1)).rejects.toBeInstanceOf(PluginCapabilityError); await expect(hosts.checkAccess(1, "view")).rejects.toBeInstanceOf( PluginCapabilityError, ); }); it("lists owned and shared hosts once granted hosts:read", async () => { h.granted = new Set(["hosts:read"]); h.ownedHosts = [ { id: 1, userId: "user-1", name: "own", ip: "10.0.0.1", port: 22, username: "root", tags: "prod", folder: null, authType: "password", }, ]; h.sharedRows = [ { id: 2, userId: "user-2", name: "shared", ip: "10.0.0.2", port: 22, username: "root", tags: null, folder: null, authType: "key", }, ]; const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); const list = await hosts.list(); expect(list.map((h2) => h2.id)).toEqual([1, 2]); }); it("checkAccess reports the granted level", async () => { h.granted = new Set(["hosts:read"]); h.access = { hasAccess: true, isOwner: false, isShared: true, permissionLevel: "edit", expiresAt: null, }; const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); const access = await hosts.checkAccess(1, "edit"); expect(access).toMatchObject({ hasAccess: true, permissionLevel: "edit" }); }); it("refuses share, listUsers, listRoles without hosts:write, auditing the refusal", async () => { const audit = vi.fn(async () => {}); const hosts = createPluginHosts({ manifest: manifest([]), audit }); await expect( hosts.share(1, [{ type: "user", id: "user-2" }], "view"), ).rejects.toBeInstanceOf(PluginCapabilityError); await expect(hosts.listUsers()).rejects.toBeInstanceOf( PluginCapabilityError, ); await expect(hosts.listRoles()).rejects.toBeInstanceOf( PluginCapabilityError, ); expect(audit).toHaveBeenCalledWith( "hosts_share", expect.any(String), expect.objectContaining({ success: false }), ); }); it("shares a host it manages and snapshots secrets per target", async () => { h.granted = new Set(["hosts:write"]); h.access = { hasAccess: true, isOwner: true, isShared: false }; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); const result = await hosts.share( 5, [{ type: "user", id: "user-2" }], "view", 24, ); expect(result).toEqual({ hostId: 5, shared: true }); expect(h.upserts).toHaveLength(1); expect(h.upserts[0]).toMatchObject({ hostId: 5, permissionLevel: "view", targetType: "user", targetUserId: "user-2", }); }); it("refuses to share a host the caller does not manage", async () => { h.granted = new Set(["hosts:write"]); h.access = { hasAccess: false, isOwner: false, isShared: false }; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); const result = await hosts.share( 5, [{ type: "user", id: "user-2" }], "view", ); expect(result).toEqual({ hostId: 5, shared: false, reason: "forbidden" }); }); it("lists non-system roles and users once granted hosts:write", async () => { h.granted = new Set(["hosts:write"]); h.users = [{ id: "user-1", username: "alice" }]; h.roles = [ { id: 1, name: "admin", displayName: "Admin", isSystem: true }, { id: 2, name: "auditor", displayName: "Auditor", isSystem: false }, ]; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); expect(await hosts.listUsers()).toEqual([ { id: "user-1", username: "alice" }, ]); expect(await hosts.listRoles()).toEqual([ { id: 2, name: "auditor", displayName: "Auditor" }, ]); }); it("refuses create/update/listOwned without hosts:write", async () => { const audit = vi.fn(async () => {}); const hosts = createPluginHosts({ manifest: manifest([]), audit }); await expect( hosts.create({ name: "n", ip: "10.0.0.1", port: 22, username: "root", authType: "password", }), ).rejects.toBeInstanceOf(PluginCapabilityError); await expect(hosts.update(1, { name: "x" })).rejects.toBeInstanceOf( PluginCapabilityError, ); await expect(hosts.listOwned()).rejects.toBeInstanceOf( PluginCapabilityError, ); expect(audit).toHaveBeenCalledWith( "hosts_create", expect.any(String), expect.objectContaining({ success: false }), ); }); it("creates a host once granted hosts:write", async () => { h.granted = new Set(["hosts:write"]); h.importRows = []; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); const created = await hosts.create({ name: "pve-guest", ip: "10.0.0.5", port: 22, username: "root", authType: "password", tags: ["a", "b"], jumpHosts: [{ hostId: 3 }], pluginSettings: { "remote-desktop": { enableRdp: true } }, }); expect(created).toMatchObject({ id: 99, name: "pve-guest", userId: "user-1", tags: "a,b", jumpHosts: [{ hostId: 3 }], pluginSettings: { mine: "own-99" }, }); expect(h.created[0]).toMatchObject({ tags: "a,b", jumpHosts: '[{"hostId":3}]', }); // Another plugin's settings go to that plugin, validated by core. expect(h.importRows).toEqual([ { hostId: 99, row: { "remote-desktop": { enableRdp: true } } }, ]); }); it("refuses a field the SDK does not take instead of dropping it", async () => { h.granted = new Set(["hosts:write"]); const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); await expect( hosts.create({ name: "n", ip: "10.0.0.1", port: 22, username: "root", authType: "password", enableRdp: true, } as never), ).rejects.toThrow(/enableRdp/); await expect( hosts.update(5, { userId: "someone-else" } as never), ).rejects.toThrow(/userId/); await expect( hosts.update(5, { pluginSettings: { docker: { undeclared: 1 } } }), ).rejects.toThrow(/undeclared/); expect(h.created).toHaveLength(0); expect(h.updates).toHaveLength(0); }); it("builds the record field by field, with no secret and no other plugin's settings", async () => { h.granted = new Set(["hosts:write"]); h.ownedHosts = [ { id: 4, userId: "user-1", name: "own", ip: "10.0.0.4", port: 22, username: "root", authType: "password", password: "secret", key: "PRIVATE", autostartPassword: "x", terminalConfig: '{"keepaliveInterval":5,"fontSize":14}', enableDocker: true, jumpHosts: "[]", }, ]; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); const [record] = await hosts.listOwned(); const loose = record as unknown as Record; for (const field of [ "password", "key", "autostartPassword", "terminalConfig", "enableDocker", ]) { expect(loose).not.toHaveProperty(field); } expect(record.sshOptions).toEqual({ keepaliveInterval: 5 }); expect(record.pluginSettings).toEqual({ mine: "own-4" }); expect(record.status).toEqual({ status: "online", lastChecked: "t" }); expect(record.enableSsh).toBe(true); }); it("updates a host once granted hosts:write", async () => { h.granted = new Set(["hosts:write"]); h.updateResult = { id: 5, name: "old" }; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); const updated = await hosts.update(5, { name: "new" }); expect(updated).toMatchObject({ id: 5, name: "new" }); expect(h.updates).toEqual([ { userId: "user-1", hostId: 5, patch: { name: "new" } }, ]); expect(updated?.pluginSettings).toEqual({ mine: "own-5" }); }); it("returns null updating a host that does not exist for this user", async () => { h.granted = new Set(["hosts:write"]); const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); expect(await hosts.update(404, { name: "x" })).toBeNull(); }); it("lists owned hosts decrypted once granted hosts:write", async () => { h.granted = new Set(["hosts:write"]); h.ownedHosts = [{ id: 1, userId: "user-1", name: "own" }]; const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]); }); }); describe("ctx.hosts.delete", () => { it("refuses without hosts:write and audits it", async () => { const audit = vi.fn(async () => {}); const hosts = createPluginHosts({ manifest: manifest([]), audit }); await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError); expect(audit).toHaveBeenCalledWith( "hosts_delete", "host 1", expect.objectContaining({ success: false }), ); expect(h.deleted).toEqual([]); }); it("refuses an acting user without hosts.delete", async () => { h.granted = new Set(["hosts:write"]); const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts"); expect(h.deleted).toEqual([]); }); it("deletes the actor's own host through core's delete path", async () => { h.granted = new Set(["hosts:write"]); h.userPermissions = new Set(["hosts.delete"]); const hosts = createPluginHosts({ manifest: manifest(["hosts:write"]), audit: vi.fn(async () => {}), }); expect(await hosts.delete(7)).toBe(true); expect(await hosts.delete(404)).toBe(false); expect(h.deleted).toEqual([ { userId: "user-1", hostId: 7 }, { userId: "user-1", hostId: 404 }, ]); }); }); describe("ctx.hosts session tracking and activity", () => { it("counts a live session through core and releases it", async () => { h.sessions = []; h.released = []; const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); const release = hosts.trackSession(4); await settle(); expect(h.sessions).toEqual([4]); release(); expect(h.released).toEqual([4]); }); it("refuses to track a session without hosts:read declared", () => { const hosts = createPluginHosts({ manifest: manifest([]), audit: vi.fn(async () => {}), }); expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError); }); it("records activity for the acting user only", async () => { h.activities = []; h.granted = new Set(["hosts:read"]); h.actor = "user-7"; const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); await hosts.recordActivity(4, "terminal", "web-01"); expect(h.activities).toEqual([ { userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" }, ]); }); }); describe("ctx.hosts.status", () => { it("reads and checks core's status with hosts:read", async () => { h.granted = new Set(["hosts:read"]); const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); await expect(hosts.status.get(4)).resolves.toEqual({ status: "online", lastChecked: "t", }); await expect(hosts.status.get(5)).resolves.toBeNull(); await expect(hosts.status.check(5)).resolves.toEqual({ status: "offline", lastChecked: "5", }); }); it("refuses every status member without hosts:read", async () => { const hosts = createPluginHosts({ manifest: manifest([]), audit: vi.fn(async () => {}), }); await expect(hosts.status.get(4)).rejects.toBeInstanceOf( PluginCapabilityError, ); expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow( PluginCapabilityError, ); expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow( PluginCapabilityError, ); await expect(hosts.status.check(4)).rejects.toBeInstanceOf( PluginCapabilityError, ); }); it("passes login reports on to core", async () => { h.statusReports = []; h.granted = new Set(["hosts:read"]); const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); hosts.status.reportLogin(4, { ok: true }); await settle(); expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]); }); it("removes a registered port when the plugin is disposed", async () => { h.statusPorts = []; h.statusPortsRemoved = []; h.granted = new Set(["hosts:read"]); const { DisposableBag } = await import("../../plugins/disposables.js"); const bag = new DisposableBag("fixture"); const hosts = createPluginHosts({ manifest: manifest(["hosts:read"]), bag, audit: vi.fn(async () => {}), }); hosts.status.registerPort("rdp", () => 3389); expect(h.statusPorts).toEqual(["rdp"]); await bag.disposeAll(); expect(h.statusPortsRemoved).toEqual(["rdp"]); }); });