getWorkspaceStatus returns persisted { id, status } only (runner-driven
transitions arrive in story 07); listWorkspaces returns the
credential-free workspace array with a strict void input (optional on
the tRPC procedure so clients can call without args). Repository gains
listWorkspaces on interface, mock, and Payload impl; the contract suite
covers listing incl. the never-returns-credentials guarantee.
Controller span+capture wrapping is extracted into a shared
bindWorkspacesController helper used by both binders, trimming the
bind-production/bind-dev-seed clone family fallow was flagging.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { ZodError } from "zod";
|
|
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
|
import { connectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case";
|
|
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
|
|
|
|
const INPUT = {
|
|
name: "Acme Web",
|
|
gitUrl: "https://github.com/acme/web.git",
|
|
pat: "ghp_super-secret-token",
|
|
};
|
|
|
|
describe("connectWorkspaceUseCase", () => {
|
|
it("creates a workspace with initial status 'created'", async () => {
|
|
const repo = new MockWorkspaceRepository(new Map());
|
|
const useCase = connectWorkspaceUseCase(repo);
|
|
|
|
const result = await useCase(INPUT);
|
|
|
|
expect(result.id).not.toBe("");
|
|
expect(result.name).toBe("Acme Web");
|
|
expect(result.gitUrl).toBe("https://github.com/acme/web.git");
|
|
expect(result.status).toBe("created");
|
|
});
|
|
|
|
it("persists the workspace — it is readable back through the repository", async () => {
|
|
const repo = new MockWorkspaceRepository(new Map());
|
|
const useCase = connectWorkspaceUseCase(repo);
|
|
|
|
const created = await useCase(INPUT);
|
|
const found = await repo.getWorkspace(created.id);
|
|
|
|
expect(found).toEqual(created);
|
|
});
|
|
|
|
it("never includes the credential in the output", async () => {
|
|
const repo = new MockWorkspaceRepository(new Map());
|
|
const useCase = connectWorkspaceUseCase(repo);
|
|
|
|
const result = await useCase(INPUT);
|
|
|
|
expect(result).not.toHaveProperty("pat");
|
|
expect(result).not.toHaveProperty("credential");
|
|
expect(JSON.stringify(result)).not.toContain(INPUT.pat);
|
|
});
|
|
|
|
it("strips a credential leaked by a misbehaving repository", async () => {
|
|
const leakyRepo = {
|
|
getWorkspace: async () => null,
|
|
getDecryptedCredential: async () => null,
|
|
listWorkspaces: async () => [],
|
|
createWorkspace: async () => ({
|
|
id: "ws-1",
|
|
name: "Acme Web",
|
|
gitUrl: "https://github.com/acme/web.git",
|
|
status: "created" as const,
|
|
credential: "ghp_super-secret-token",
|
|
}),
|
|
};
|
|
const useCase = connectWorkspaceUseCase(leakyRepo);
|
|
|
|
const result = await useCase(INPUT);
|
|
|
|
expect(result).not.toHaveProperty("credential");
|
|
});
|
|
|
|
it("records the workspace-connected audit event", async () => {
|
|
const repo = new MockWorkspaceRepository(new Map());
|
|
const auditLog = new RecordingAuditLog();
|
|
const useCase = connectWorkspaceUseCase(repo, auditLog);
|
|
|
|
const created = await useCase(INPUT);
|
|
|
|
expect(auditLog.recorded).toHaveLength(1);
|
|
const entry = auditLog.recorded[0]!;
|
|
expect(entry.action).toBe("CREATE");
|
|
expect(entry.reason).toBe("workspace-connected");
|
|
expect(entry.resource).toEqual({ type: "workspaces", id: created.id });
|
|
expect(entry.outcome).toBe("success");
|
|
expect(entry.containsPii).toBe(false);
|
|
expect(entry.scope.feature).toBe("workspaces");
|
|
});
|
|
|
|
describe("audit scope environment", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("falls back to 'development' when NODE_ENV is unset", async () => {
|
|
vi.stubEnv("NODE_ENV", undefined);
|
|
const repo = new MockWorkspaceRepository(new Map());
|
|
const auditLog = new RecordingAuditLog();
|
|
const useCase = connectWorkspaceUseCase(repo, auditLog);
|
|
|
|
await useCase(INPUT);
|
|
|
|
expect(auditLog.recorded[0]!.scope.environment).toBe("development");
|
|
});
|
|
});
|
|
|
|
it("does not audit when the repository write fails", async () => {
|
|
const failingRepo = {
|
|
getWorkspace: async () => null,
|
|
getDecryptedCredential: async () => null,
|
|
listWorkspaces: async () => [],
|
|
createWorkspace: async () => {
|
|
throw new Error("boom");
|
|
},
|
|
};
|
|
const auditLog = new RecordingAuditLog();
|
|
const useCase = connectWorkspaceUseCase(failingRepo, auditLog);
|
|
|
|
await expect(useCase(INPUT)).rejects.toThrow("boom");
|
|
expect(auditLog.recorded).toHaveLength(0);
|
|
});
|
|
|
|
it("works without an audit log (optional dependency)", async () => {
|
|
const repo = new MockWorkspaceRepository(new Map());
|
|
const useCase = connectWorkspaceUseCase(repo, undefined);
|
|
|
|
await expect(useCase(INPUT)).resolves.toMatchObject({ status: "created" });
|
|
});
|
|
|
|
it("throws ZodError when the repository returns malformed data", async () => {
|
|
const malformedRepo = {
|
|
getWorkspace: async () => null,
|
|
getDecryptedCredential: async () => null,
|
|
listWorkspaces: async () => [],
|
|
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
|
};
|
|
const useCase = connectWorkspaceUseCase(malformedRepo);
|
|
|
|
await expect(useCase(INPUT)).rejects.toBeInstanceOf(ZodError);
|
|
});
|
|
});
|