import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../lib/config.js", () => ({ getConfig: vi.fn(() => ({ apiUrl: "https://api.skills-hub.ai", accessToken: "tok", })), getAuthHeader: vi.fn(() => ({ Authorization: "Bearer tok" })), saveConfig: vi.fn(), ensureAuth: vi.fn(), })); vi.mock("../lib/api-client.js", () => ({ apiRequest: vi.fn(), apiRequestText: vi.fn(), })); vi.mock("node:fs", () => ({ writeFileSync: vi.fn(), })); import { writeFileSync } from "node:fs"; import { orgAuditCommand, orgAuditExportCommand } from "./org-audit.js"; import { apiRequest, apiRequestText } from "../lib/api-client.js"; import { ensureAuth } from "../lib/config.js"; const mockApiRequest = vi.mocked(apiRequest); const mockApiRequestText = vi.mocked(apiRequestText); const mockWriteFileSync = vi.mocked(writeFileSync); const mockEnsureAuth = vi.mocked(ensureAuth); const userEvent = { id: "evt-1", actorUserId: "user-1", actorUsername: "alice", action: "org.policy.mode_changed", resourceType: "org", resourceId: "org-1", metadata: { from: "OPEN", to: "ALLOWLIST" }, ip: "10.0.0.1", createdAt: "2026-07-01T12:00:00Z", }; const tokenEvent = { id: "evt-2", actorUserId: null, actorUsername: null, action: "org.token.created", resourceType: "api_key", resourceId: "tok-1", metadata: { actorTokenName: "ci-bot" }, ip: null, createdAt: "2026-07-02T12:00:00Z", }; beforeEach(() => { vi.clearAllMocks(); mockApiRequest.mockResolvedValue({ events: [userEvent], nextCursor: null, }); }); describe("org audit", () => { it("requires authentication", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "acme"], { from: "node", }); expect(mockEnsureAuth).toHaveBeenCalled(); log.mockRestore(); }); it("fetches the audit trail with the default limit and renders events", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "acme"], { from: "node", }); expect(mockApiRequest).toHaveBeenCalledWith( "/api/v1/orgs/acme/audit?limit=50", ); const output = log.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("org.policy.mode_changed"); expect(output).toContain("alice"); expect(output).toContain("org: org-1"); log.mockRestore(); }); it("passes the --action filter as a query param", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync( ["node", "audit", "acme", "--action", "org.policy."], { from: "node" }, ); expect(mockApiRequest).toHaveBeenCalledWith( "/api/v1/orgs/acme/audit?action=org.policy.&limit=50", ); log.mockRestore(); }); it("passes a custom --limit", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync( ["node", "audit", "acme", "--limit", "10"], { from: "node" }, ); expect(mockApiRequest).toHaveBeenCalledWith( "/api/v1/orgs/acme/audit?limit=10", ); log.mockRestore(); }); it("prints raw JSON with --json", async () => { mockApiRequest.mockResolvedValue({ events: [userEvent, tokenEvent], nextCursor: "cur-2", }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "acme", "--json"], { from: "node", }); expect(log).toHaveBeenCalledTimes(1); const parsed = JSON.parse(log.mock.calls[0][0] as string) as { events: Array<{ id: string }>; nextCursor: string | null; }; expect(parsed.events).toHaveLength(2); expect(parsed.events[0].id).toBe("evt-1"); expect(parsed.events[1].id).toBe("evt-2"); expect(parsed.nextCursor).toBe("cur-2"); log.mockRestore(); }); it("renders token actors from metadata.actorTokenName", async () => { mockApiRequest.mockResolvedValue({ events: [tokenEvent], nextCursor: null, }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "acme"], { from: "node", }); const output = log.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("token:ci-bot"); log.mockRestore(); }); it("shows a message when there are no events", async () => { mockApiRequest.mockResolvedValue({ events: [], nextCursor: null }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "acme"], { from: "node", }); const output = log.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("No audit events"); log.mockRestore(); }); it("hints at the export command when more pages exist", async () => { mockApiRequest.mockResolvedValue({ events: [userEvent], nextCursor: "cur-2", }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "acme"], { from: "node", }); const output = log.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("audit-export acme"); log.mockRestore(); }); it("URL-encodes the org slug", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditCommand.parseAsync(["node", "audit", "my org"], { from: "node", }); expect(mockApiRequest).toHaveBeenCalledWith( "/api/v1/orgs/my%20org/audit?limit=50", ); log.mockRestore(); }); it("handles API errors with exit 1", async () => { mockApiRequest.mockRejectedValue(new Error("Forbidden")); const errLog = vi.spyOn(console, "error").mockImplementation(() => {}); const exit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); }); await expect( orgAuditCommand.parseAsync(["node", "audit", "acme"], { from: "node" }), ).rejects.toThrow("exit"); expect(exit).toHaveBeenCalledWith(1); exit.mockRestore(); errLog.mockRestore(); }); }); describe("org audit-export", () => { it("requires authentication", async () => { mockApiRequestText.mockResolvedValue("createdAt,action\n"); const out = vi.spyOn(process.stdout, "write").mockImplementation(() => true); await orgAuditExportCommand.parseAsync( ["node", "audit-export", "acme"], { from: "node" }, ); expect(mockEnsureAuth).toHaveBeenCalled(); out.mockRestore(); }); it("GETs the CSV endpoint and writes it to stdout by default", async () => { mockApiRequestText.mockResolvedValue("createdAt,action\n2026,org.token.created\n"); const out = vi.spyOn(process.stdout, "write").mockImplementation(() => true); await orgAuditExportCommand.parseAsync( ["node", "audit-export", "acme"], { from: "node" }, ); expect(mockApiRequestText).toHaveBeenCalledWith( "/api/v1/orgs/acme/audit/export.csv", ); expect(out).toHaveBeenCalledWith( "createdAt,action\n2026,org.token.created\n", ); out.mockRestore(); }); it("writes to a file with --out", async () => { mockApiRequestText.mockResolvedValue("createdAt,action\n"); const log = vi.spyOn(console, "log").mockImplementation(() => {}); await orgAuditExportCommand.parseAsync( ["node", "audit-export", "acme", "--out", "audit.csv"], { from: "node" }, ); expect(mockWriteFileSync).toHaveBeenCalledWith( "audit.csv", "createdAt,action\n", ); log.mockRestore(); }); it("URL-encodes the org slug", async () => { mockApiRequestText.mockResolvedValue(""); const out = vi.spyOn(process.stdout, "write").mockImplementation(() => true); await orgAuditExportCommand.parseAsync( ["node", "audit-export", "my org"], { from: "node" }, ); expect(mockApiRequestText).toHaveBeenCalledWith( "/api/v1/orgs/my%20org/audit/export.csv", ); out.mockRestore(); }); it("handles API errors with exit 1", async () => { mockApiRequestText.mockRejectedValue(new Error("Forbidden")); const errLog = vi.spyOn(console, "error").mockImplementation(() => {}); const exit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); }); await expect( orgAuditExportCommand.parseAsync(["node", "audit-export", "acme"], { from: "node", }), ).rejects.toThrow("exit"); expect(exit).toHaveBeenCalledWith(1); exit.mockRestore(); errLog.mockRestore(); }); });