import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockProjectService = vi.hoisted(() => ({ list: vi.fn(), getById: vi.fn(), create: vi.fn(), update: vi.fn(), createWorkspace: vi.fn(), listWorkspaces: vi.fn(), updateWorkspace: vi.fn(), removeWorkspace: vi.fn(), remove: vi.fn(), resolveByReference: vi.fn(), })); const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn(), })); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackProjectCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); vi.mock("@paperclipai/shared/telemetry", async () => { const actual = await vi.importActual( "@paperclipai/shared/telemetry", ); return { ...actual, trackProjectCreated: mockTrackProjectCreated, }; }); vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, })); vi.mock("../services/index.js", () => ({ logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, workspaceOperationService: () => mockWorkspaceOperationService, })); vi.mock("../services/workspace-runtime.js", () => ({ startRuntimeServicesForWorkspaceControl: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), })); async function createApp() { const { projectRoutes } = await import("../routes/projects.js"); const { errorHandler } = await import("../middleware/index.js"); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = { type: "board", userId: "board-user", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }; next(); }); app.use("/api", projectRoutes({} as any)); app.use(errorHandler); return app; } function buildProject(overrides: Record = {}) { return { id: "project-1", companyId: "company-1", urlKey: "project-1", goalId: null, goalIds: [], goals: [], name: "Project", description: null, status: "backlog", leadAgentId: null, targetDate: null, color: null, env: null, pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null, localFolder: null, managedFolder: "/tmp/project", effectiveLocalFolder: "/tmp/project", origin: "managed_checkout", }, workspaces: [], primaryWorkspace: null, archivedAt: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } describe("project env routes", () => { beforeEach(() => { vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.createWorkspace.mockResolvedValue(null); mockProjectService.listWorkspaces.mockResolvedValue([]); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); }); it("normalizes env bindings on create and logs only env keys", async () => { const normalizedEnv = { API_KEY: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111", version: "latest", }, }; mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv); mockProjectService.create.mockResolvedValue(buildProject({ env: normalizedEnv })); const app = await createApp(); const res = await request(app) .post("/api/companies/company-1/projects") .send({ name: "Project", env: normalizedEnv, }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith( "company-1", normalizedEnv, expect.objectContaining({ fieldPath: "env" }), ); expect(mockProjectService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ env: normalizedEnv }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ details: expect.objectContaining({ envKeys: ["API_KEY"], }), }), ); }); it("normalizes env bindings on update and avoids logging raw values", async () => { const normalizedEnv = { PLAIN_KEY: { type: "plain", value: "top-secret" }, }; mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv); mockProjectService.getById.mockResolvedValue(buildProject()); mockProjectService.update.mockResolvedValue(buildProject({ env: normalizedEnv })); const app = await createApp(); const res = await request(app) .patch("/api/projects/project-1") .send({ env: normalizedEnv, }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockProjectService.update).toHaveBeenCalledWith( "project-1", expect.objectContaining({ env: normalizedEnv }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ details: { changedKeys: ["env"], envKeys: ["PLAIN_KEY"], }, }), ); }); });