mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Stabilize tests and local maintenance assets (#4423)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - A fast-moving control plane needs stable local tests and repeatable local maintenance tools so contributors can safely split and review work > - Several route suites needed stronger isolation, Codex manual model selection needed a faster-mode option, and local browser cleanup missed Playwright's headless shell binary > - Storybook static output also needed to be preserved as a generated review artifact from the working branch > - This pull request groups the test/local-dev maintenance pieces so they can be reviewed separately from product runtime changes > - The benefit is more predictable contributor verification and cleaner local maintenance without mixing these changes into feature PRs ## What Changed - Added stable Vitest runner support and serialized route/authz test isolation. - Fixed workspace runtime authz route mocks and stabilized Claude/company-import related assertions. - Allowed Codex fast mode for manually selected models. - Broadened the agent browser cleanup script to detect `chrome-headless-shell` as well as Chrome for Testing. - Preserved generated Storybook static output from the source branch. ## Verification - `pnpm exec vitest run src/__tests__/workspace-runtime-routes-authz.test.ts src/__tests__/claude-local-execute.test.ts --config vitest.config.ts` from `server/` passed: 2 files, 19 tests. - `pnpm exec vitest run src/server/codex-args.test.ts --config vitest.config.ts` from `packages/adapters/codex-local/` passed: 1 file, 3 tests. - `bash -n scripts/kill-agent-browsers.sh && scripts/kill-agent-browsers.sh --dry` passed; dry-run detected `chrome-headless-shell` processes without killing them. - `test -f ui/storybook-static/index.html && test -f ui/storybook-static/assets/forms-editors.stories-Dry7qwx2.js` passed. - `git diff --check public-gh/master..pap-2228-test-local-maintenance -- . ':(exclude)ui/storybook-static'` passed. - `pnpm exec vitest run cli/src/__tests__/company-import-export-e2e.test.ts --config cli/vitest.config.ts` did not complete in the isolated split worktree because `paperclipai run` exited during build prep with `TS2688: Cannot find type definition file for 'react'`; this appears to be caused by the worktree dependency symlink setup, not the code under test. - Confirmed this PR does not include `pnpm-lock.yaml`. ## Risks - Medium risk: the stable Vitest runner changes how route/authz tests are scheduled. - Generated `ui/storybook-static` files are large and contain minified third-party output; `git diff --check` reports whitespace inside those generated assets, so reviewers may choose to drop or regenerate that artifact before merge. - No database migrations. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with shell, git, Paperclip API, and GitHub CLI tool use in the local Paperclip workspace. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Note: screenshot checklist item is not applicable to source UI behavior; the included Storybook static output is generated artifact preservation from the source branch. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
||||
**/node_modules
|
||||
**/node_modules/
|
||||
dist/
|
||||
ui/storybook-static/
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
drizzle/meta/
|
||||
|
||||
@@ -398,10 +398,11 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/issues`,
|
||||
);
|
||||
const importedMatchingIssues = importedIssues.filter((issue) => issue.title === sourceIssue.title);
|
||||
|
||||
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
|
||||
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
|
||||
expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);
|
||||
expect(importedMatchingIssues).toHaveLength(1);
|
||||
|
||||
const previewExisting = await runCliJson<{
|
||||
errors: string[];
|
||||
@@ -471,11 +472,13 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/issues`,
|
||||
);
|
||||
const twiceImportedMatchingIssues = twiceImportedIssues.filter((issue) => issue.title === sourceIssue.title);
|
||||
|
||||
expect(twiceImportedAgents).toHaveLength(2);
|
||||
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
||||
expect(twiceImportedProjects).toHaveLength(2);
|
||||
expect(twiceImportedIssues).toHaveLength(2);
|
||||
expect(twiceImportedMatchingIssues).toHaveLength(2);
|
||||
expect(new Set(twiceImportedMatchingIssues.map((issue) => issue.identifier)).size).toBe(2);
|
||||
|
||||
const zipPath = path.join(tempRoot, "exported-company.zip");
|
||||
const portableFiles: Record<string, string> = {};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||
"test": "pnpm run test:run",
|
||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:run": "pnpm run preflight:workspace-links && vitest run",
|
||||
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
|
||||
|
||||
@@ -4,7 +4,23 @@ export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
|
||||
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
|
||||
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const;
|
||||
|
||||
function normalizeModelId(model: string | null | undefined): string {
|
||||
return typeof model === "string" ? model.trim() : "";
|
||||
}
|
||||
|
||||
export function isCodexLocalKnownModel(model: string | null | undefined): boolean {
|
||||
const normalizedModel = normalizeModelId(model);
|
||||
if (!normalizedModel) return false;
|
||||
return models.some((entry) => entry.id === normalizedModel);
|
||||
}
|
||||
|
||||
export function isCodexLocalManualModel(model: string | null | undefined): boolean {
|
||||
const normalizedModel = normalizeModelId(model);
|
||||
return Boolean(normalizedModel) && !isCodexLocalKnownModel(normalizedModel);
|
||||
}
|
||||
|
||||
export function isCodexLocalFastModeSupported(model: string | null | undefined): boolean {
|
||||
if (isCodexLocalManualModel(model)) return true;
|
||||
const normalizedModel = typeof model === "string" ? model.trim() : "";
|
||||
return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.includes(
|
||||
normalizedModel as (typeof CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS)[number],
|
||||
@@ -35,7 +51,7 @@ Core fields:
|
||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=...
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- search (boolean, optional): run codex with --search
|
||||
- fastMode (boolean, optional): enable Codex Fast mode; currently supported on GPT-5.4 only and consumes credits faster
|
||||
- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-5.4 and passed through for manual model IDs
|
||||
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
@@ -54,6 +70,6 @@ Notes:
|
||||
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances/<id>/companies/<companyId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
|
||||
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
- Fast mode is currently supported on GPT-5.4 only. When enabled, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
|
||||
- Fast mode is supported on GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -26,6 +26,28 @@ describe("buildCodexExecArgs", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("enables Codex fast mode overrides for manual models", () => {
|
||||
const result = buildCodexExecArgs({
|
||||
model: "gpt-5.5",
|
||||
fastMode: true,
|
||||
});
|
||||
|
||||
expect(result.fastModeRequested).toBe(true);
|
||||
expect(result.fastModeApplied).toBe(true);
|
||||
expect(result.fastModeIgnoredReason).toBeNull();
|
||||
expect(result.args).toEqual([
|
||||
"exec",
|
||||
"--json",
|
||||
"--model",
|
||||
"gpt-5.5",
|
||||
"-c",
|
||||
'service_tier="fast"',
|
||||
"-c",
|
||||
"features.fast_mode=true",
|
||||
"-",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores fast mode for unsupported models", () => {
|
||||
const result = buildCodexExecArgs({
|
||||
model: "gpt-5.3-codex",
|
||||
@@ -34,7 +56,9 @@ describe("buildCodexExecArgs", () => {
|
||||
|
||||
expect(result.fastModeRequested).toBe(true);
|
||||
expect(result.fastModeApplied).toBe(false);
|
||||
expect(result.fastModeIgnoredReason).toContain("currently only supported on gpt-5.4");
|
||||
expect(result.fastModeIgnoredReason).toContain(
|
||||
"currently only supported on gpt-5.4 or manually configured model IDs",
|
||||
);
|
||||
expect(result.args).toEqual([
|
||||
"exec",
|
||||
"--json",
|
||||
|
||||
@@ -25,7 +25,7 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function formatFastModeSupportedModels(): string {
|
||||
return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", ");
|
||||
return `${CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", ")} or manually configured model IDs`;
|
||||
}
|
||||
|
||||
export function buildCodexExecArgs(
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function testEnvironment(
|
||||
code: "codex_fast_mode_unsupported_model",
|
||||
level: "warn",
|
||||
message: execArgs.fastModeIgnoredReason,
|
||||
hint: "Switch the agent model to GPT-5.4 to enable Codex Fast mode.",
|
||||
hint: "Switch the agent model to GPT-5.4 or enter a manual model ID to enable Codex Fast mode.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kill all "Google Chrome for Testing" processes (agent headless browsers).
|
||||
# Kill all agent headless browser processes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/kill-agent-browsers.sh # kill all
|
||||
@@ -22,14 +22,14 @@ while IFS= read -r line; do
|
||||
pid=$(echo "$line" | awk '{print $2}')
|
||||
pids+=("$pid")
|
||||
lines+=("$line")
|
||||
done < <(ps aux | grep 'Google Chrome for Testing' | grep -v grep || true)
|
||||
done < <(ps aux | grep -E 'Google Chrome for Testing|chrome-headless-shell' | grep -v grep || true)
|
||||
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
echo "No Google Chrome for Testing processes found."
|
||||
echo "No agent headless browser processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found ${#pids[@]} Google Chrome for Testing process(es):"
|
||||
echo "Found ${#pids[@]} agent headless browser process(es):"
|
||||
echo ""
|
||||
|
||||
for i in "${!pids[@]}"; do
|
||||
|
||||
126
scripts/run-vitest-stable.mjs
Normal file
126
scripts/run-vitest-stable.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const serverTestsDir = path.join(repoRoot, "server", "src", "__tests__");
|
||||
const nonServerProjects = [
|
||||
"@paperclipai/shared",
|
||||
"@paperclipai/db",
|
||||
"@paperclipai/adapter-utils",
|
||||
"@paperclipai/adapter-codex-local",
|
||||
"@paperclipai/adapter-opencode-local",
|
||||
"@paperclipai/ui",
|
||||
"paperclipai",
|
||||
];
|
||||
const routeTestPattern = /[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/;
|
||||
const additionalSerializedServerTests = new Set([
|
||||
"server/src/__tests__/approval-routes-idempotency.test.ts",
|
||||
"server/src/__tests__/assets.test.ts",
|
||||
"server/src/__tests__/authz-company-access.test.ts",
|
||||
"server/src/__tests__/companies-route-path-guard.test.ts",
|
||||
"server/src/__tests__/company-portability.test.ts",
|
||||
"server/src/__tests__/costs-service.test.ts",
|
||||
"server/src/__tests__/express5-auth-wildcard.test.ts",
|
||||
"server/src/__tests__/health-dev-server-token.test.ts",
|
||||
"server/src/__tests__/health.test.ts",
|
||||
"server/src/__tests__/heartbeat-dependency-scheduling.test.ts",
|
||||
"server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts",
|
||||
"server/src/__tests__/heartbeat-process-recovery.test.ts",
|
||||
"server/src/__tests__/invite-accept-existing-member.test.ts",
|
||||
"server/src/__tests__/invite-accept-gateway-defaults.test.ts",
|
||||
"server/src/__tests__/invite-accept-replay.test.ts",
|
||||
"server/src/__tests__/invite-expiry.test.ts",
|
||||
"server/src/__tests__/invite-join-manager.test.ts",
|
||||
"server/src/__tests__/invite-onboarding-text.test.ts",
|
||||
"server/src/__tests__/issues-checkout-wakeup.test.ts",
|
||||
"server/src/__tests__/issues-service.test.ts",
|
||||
"server/src/__tests__/opencode-local-adapter-environment.test.ts",
|
||||
"server/src/__tests__/project-routes-env.test.ts",
|
||||
"server/src/__tests__/redaction.test.ts",
|
||||
"server/src/__tests__/routines-e2e.test.ts",
|
||||
]);
|
||||
let invocationIndex = 0;
|
||||
|
||||
function walk(dir) {
|
||||
const entries = readdirSync(dir);
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(dir, entry);
|
||||
const stats = statSync(absolute);
|
||||
if (stats.isDirectory()) {
|
||||
files.push(...walk(absolute));
|
||||
} else if (stats.isFile()) {
|
||||
files.push(absolute);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function toRepoPath(file) {
|
||||
return path.relative(repoRoot, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isRouteOrAuthzTest(file) {
|
||||
if (routeTestPattern.test(file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return additionalSerializedServerTests.has(file);
|
||||
}
|
||||
|
||||
function runVitest(args, label) {
|
||||
console.log(`\n[test:run] ${label}`);
|
||||
invocationIndex += 1;
|
||||
const testRoot = mkdtempSync(path.join(os.tmpdir(), `paperclip-vitest-${process.pid}-${invocationIndex}-`));
|
||||
const env = {
|
||||
...process.env,
|
||||
PAPERCLIP_HOME: path.join(testRoot, "home"),
|
||||
PAPERCLIP_INSTANCE_ID: `vitest-${process.pid}-${invocationIndex}`,
|
||||
TMPDIR: path.join(testRoot, "tmp"),
|
||||
};
|
||||
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
|
||||
mkdirSync(env.TMPDIR, { recursive: true });
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...args], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`[test:run] Failed to start Vitest: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
const routeTests = walk(serverTestsDir)
|
||||
.filter((file) => isRouteOrAuthzTest(toRepoPath(file)))
|
||||
.map((file) => ({ repoPath: toRepoPath(file) }))
|
||||
.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
||||
|
||||
const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.repoPath]);
|
||||
for (const project of nonServerProjects) {
|
||||
runVitest(["--project", project], `non-server project ${project}`);
|
||||
}
|
||||
|
||||
runVitest(
|
||||
["--project", "@paperclipai/server", ...excludeRouteArgs],
|
||||
`server suites excluding ${routeTests.length} serialized suites`,
|
||||
);
|
||||
|
||||
for (const routeTest of routeTests) {
|
||||
runVitest(
|
||||
[
|
||||
"--project",
|
||||
"@paperclipai/server",
|
||||
routeTest.repoPath,
|
||||
"--pool=forks",
|
||||
"--poolOptions.forks.isolate=true",
|
||||
],
|
||||
routeTest.repoPath,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockActivityService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -33,8 +32,6 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
async function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
@@ -44,44 +41,64 @@ async function createApp(
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
) {
|
||||
vi.resetModules();
|
||||
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
|
||||
import("../middleware/index.js"),
|
||||
import("../routes/activity.js"),
|
||||
import("../middleware/index.js") as Promise<typeof import("../middleware/index.js")>,
|
||||
import("../routes/activity.js") as Promise<typeof import("../routes/activity.js")>,
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
(req as any).actor = {
|
||||
...actor,
|
||||
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", activityRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
server = app.listen(0);
|
||||
return server;
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("activity routes", () => {
|
||||
afterAll(async () => {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server?.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
server = null;
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe.sequential("activity routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
for (const mock of Object.values(mockActivityService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockIssueService)) mock.mockReset();
|
||||
});
|
||||
|
||||
it("limits company activity lists by default", async () => {
|
||||
mockActivityService.list.mockResolvedValue([]);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).get("/api/companies/company-1/activity");
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/companies/company-1/activity"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockActivityService.list).toHaveBeenCalledWith({
|
||||
@@ -97,7 +114,9 @@ describe("activity routes", () => {
|
||||
mockActivityService.list.mockResolvedValue([]);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).get("/api/companies/company-1/activity?limit=5000&entityType=issue");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl).get("/api/companies/company-1/activity?limit=5000&entityType=issue"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockActivityService.list).toHaveBeenCalledWith({
|
||||
@@ -122,7 +141,7 @@ describe("activity routes", () => {
|
||||
]);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).get("/api/issues/PAP-475/runs");
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/PAP-475/runs"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
|
||||
@@ -133,14 +152,14 @@ describe("activity routes", () => {
|
||||
|
||||
it("requires company access before creating activity events", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-2/activity")
|
||||
.send({
|
||||
actorId: "user-1",
|
||||
action: "test.event",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
});
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockActivityService.create).not.toHaveBeenCalled();
|
||||
@@ -153,7 +172,7 @@ describe("activity routes", () => {
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).get("/api/heartbeat-runs/run-2/issues");
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-2/issues"));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
|
||||
@@ -161,7 +180,7 @@ describe("activity routes", () => {
|
||||
|
||||
it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => {
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app).get("/api/heartbeat-runs/missing-run/issues");
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/missing-run/issues"));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
@@ -121,7 +121,13 @@ function createApp(actor: Express.Request["actor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
req.actor = {
|
||||
...actor,
|
||||
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
|
||||
memberships: Array.isArray(actor.memberships)
|
||||
? actor.memberships.map((membership) => ({ ...membership }))
|
||||
: actor.memberships,
|
||||
} as Express.Request["actor"];
|
||||
next();
|
||||
});
|
||||
app.use("/api", adapterRoutes());
|
||||
@@ -129,6 +135,33 @@ function createApp(actor: Express.Request["actor"]) {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boardMember(membershipRole: "admin" | "operator" | "viewer"): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
@@ -162,23 +195,29 @@ const instanceAdmin: Express.Request["actor"] = {
|
||||
function sendMutatingRequest(app: express.Express, name: string) {
|
||||
switch (name) {
|
||||
case "install":
|
||||
return request(app)
|
||||
.post("/api/adapters/install")
|
||||
.send({ packageName: EXTERNAL_PACKAGE_NAME });
|
||||
return requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/adapters/install")
|
||||
.send({ packageName: EXTERNAL_PACKAGE_NAME }),
|
||||
);
|
||||
case "disable":
|
||||
return request(app)
|
||||
.patch(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`)
|
||||
.send({ disabled: true });
|
||||
return requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.patch(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`)
|
||||
.send({ disabled: true }),
|
||||
);
|
||||
case "override":
|
||||
return request(app)
|
||||
.patch("/api/adapters/claude_local/override")
|
||||
.send({ paused: true });
|
||||
return requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.patch("/api/adapters/claude_local/override")
|
||||
.send({ paused: true }),
|
||||
);
|
||||
case "delete":
|
||||
return request(app).delete(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`);
|
||||
return requestApp(app, (baseUrl) => request(baseUrl).delete(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`));
|
||||
case "reload":
|
||||
return request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`);
|
||||
return requestApp(app, (baseUrl) => request(baseUrl).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`));
|
||||
case "reinstall":
|
||||
return request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reinstall`);
|
||||
return requestApp(app, (baseUrl) => request(baseUrl).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reinstall`));
|
||||
default:
|
||||
throw new Error(`Unknown mutating adapter route: ${name}`);
|
||||
}
|
||||
@@ -190,7 +229,13 @@ function seedInstalledExternalAdapter() {
|
||||
registerServerAdapter(createAdapter());
|
||||
}
|
||||
|
||||
describe("adapter management route authorization", () => {
|
||||
function resetInstalledExternalAdapterState() {
|
||||
mocks.externalRecords.clear();
|
||||
unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE);
|
||||
setOverridePaused("claude_local", false);
|
||||
}
|
||||
|
||||
describe.sequential("adapter management route authorization", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("node:child_process");
|
||||
@@ -232,50 +277,61 @@ describe("adapter management route authorization", () => {
|
||||
setOverridePaused("claude_local", false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"install",
|
||||
"disable",
|
||||
"override",
|
||||
"delete",
|
||||
"reload",
|
||||
"reinstall",
|
||||
])("rejects %s for a non-instance-admin board user with company membership", async (routeName) => {
|
||||
seedInstalledExternalAdapter();
|
||||
const app = createApp(boardMember("admin"));
|
||||
it("rejects mutating adapter routes for a non-instance-admin board user with company membership", async () => {
|
||||
for (const routeName of [
|
||||
"install",
|
||||
"disable",
|
||||
"override",
|
||||
"delete",
|
||||
"reload",
|
||||
"reinstall",
|
||||
]) {
|
||||
resetInstalledExternalAdapterState();
|
||||
seedInstalledExternalAdapter();
|
||||
const app = createApp(boardMember("admin"));
|
||||
|
||||
const res = await sendMutatingRequest(app, routeName);
|
||||
const res = await sendMutatingRequest(app, routeName);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["install", 201],
|
||||
["disable", 200],
|
||||
["override", 200],
|
||||
["delete", 200],
|
||||
["reload", 200],
|
||||
["reinstall", 200],
|
||||
] as const)("allows instance admins to reach %s", async (routeName, expectedStatus) => {
|
||||
if (routeName !== "install") {
|
||||
seedInstalledExternalAdapter();
|
||||
it("allows instance admins to reach mutating adapter routes", async () => {
|
||||
for (const [routeName, expectedStatus] of [
|
||||
["install", 201],
|
||||
["disable", 200],
|
||||
["override", 200],
|
||||
["delete", 200],
|
||||
["reload", 200],
|
||||
["reinstall", 200],
|
||||
] as const) {
|
||||
resetInstalledExternalAdapterState();
|
||||
if (routeName !== "install") {
|
||||
seedInstalledExternalAdapter();
|
||||
}
|
||||
const app = createApp(instanceAdmin);
|
||||
|
||||
const res = await sendMutatingRequest(app, routeName);
|
||||
|
||||
expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(expectedStatus);
|
||||
}
|
||||
const app = createApp(instanceAdmin);
|
||||
|
||||
const res = await sendMutatingRequest(app, routeName);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(expectedStatus);
|
||||
});
|
||||
|
||||
it.each(["viewer", "operator"] as const)(
|
||||
"does not let a company %s trigger adapter npm install or reload",
|
||||
async (membershipRole) => {
|
||||
seedInstalledExternalAdapter();
|
||||
const app = createApp(boardMember(membershipRole));
|
||||
const installApp = createApp(boardMember(membershipRole));
|
||||
const reloadApp = createApp(boardMember(membershipRole));
|
||||
|
||||
const install = await request(app)
|
||||
.post("/api/adapters/install")
|
||||
.send({ packageName: EXTERNAL_PACKAGE_NAME });
|
||||
const reload = await request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`);
|
||||
const install = await requestApp(installApp, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/adapters/install")
|
||||
.send({ packageName: EXTERNAL_PACKAGE_NAME }),
|
||||
);
|
||||
const reload = await requestApp(reloadApp, (baseUrl) =>
|
||||
request(baseUrl).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`),
|
||||
);
|
||||
|
||||
expect(install.status, JSON.stringify(install.body)).toBe(403);
|
||||
expect(reload.status, JSON.stringify(reload.body)).toBe(403);
|
||||
|
||||
@@ -148,6 +148,33 @@ async function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unregisterTestAdapter(type: string) {
|
||||
const { unregisterServerAdapter } = await import("../adapters/index.js");
|
||||
unregisterServerAdapter(type);
|
||||
@@ -161,7 +188,7 @@ describe("agent routes adapter validation", () => {
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
@@ -207,12 +234,14 @@ describe("agent routes adapter validation", () => {
|
||||
registerServerAdapter(externalAdapter);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "External Agent",
|
||||
adapterType: "external_test",
|
||||
});
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "External Agent",
|
||||
adapterType: "external_test",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body.adapterType).toBe("external_test");
|
||||
@@ -220,12 +249,14 @@ describe("agent routes adapter validation", () => {
|
||||
|
||||
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Missing Adapter",
|
||||
adapterType: missingAdapterType,
|
||||
});
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Missing Adapter",
|
||||
adapterType: missingAdapterType,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
|
||||
vi.unmock("http");
|
||||
vi.unmock("node:http");
|
||||
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -42,6 +43,9 @@ const baseKey = {
|
||||
revokedAt: null,
|
||||
};
|
||||
|
||||
let currentKeyAgentId = agentId;
|
||||
let currentAccessCanUser = false;
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
@@ -111,6 +115,66 @@ vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../routes/authz.js", async () => {
|
||||
const { forbidden, unauthorized } = await vi.importActual<typeof import("../errors.js")>("../errors.js");
|
||||
function assertAuthenticated(req: Express.Request) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
function assertBoard(req: Express.Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
}
|
||||
}
|
||||
|
||||
function assertCompanyAccess(req: Express.Request, expectedCompanyId: string) {
|
||||
assertAuthenticated(req);
|
||||
if (req.actor.type === "agent" && req.actor.companyId !== expectedCompanyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
|
||||
const allowedCompanies = req.actor.companyIds ?? [];
|
||||
if (!allowedCompanies.includes(expectedCompanyId)) {
|
||||
throw forbidden("User does not have access to this company");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstanceAdmin(req: Express.Request) {
|
||||
assertBoard(req);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
throw forbidden("Instance admin access required");
|
||||
}
|
||||
|
||||
function getActorInfo(req: Express.Request) {
|
||||
assertAuthenticated(req);
|
||||
if (req.actor.type === "agent") {
|
||||
return {
|
||||
actorType: "agent" as const,
|
||||
actorId: req.actor.agentId ?? "unknown-agent",
|
||||
agentId: req.actor.agentId ?? null,
|
||||
runId: req.actor.runId ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
actorType: "user" as const,
|
||||
actorId: req.actor.userId ?? "board",
|
||||
agentId: null,
|
||||
runId: req.actor.runId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
assertAuthenticated,
|
||||
assertBoard,
|
||||
assertCompanyAccess,
|
||||
assertInstanceAdmin,
|
||||
getActorInfo,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
@@ -133,11 +197,30 @@ vi.mock("../services/instance-settings.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
let routeModules:
|
||||
| Promise<[
|
||||
typeof import("../middleware/index.js"),
|
||||
typeof import("../routes/agents.js"),
|
||||
]>
|
||||
| null = null;
|
||||
|
||||
async function loadRouteModules() {
|
||||
routeModules ??= Promise.all([
|
||||
import("../middleware/index.js"),
|
||||
import("../routes/agents.js"),
|
||||
]);
|
||||
return routeModules;
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { agentRoutes }] = await loadRouteModules();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
(req as any).actor = {
|
||||
...actor,
|
||||
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
@@ -145,111 +228,138 @@ function createApp(actor: Record<string, unknown>) {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent cross-tenant route authorization", () => {
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetMockDefaults() {
|
||||
vi.clearAllMocks();
|
||||
for (const mock of Object.values(mockAgentService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockAccessService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockApprovalService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockBudgetService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockIssueApprovalService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockIssueService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockSecretService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockAgentInstructionsService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockCompanySkillService)) mock.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
mockGetTelemetryClient.mockReset();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
currentKeyAgentId = agentId;
|
||||
currentAccessCanUser = false;
|
||||
mockAgentService.getById.mockImplementation(async () => ({ ...baseAgent }));
|
||||
mockAgentService.pause.mockImplementation(async () => ({ ...baseAgent }));
|
||||
mockAgentService.resume.mockImplementation(async () => ({ ...baseAgent }));
|
||||
mockAgentService.terminate.mockImplementation(async () => ({ ...baseAgent }));
|
||||
mockAgentService.remove.mockImplementation(async () => ({ ...baseAgent }));
|
||||
mockAgentService.listKeys.mockImplementation(async () => []);
|
||||
mockAgentService.createApiKey.mockImplementation(async () => ({
|
||||
id: keyId,
|
||||
name: baseKey.name,
|
||||
token: "pcp_test_token",
|
||||
createdAt: baseKey.createdAt,
|
||||
}));
|
||||
mockAgentService.getKeyById.mockImplementation(async () => ({
|
||||
...baseKey,
|
||||
agentId: currentKeyAgentId,
|
||||
}));
|
||||
mockAgentService.revokeKey.mockImplementation(async () => ({
|
||||
...baseKey,
|
||||
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
|
||||
}));
|
||||
mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser);
|
||||
mockAccessService.hasPermission.mockImplementation(async () => false);
|
||||
mockAccessService.getMembership.mockImplementation(async () => null);
|
||||
mockAccessService.listPrincipalGrants.mockImplementation(async () => []);
|
||||
mockAccessService.ensureMembership.mockImplementation(async () => undefined);
|
||||
mockAccessService.setPrincipalPermission.mockImplementation(async () => undefined);
|
||||
mockHeartbeatService.cancelActiveForAgent.mockImplementation(async () => undefined);
|
||||
mockLogActivity.mockImplementation(async () => undefined);
|
||||
}
|
||||
|
||||
describe.sequential("agent cross-tenant route authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
mockAgentService.pause.mockResolvedValue(baseAgent);
|
||||
mockAgentService.resume.mockResolvedValue(baseAgent);
|
||||
mockAgentService.terminate.mockResolvedValue(baseAgent);
|
||||
mockAgentService.remove.mockResolvedValue(baseAgent);
|
||||
mockAgentService.listKeys.mockResolvedValue([]);
|
||||
mockAgentService.createApiKey.mockResolvedValue({
|
||||
id: keyId,
|
||||
name: baseKey.name,
|
||||
token: "pcp_test_token",
|
||||
createdAt: baseKey.createdAt,
|
||||
});
|
||||
mockAgentService.getKeyById.mockResolvedValue(baseKey);
|
||||
mockAgentService.revokeKey.mockResolvedValue({
|
||||
...baseKey,
|
||||
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
|
||||
});
|
||||
mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
resetMockDefaults();
|
||||
});
|
||||
|
||||
it("rejects cross-tenant board pause before mutating the agent", async () => {
|
||||
const app = createApp({
|
||||
it("enforces company boundaries before mutating or reading agent keys", async () => {
|
||||
const crossTenantActor = {
|
||||
type: "board",
|
||||
userId: "mallory",
|
||||
companyIds: [],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
};
|
||||
const deniedCases = [
|
||||
{
|
||||
label: "pause",
|
||||
request: (app: express.Express) =>
|
||||
requestApp(app, (baseUrl) => request(baseUrl).post(`/api/agents/${agentId}/pause`).send({})),
|
||||
untouched: [mockAgentService.pause, mockHeartbeatService.cancelActiveForAgent],
|
||||
},
|
||||
{
|
||||
label: "list keys",
|
||||
request: (app: express.Express) =>
|
||||
requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}/keys`)),
|
||||
untouched: [mockAgentService.listKeys],
|
||||
},
|
||||
{
|
||||
label: "create key",
|
||||
request: (app: express.Express) =>
|
||||
requestApp(app, (baseUrl) => request(baseUrl).post(`/api/agents/${agentId}/keys`).send({ name: "exploit" })),
|
||||
untouched: [mockAgentService.createApiKey],
|
||||
},
|
||||
{
|
||||
label: "revoke key",
|
||||
request: (app: express.Express) =>
|
||||
requestApp(app, (baseUrl) => request(baseUrl).delete(`/api/agents/${agentId}/keys/${keyId}`)),
|
||||
untouched: [mockAgentService.getKeyById, mockAgentService.revokeKey],
|
||||
},
|
||||
];
|
||||
|
||||
const res = await request(app).post(`/api/agents/${agentId}/pause`).send({});
|
||||
for (const deniedCase of deniedCases) {
|
||||
resetMockDefaults();
|
||||
const app = await createApp(crossTenantActor);
|
||||
const res = await deniedCase.request(app);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("User does not have access to this company");
|
||||
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
|
||||
expect(mockAgentService.pause).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(res.status, `${deniedCase.label}: ${JSON.stringify(res.body)}`).toBe(403);
|
||||
expect(res.body.error).toContain("User does not have access to this company");
|
||||
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
|
||||
for (const mock of deniedCase.untouched) {
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
it("rejects cross-tenant board key listing before reading any keys", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "mallory",
|
||||
companyIds: [],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
resetMockDefaults();
|
||||
currentKeyAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
currentAccessCanUser = true;
|
||||
|
||||
const res = await request(app).get(`/api/agents/${agentId}/keys`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("User does not have access to this company");
|
||||
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
|
||||
expect(mockAgentService.listKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-tenant board key creation before minting a token", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "mallory",
|
||||
companyIds: [],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/${agentId}/keys`)
|
||||
.send({ name: "exploit" });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("User does not have access to this company");
|
||||
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
|
||||
expect(mockAgentService.createApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-tenant board key revocation before touching the key", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "mallory",
|
||||
companyIds: [],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("User does not have access to this company");
|
||||
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
|
||||
expect(mockAgentService.getKeyById).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.revokeKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the key to belong to the route agent before revocation", async () => {
|
||||
mockAgentService.getKeyById.mockResolvedValue({
|
||||
...baseKey,
|
||||
agentId: "44444444-4444-4444-8444-444444444444",
|
||||
});
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
|
||||
const app = createApp({
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
@@ -257,7 +367,7 @@ describe("agent cross-tenant route authorization", () => {
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`);
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).delete(`/api/agents/${agentId}/keys/${keyId}`));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Key not found");
|
||||
|
||||
@@ -103,6 +103,33 @@ async function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgent() {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -129,7 +156,7 @@ describe("agent instructions bundle routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent());
|
||||
@@ -194,8 +221,11 @@ describe("agent instructions bundle routes", () => {
|
||||
});
|
||||
|
||||
it("returns bundle metadata", async () => {
|
||||
const res = await request(await createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
@@ -208,13 +238,13 @@ describe("agent instructions bundle routes", () => {
|
||||
});
|
||||
|
||||
it("writes a bundle file and persists compatibility config", async () => {
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
|
||||
.send({
|
||||
path: "AGENTS.md",
|
||||
content: "# Updated Agent\n",
|
||||
clearLegacyPromptTemplate: true,
|
||||
});
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith(
|
||||
@@ -250,14 +280,14 @@ describe("agent instructions bundle routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
model: "claude-sonnet-4",
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
@@ -289,13 +319,13 @@ describe("agent instructions bundle routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
adapterConfig: {
|
||||
command: "codex --profile engineer",
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
@@ -327,14 +357,14 @@ describe("agent instructions bundle routes", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1")
|
||||
.send({
|
||||
replaceAdapterConfig: true,
|
||||
adapterConfig: {
|
||||
command: "codex --profile engineer",
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.adapterConfig).toMatchObject({
|
||||
|
||||
@@ -7,6 +7,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
buildRunOutputSilence: vi.fn(),
|
||||
getRunIssueSummary: vi.fn(),
|
||||
getActiveRunIssueSummaryForAgent: vi.fn(),
|
||||
getRunLogAccess: vi.fn(),
|
||||
@@ -91,6 +92,33 @@ async function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent live run routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -104,7 +132,7 @@ describe("agent live run routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
@@ -132,6 +160,7 @@ describe("agent live run routes", () => {
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
||||
mockHeartbeatService.buildRunOutputSilence.mockResolvedValue(null);
|
||||
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
@@ -160,12 +189,15 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
|
||||
it("returns a compact active run payload for issue polling", async () => {
|
||||
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295");
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
expect(res.body).toEqual({
|
||||
expect(res.body).toMatchObject({
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "on_demand",
|
||||
@@ -207,7 +239,10 @@ describe("agent live run routes", () => {
|
||||
issueId: "issue-1",
|
||||
});
|
||||
|
||||
const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
@@ -222,7 +257,10 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
|
||||
it("uses narrow run log metadata lookups for log polling", async () => {
|
||||
const res = await request(await createApp()).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1");
|
||||
|
||||
@@ -165,6 +165,33 @@ async function createApp(db: Record<string, unknown> = createDb()) {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgent(adapterType: string) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -184,14 +211,27 @@ function makeAgent(adapterType: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent skill routes", () => {
|
||||
describe.sequential("agent skill routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/agents.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
for (const mock of Object.values(mockAgentService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockAccessService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockApprovalService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockIssueApprovalService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockAgentInstructionsService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockCompanySkillService)) mock.mockReset();
|
||||
for (const mock of Object.values(mockSecretService)) mock.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
mockTrackAgentCreated.mockReset();
|
||||
mockGetTelemetryClient.mockReset();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
|
||||
mockAdapter.listSkills.mockReset();
|
||||
mockAdapter.syncSkills.mockReset();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
@@ -276,8 +316,11 @@ describe("agent skill routes", () => {
|
||||
it("skips runtime materialization when listing Claude skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
|
||||
@@ -301,8 +344,11 @@ describe("agent skill routes", () => {
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
});
|
||||
@@ -318,8 +364,11 @@ describe("agent skill routes", () => {
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
});
|
||||
@@ -327,9 +376,9 @@ describe("agent skill routes", () => {
|
||||
it("skips runtime materialization when syncing Claude skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
|
||||
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] });
|
||||
.send({ desiredSkills: ["paperclipai/paperclip/paperclip"] }));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAdapter.syncSkills).toHaveBeenCalled();
|
||||
@@ -338,9 +387,9 @@ describe("agent skill routes", () => {
|
||||
it("canonicalizes desired skill references before syncing", async () => {
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
|
||||
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
|
||||
.send({ desiredSkills: ["paperclip"] });
|
||||
.send({ desiredSkills: ["paperclip"] }));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
@@ -357,7 +406,7 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
it("persists canonical desired skills when creating an agent directly", async () => {
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
@@ -365,7 +414,7 @@ describe("agent skill routes", () => {
|
||||
adapterType: "claude_local",
|
||||
desiredSkills: ["paperclip"],
|
||||
adapterConfig: {},
|
||||
});
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
@@ -388,7 +437,7 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
it("materializes a managed AGENTS.md for directly created local agents", async () => {
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
@@ -397,7 +446,7 @@ describe("agent skill routes", () => {
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
@@ -418,14 +467,14 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
});
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
@@ -445,14 +494,14 @@ describe("agent skill routes", () => {
|
||||
});
|
||||
|
||||
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
|
||||
const res = await request(await createApp())
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
});
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
await vi.waitFor(() => {
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("approval routes idempotent retries", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockApprovalService.list.mockReset();
|
||||
mockApprovalService.getById.mockReset();
|
||||
mockApprovalService.create.mockReset();
|
||||
|
||||
@@ -106,6 +106,33 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected HTTP server to listen on a TCP port");
|
||||
}
|
||||
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -116,7 +143,7 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
@@ -128,10 +155,12 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png"),
|
||||
);
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
@@ -155,10 +184,12 @@ describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
originalFilename: "note.txt",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }),
|
||||
);
|
||||
|
||||
expect([200, 201]).toContain(res.status);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
@@ -174,7 +205,7 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
@@ -186,11 +217,13 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("png"), "logo.png"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.status, JSON.stringify({ body: res.body, text: res.text, createCalls: createAssetMock.mock.calls.length })).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.__calls.putFileInputs[0]).toMatchObject({
|
||||
@@ -212,17 +245,19 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
originalFilename: "logo.svg",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach(
|
||||
"file",
|
||||
Buffer.from(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>",
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach(
|
||||
"file",
|
||||
Buffer.from(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>",
|
||||
),
|
||||
"logo.svg",
|
||||
),
|
||||
"logo.svg",
|
||||
);
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.status, JSON.stringify({ body: res.body, text: res.text, createCalls: createAssetMock.mock.calls.length })).toBe(201);
|
||||
expect(svg.__calls.putFileInputs).toHaveLength(1);
|
||||
const stored = svg.__calls.putFileInputs[0];
|
||||
expect(stored.contentType).toBe("image/svg+xml");
|
||||
@@ -241,11 +276,13 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(150 * 1024, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "within-limit.png");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "within-limit.png"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.status, JSON.stringify({ body: res.body, text: res.text, createCalls: createAssetMock.mock.calls.length })).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects logo files larger than the general attachment limit", async () => {
|
||||
@@ -253,9 +290,11 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "too-large.png");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "too-large.png"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
|
||||
@@ -265,9 +304,11 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
const app = await createApp(createStorageService("text/plain"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not an image"), "note.txt");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not an image"), "note.txt"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Unsupported image type: text/plain");
|
||||
@@ -278,9 +319,11 @@ describe("POST /api/companies/:companyId/logo", () => {
|
||||
const app = await createApp(createStorageService("image/svg+xml"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not actually svg"), "logo.svg");
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not actually svg"), "logo.svg"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("SVG could not be sanitized");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { authRoutes } from "../routes/auth.js";
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
return {
|
||||
@@ -32,16 +34,12 @@ function createUpdateChain(row: unknown) {
|
||||
|
||||
function createDb(row: Record<string, unknown>) {
|
||||
return {
|
||||
select: vi.fn(() => createSelectChain([row])),
|
||||
update: vi.fn(() => createUpdateChain(row)),
|
||||
select: () => createSelectChain([row]),
|
||||
update: () => createUpdateChain(row),
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function createApp(actor: Express.Request["actor"], row: Record<string, unknown>) {
|
||||
const [{ authRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/auth.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
function createApp(actor: Express.Request["actor"], row: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -53,7 +51,7 @@ async function createApp(actor: Express.Request["actor"], row: Record<string, un
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("auth routes", () => {
|
||||
describe.sequential("auth routes", () => {
|
||||
const baseUser = {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
@@ -61,10 +59,6 @@ describe("auth routes", () => {
|
||||
image: "https://example.com/jane.png",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns the persisted user profile in the session payload", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
|
||||
@@ -415,7 +415,7 @@ describe("claude execute", () => {
|
||||
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
try {
|
||||
const first = await execute({
|
||||
@@ -574,7 +574,7 @@ describe("claude execute", () => {
|
||||
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
process.env.HOME = root;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
try {
|
||||
const first = await execute({
|
||||
@@ -711,8 +711,9 @@ describe("claude execute", () => {
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("claude_transient_upstream");
|
||||
expect(result.errorFamily).toBe("transient_upstream");
|
||||
expect(result.retryNotBefore).toBe("2026-04-22T21:00:00.000Z");
|
||||
expect(result.resultJson?.retryNotBefore).toBe("2026-04-22T21:00:00.000Z");
|
||||
const expectedRetryNotBefore = "2026-04-22T21:00:00.000Z";
|
||||
expect(result.retryNotBefore).toBe(expectedRetryNotBefore);
|
||||
expect(result.resultJson?.retryNotBefore).toBe(expectedRetryNotBefore);
|
||||
expect(result.errorMessage ?? "").toContain("extra usage");
|
||||
expect(new Date(String(result.resultJson?.transientRetryNotBefore)).getTime()).toBe(
|
||||
new Date("2026-04-22T21:00:00.000Z").getTime(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
@@ -35,20 +34,6 @@ vi.mock("../services/index.js", () => ({
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
let currentServer: Server | null = null;
|
||||
|
||||
async function closeCurrentServer() {
|
||||
if (!currentServer) return;
|
||||
const server = currentServer;
|
||||
currentServer = null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
@@ -62,16 +47,31 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
let appImportCounter = 0;
|
||||
|
||||
async function createApp(actor: any, db: any = {} as any) {
|
||||
await closeCurrentServer();
|
||||
appImportCounter += 1;
|
||||
const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`;
|
||||
const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`;
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import(routeModulePath) as Promise<typeof import("../routes/access.js")>,
|
||||
import(middlewareModulePath) as Promise<typeof import("../middleware/index.js")>,
|
||||
]);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
req.actor = {
|
||||
...actor,
|
||||
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
|
||||
memberships: Array.isArray(actor.memberships)
|
||||
? actor.memberships.map((membership: unknown) =>
|
||||
typeof membership === "object" && membership !== null
|
||||
? { ...membership }
|
||||
: membership,
|
||||
)
|
||||
: actor.memberships,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
@@ -84,13 +84,10 @@ async function createApp(actor: any, db: any = {} as any) {
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
currentServer = app.listen(0);
|
||||
return currentServer;
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
afterEach(closeCurrentServer);
|
||||
|
||||
describe.sequential("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
@@ -101,7 +98,7 @@ describe("cli auth routes", () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||
it.sequential("creates a CLI auth challenge with approval metadata", async () => {
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
@@ -120,7 +117,7 @@ describe("cli auth routes", () => {
|
||||
requestedAccess: "board",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.status, res.text || JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "challenge-1",
|
||||
token: "pcp_cli_auth_secret",
|
||||
@@ -132,18 +129,18 @@ describe("cli auth routes", () => {
|
||||
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
|
||||
});
|
||||
|
||||
it("rejects anonymous access to generic skill documents", async () => {
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const [indexRes, skillRes] = await Promise.all([
|
||||
request(app).get("/api/skills/index"),
|
||||
request(app).get("/api/skills/paperclip"),
|
||||
]);
|
||||
it.sequential("rejects anonymous access to generic skill documents", async () => {
|
||||
const indexApp = await createApp({ type: "none", source: "none" });
|
||||
const skillApp = await createApp({ type: "none", source: "none" });
|
||||
|
||||
expect(indexRes.status).toBe(401);
|
||||
expect(skillRes.status).toBe(401);
|
||||
const indexRes = await request(indexApp).get("/api/skills/index");
|
||||
const skillRes = await request(skillApp).get("/api/skills/paperclip");
|
||||
|
||||
expect(indexRes.status, JSON.stringify(indexRes.body)).toBe(401);
|
||||
expect(skillRes.status, skillRes.text || JSON.stringify(skillRes.body)).toBe(401);
|
||||
});
|
||||
|
||||
it("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
|
||||
it.sequential("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
@@ -174,7 +171,7 @@ describe("cli auth routes", () => {
|
||||
expect(res.text).toContain("# Paperclip Skill");
|
||||
});
|
||||
|
||||
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
|
||||
it.sequential("marks challenge status as requiring sign-in for anonymous viewers", async () => {
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
||||
id: "challenge-1",
|
||||
status: "pending",
|
||||
@@ -197,7 +194,7 @@ describe("cli auth routes", () => {
|
||||
expect(res.body.canApprove).toBe(false);
|
||||
});
|
||||
|
||||
it("approves a CLI auth challenge for a signed-in board user", async () => {
|
||||
it.sequential("approves a CLI auth challenge for a signed-in board user", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
@@ -242,7 +239,7 @@ describe("cli auth routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("logs approve activity for instance admins without company memberships", async () => {
|
||||
it.sequential("logs approve activity for instance admins without company memberships", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
@@ -275,7 +272,7 @@ describe("cli auth routes", () => {
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("logs revoke activity with resolved audit company ids", async () => {
|
||||
it.sequential("logs revoke activity with resolved audit company ids", async () => {
|
||||
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
|
||||
id: "board-key-3",
|
||||
userId: "admin-2",
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("PATCH /api/companies/:companyId/branding", () => {
|
||||
vi.doUnmock("../routes/companies.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
|
||||
@@ -39,37 +39,45 @@ const mockFeedbackService = vi.hoisted(() => ({
|
||||
saveIssueVote: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
vi.mock("../services/access.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/access.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
}));
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
vi.mock("../services/agents.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/agents.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
vi.mock("../services/budgets.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/budgets.js", () => ({
|
||||
budgetService: () => mockBudgetService,
|
||||
}));
|
||||
vi.mock("../services/companies.js", () => ({
|
||||
companyService: () => mockCompanyService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/companies.js", () => ({
|
||||
companyService: () => mockCompanyService,
|
||||
}));
|
||||
vi.mock("../services/company-portability.js", () => ({
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/company-portability.js", () => ({
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
}));
|
||||
vi.mock("../services/feedback.js", () => ({
|
||||
feedbackService: () => mockFeedbackService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/feedback.js", () => ({
|
||||
feedbackService: () => mockFeedbackService,
|
||||
}));
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
budgetService: () => mockBudgetService,
|
||||
companyPortabilityService: () => mockCompanyPortabilityService,
|
||||
companyService: () => mockCompanyService,
|
||||
feedbackService: () => mockFeedbackService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function registerCompanyRouteMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
@@ -81,10 +89,16 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
let appImportCounter = 0;
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
registerCompanyRouteMocks();
|
||||
appImportCounter += 1;
|
||||
const routeModulePath = `../routes/companies.js?company-portability-routes-${appImportCounter}`;
|
||||
const middlewareModulePath = `../middleware/index.js?company-portability-routes-${appImportCounter}`;
|
||||
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/companies.js")>("../routes/companies.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import(routeModulePath) as Promise<typeof import("../routes/companies.js")>,
|
||||
import(middlewareModulePath) as Promise<typeof import("../middleware/index.js")>,
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -98,6 +112,8 @@ async function createApp(actor: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
const companyId = "11111111-1111-4111-8111-111111111111";
|
||||
const ceoAgentId = "ceo-agent";
|
||||
const engineerAgentId = "engineer-agent";
|
||||
|
||||
const exportRequest = {
|
||||
include: { company: true, agents: true, projects: true },
|
||||
@@ -123,33 +139,36 @@ function createExportResult() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("company portability routes", () => {
|
||||
describe.sequential("company portability routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/access.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/agents.js");
|
||||
vi.doUnmock("../services/budgets.js");
|
||||
vi.doUnmock("../services/companies.js");
|
||||
vi.doUnmock("../services/company-portability.js");
|
||||
vi.doUnmock("../services/feedback.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/companies.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
companyId,
|
||||
role: id === ceoAgentId ? "ceo" : "engineer",
|
||||
}));
|
||||
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
|
||||
mockCompanyPortabilityService.previewExport.mockResolvedValue({
|
||||
rootPath: "paperclip",
|
||||
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
|
||||
files: {},
|
||||
fileInventory: [],
|
||||
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },
|
||||
warnings: [],
|
||||
paperclipExtensionPath: ".paperclip.yaml",
|
||||
});
|
||||
mockCompanyPortabilityService.previewImport.mockResolvedValue({ ok: true });
|
||||
mockCompanyPortabilityService.importBundle.mockResolvedValue({
|
||||
company: { id: companyId, action: "created" },
|
||||
agents: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from CEO-safe export preview routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId,
|
||||
role: "engineer",
|
||||
});
|
||||
it.sequential("rejects non-CEO agents from CEO-safe export preview routes", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: engineerAgentId,
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -164,15 +183,10 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId,
|
||||
role: "engineer",
|
||||
});
|
||||
it.sequential("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: engineerAgentId,
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -187,12 +201,7 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.exportBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows CEO agents to use company-scoped export preview routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId,
|
||||
role: "ceo",
|
||||
});
|
||||
it.sequential("allows CEO agents to use company-scoped export preview routes", async () => {
|
||||
mockCompanyPortabilityService.previewExport.mockResolvedValue({
|
||||
rootPath: "paperclip",
|
||||
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
|
||||
@@ -204,7 +213,7 @@ describe("company portability routes", () => {
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: ceoAgentId,
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -218,16 +227,11 @@ describe("company portability routes", () => {
|
||||
expect(res.body.rootPath).toBe("paperclip");
|
||||
});
|
||||
|
||||
it("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId,
|
||||
role: "ceo",
|
||||
});
|
||||
it.sequential("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => {
|
||||
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: ceoAgentId,
|
||||
companyId,
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -244,7 +248,7 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenNthCalledWith(2, companyId, exportRequest);
|
||||
});
|
||||
|
||||
it("allows board users to export through legacy and CEO-safe bundle routes", async () => {
|
||||
it.sequential("allows board users to export through legacy and CEO-safe bundle routes", async () => {
|
||||
mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult());
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
@@ -263,15 +267,10 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects replace collision strategy on CEO-safe import routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "ceo",
|
||||
});
|
||||
it.sequential("rejects replace collision strategy on CEO-safe import routes", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: ceoAgentId,
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -291,10 +290,10 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps global import preview routes board-only", async () => {
|
||||
it.sequential("keeps global import preview routes board-only", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: engineerAgentId,
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -313,7 +312,7 @@ describe("company portability routes", () => {
|
||||
expect(res.body.error).toContain("Board access required");
|
||||
});
|
||||
|
||||
it("requires instance admin for new-company import preview", async () => {
|
||||
it.sequential("requires instance admin for new-company import preview", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -336,15 +335,10 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects replace collision strategy on CEO-safe import apply routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "ceo",
|
||||
});
|
||||
it.sequential("rejects replace collision strategy on CEO-safe import apply routes", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: ceoAgentId,
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -364,15 +358,10 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from CEO-safe import preview routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
});
|
||||
it.sequential("rejects non-CEO agents from CEO-safe import preview routes", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: engineerAgentId,
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -392,15 +381,10 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-CEO agents from CEO-safe import apply routes", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
role: "engineer",
|
||||
});
|
||||
it.sequential("rejects non-CEO agents from CEO-safe import apply routes", async () => {
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
agentId: engineerAgentId,
|
||||
companyId: "11111111-1111-4111-8111-111111111111",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
@@ -420,7 +404,7 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires instance admin for new-company import apply", async () => {
|
||||
it.sequential("requires instance admin for new-company import apply", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("company skill mutation permissions", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { executionWorkspaceRoutes } from "../routes/execution-workspaces.js";
|
||||
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -15,19 +17,15 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({
|
||||
createRecorder: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
}
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
async function createApp() {
|
||||
const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/execution-workspaces.js")>("../routes/execution-workspaces.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
vi.mock("../services/index.js", () => ({
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
logActivity: mockLogActivity,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -45,15 +43,9 @@ async function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("execution workspace routes", () => {
|
||||
describe.sequential("execution workspace routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/execution-workspaces.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockExecutionWorkspaceService.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspaceService.listSummaries.mockResolvedValue([
|
||||
{
|
||||
@@ -66,7 +58,7 @@ describe("execution workspace routes", () => {
|
||||
});
|
||||
|
||||
it("uses summary mode for lightweight workspace lookups", async () => {
|
||||
const res = await request(await createApp())
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/paperclipai/paperclip/issues/2898
|
||||
@@ -29,33 +29,28 @@ describe("Express 5 /api/auth wildcard route", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("matches a shallow auth sub-path (sign-in/email)", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).post("/api/auth/sign-in/email");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("matches a deep auth sub-path (callback/credentials/sign-in)", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app).get(
|
||||
"/api/auth/callback/credentials/sign-in"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not match unrelated paths outside /api/auth", async () => {
|
||||
// Confirm the route is not over-broad — requests to other API paths
|
||||
// must fall through to 404 and not reach the better-auth handler.
|
||||
it("matches auth sub-paths without matching unrelated API paths", async () => {
|
||||
const { app, getCallCount } = buildApp();
|
||||
const res = await request(app).get("/api/other/endpoint");
|
||||
expect(res.status).toBe(404);
|
||||
expect(getCallCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("invokes the handler for every matched sub-path", async () => {
|
||||
const { app, getCallCount } = buildApp();
|
||||
await request(app).post("/api/auth/sign-out");
|
||||
await request(app).get("/api/auth/session");
|
||||
await expect(request(app).post("/api/auth/sign-in/email")).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
await expect(request(app).get("/api/auth/callback/credentials/sign-in")).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(getCallCount()).toBe(2);
|
||||
|
||||
await expect(request(app).get("/api/other/endpoint")).resolves.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
expect(getCallCount()).toBe(2);
|
||||
|
||||
await expect(request(app).post("/api/auth/sign-out")).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
await expect(request(app).get("/api/auth/session")).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(getCallCount()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("instance settings routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockInstanceSettingsService.getGeneral.mockReset();
|
||||
mockInstanceSettingsService.getExperimental.mockReset();
|
||||
mockInstanceSettingsService.updateGeneral.mockReset();
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("POST /companies/:companyId/invites", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("GET /invites/:token", () => {
|
||||
expect(res.body.companyBrandColor).toBe("#114488");
|
||||
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
|
||||
expect(res.body.inviteType).toBe("company_join");
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("omits companyLogoUrl when the stored logo object is missing", async () => {
|
||||
mockStorage.headObject.mockResolvedValue({ exists: false });
|
||||
@@ -172,7 +172,7 @@ describe("GET /invites/:token", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.companyLogoUrl).toBeNull();
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("returns pending join-request status for an already-accepted invite", async () => {
|
||||
const invite = {
|
||||
@@ -218,7 +218,7 @@ describe("GET /invites/:token", () => {
|
||||
expect(res.body.joinRequestStatus).toBe("pending_approval");
|
||||
expect(res.body.joinRequestType).toBe("human");
|
||||
expect(res.body.companyName).toBe("Acme Robotics");
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => {
|
||||
const invite = {
|
||||
@@ -274,5 +274,5 @@ describe("GET /invites/:token", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.joinRequestStatus).toBe("pending_approval");
|
||||
expect(res.body.joinRequestType).toBe("human");
|
||||
});
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("issue activity event routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAccessService.hasPermission.mockReset();
|
||||
mockAgentService.getById.mockReset();
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("issue attachment routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -125,8 +125,8 @@ function registerServiceMocks() {
|
||||
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -173,7 +173,7 @@ function makeClosedWorkspace() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("closed isolated workspace issue routes", () => {
|
||||
describe.sequential("closed isolated workspace issue routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
@@ -189,7 +189,7 @@ describe("closed isolated workspace issue routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace());
|
||||
});
|
||||
|
||||
@@ -113,8 +113,8 @@ function createApp() {
|
||||
|
||||
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
@@ -159,7 +159,7 @@ function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue comment cancel routes", () => {
|
||||
describe.sequential("issue comment cancel routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
@@ -175,7 +175,7 @@ describe("issue comment cancel routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.getComment.mockResolvedValue(makeComment());
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("issue dependency wakeups in issue routes", () => {
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||
|
||||
@@ -177,7 +177,7 @@ describe("issue document revision routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: issueId,
|
||||
companyId,
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("issue telemetry routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
|
||||
@@ -136,7 +136,7 @@ async function createApp(actor: Record<string, unknown> = {
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue thread interaction routes", () => {
|
||||
describe.sequential("issue thread interaction routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
@@ -144,7 +144,7 @@ describe("issue thread interaction routes", () => {
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(createIssue());
|
||||
mockInteractionService.listForIssue.mockResolvedValue([]);
|
||||
mockInteractionService.create.mockResolvedValue({
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("issue update comment wakeups", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("issue workspace command authorization", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.addComment.mockResolvedValue(null);
|
||||
mockIssueService.create.mockResolvedValue(makeIssue());
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("llm routes", () => {
|
||||
vi.doUnmock("../routes/llms.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockListServerAdapters.mockReturnValue([
|
||||
{ type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" },
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
@@ -36,40 +38,18 @@ const mockStorage = vi.hoisted(() => ({
|
||||
headObject: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/access.js", async () => vi.importActual("../routes/access.js"));
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/access.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/activity-log.js", () => ({
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/agents.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/board-auth.js", () => ({
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
}
|
||||
vi.mock("../storage/index.js", () => ({
|
||||
getStorageService: () => mockStorage,
|
||||
}));
|
||||
|
||||
function createSelectChain(rows: unknown[]) {
|
||||
const query = {
|
||||
@@ -126,11 +106,7 @@ function createDbStub(...selectResponses: unknown[][]) {
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/access.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -150,7 +126,7 @@ async function createApp(actor: Record<string, unknown>, db: Record<string, unkn
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
describe.sequential("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
const companyBranding = {
|
||||
name: "Acme AI",
|
||||
brandColor: "#225577",
|
||||
@@ -165,18 +141,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/access.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/agents.js");
|
||||
vi.doUnmock("../services/board-auth.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../storage/index.js");
|
||||
vi.doUnmock("../routes/access.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
@@ -190,7 +155,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -215,7 +180,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
@@ -243,7 +208,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
|
||||
it("includes companyName in invite summary responses", async () => {
|
||||
const db = createDbStub([companyBranding], [logoAsset]);
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -267,7 +232,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub([companyBranding], [logoAsset]);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
@@ -291,7 +256,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = await createApp(
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
|
||||
@@ -16,25 +16,21 @@ const mockLifecycle = vi.hoisted(() => ({
|
||||
disable: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerRouteMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
vi.mock("../services/plugin-registry.js", () => ({
|
||||
pluginRegistryService: () => mockRegistry,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/plugin-registry.js", () => ({
|
||||
pluginRegistryService: () => mockRegistry,
|
||||
}));
|
||||
vi.mock("../services/plugin-lifecycle.js", () => ({
|
||||
pluginLifecycleManager: () => mockLifecycle,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/plugin-lifecycle.js", () => ({
|
||||
pluginLifecycleManager: () => mockLifecycle,
|
||||
}));
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/live-events.js", () => ({
|
||||
publishGlobalLiveEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
vi.mock("../services/live-events.js", () => ({
|
||||
publishGlobalLiveEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
async function createApp(
|
||||
actor: Record<string, unknown>,
|
||||
@@ -47,8 +43,8 @@ async function createApp(
|
||||
} = {},
|
||||
) {
|
||||
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import("../routes/plugins.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
|
||||
const loader = {
|
||||
@@ -114,21 +110,9 @@ function readyPlugin() {
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin install and upgrade authz", () => {
|
||||
describe.sequential("plugin install and upgrade authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/issues.js");
|
||||
vi.doUnmock("../services/plugin-config-validator.js");
|
||||
vi.doUnmock("../services/plugin-loader.js");
|
||||
vi.doUnmock("../services/plugin-registry.js");
|
||||
vi.doUnmock("../services/plugin-lifecycle.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/live-events.js");
|
||||
vi.doUnmock("../routes/plugins.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects plugin installation for non-admin board users", async () => {
|
||||
@@ -267,21 +251,9 @@ describe("plugin install and upgrade authz", () => {
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe("scoped plugin API routes", () => {
|
||||
describe.sequential("scoped plugin API routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/issues.js");
|
||||
vi.doUnmock("../services/plugin-config-validator.js");
|
||||
vi.doUnmock("../services/plugin-loader.js");
|
||||
vi.doUnmock("../services/plugin-registry.js");
|
||||
vi.doUnmock("../services/plugin-lifecycle.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/live-events.js");
|
||||
vi.doUnmock("../routes/plugins.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("dispatches manifest-declared scoped routes after company access checks", async () => {
|
||||
@@ -345,21 +317,9 @@ describe("scoped plugin API routes", () => {
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe("plugin tool and bridge authz", () => {
|
||||
describe.sequential("plugin tool and bridge authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/issues.js");
|
||||
vi.doUnmock("../services/plugin-config-validator.js");
|
||||
vi.doUnmock("../services/plugin-loader.js");
|
||||
vi.doUnmock("../services/plugin-registry.js");
|
||||
vi.doUnmock("../services/plugin-lifecycle.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/live-events.js");
|
||||
vi.doUnmock("../routes/plugins.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRouteMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects tool execution when the board user cannot access runContext.companyId", async () => {
|
||||
@@ -393,63 +353,67 @@ describe("plugin tool and bridge authz", () => {
|
||||
expect(executeTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"agentId",
|
||||
it("rejects tool execution when any runContext reference is outside the company scope", async () => {
|
||||
const cases: Array<[string, Array<Array<Record<string, unknown>>>]> = [
|
||||
[
|
||||
[{ companyId: companyB }],
|
||||
"agentId",
|
||||
[
|
||||
[{ companyId: companyB }],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"runId company",
|
||||
[
|
||||
[{ companyId: companyA }],
|
||||
[{ companyId: companyB, agentId: agentA }],
|
||||
"runId company",
|
||||
[
|
||||
[{ companyId: companyA }],
|
||||
[{ companyId: companyB, agentId: agentA }],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"runId agent",
|
||||
[
|
||||
[{ companyId: companyA }],
|
||||
[{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }],
|
||||
"runId agent",
|
||||
[
|
||||
[{ companyId: companyA }],
|
||||
[{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
"projectId",
|
||||
[
|
||||
[{ companyId: companyA }],
|
||||
[{ companyId: companyA, agentId: agentA }],
|
||||
[{ companyId: companyB }],
|
||||
"projectId",
|
||||
[
|
||||
[{ companyId: companyA }],
|
||||
[{ companyId: companyA, agentId: agentA }],
|
||||
[{ companyId: companyB }],
|
||||
],
|
||||
],
|
||||
],
|
||||
])("rejects tool execution when runContext.%s is outside the company scope", async (_case, rows) => {
|
||||
const executeTool = vi.fn();
|
||||
const { app } = await createApp(boardActor(), {}, {
|
||||
db: createSelectQueueDb(rows),
|
||||
toolDeps: {
|
||||
toolDispatcher: {
|
||||
listToolsForAgent: vi.fn(),
|
||||
getTool: vi.fn(() => ({ name: "paperclip.example:search" })),
|
||||
executeTool,
|
||||
},
|
||||
},
|
||||
});
|
||||
];
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/plugins/tools/execute")
|
||||
.send({
|
||||
tool: "paperclip.example:search",
|
||||
parameters: {},
|
||||
runContext: {
|
||||
agentId: agentA,
|
||||
runId: runA,
|
||||
companyId: companyA,
|
||||
projectId: projectA,
|
||||
for (const [label, rows] of cases) {
|
||||
const executeTool = vi.fn();
|
||||
const { app } = await createApp(boardActor(), {}, {
|
||||
db: createSelectQueueDb(rows),
|
||||
toolDeps: {
|
||||
toolDispatcher: {
|
||||
listToolsForAgent: vi.fn(),
|
||||
getTool: vi.fn(() => ({ name: "paperclip.example:search" })),
|
||||
executeTool,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(executeTool).not.toHaveBeenCalled();
|
||||
const res = await request(app)
|
||||
.post("/api/plugins/tools/execute")
|
||||
.send({
|
||||
tool: "paperclip.example:search",
|
||||
parameters: {},
|
||||
runContext: {
|
||||
agentId: agentA,
|
||||
runId: runA,
|
||||
companyId: companyA,
|
||||
projectId: projectA,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, label).toBe(403);
|
||||
expect(executeTool).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows tool execution when agent, run, and project all belong to runContext.companyId", async () => {
|
||||
|
||||
@@ -38,30 +38,6 @@ vi.mock("../services/live-events.js", () => ({
|
||||
publishGlobalLiveEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
vi.doMock("../services/plugin-registry.js", () => ({
|
||||
pluginRegistryService: () => mockRegistry,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/plugin-lifecycle.js", () => ({
|
||||
pluginLifecycleManager: () => mockLifecycle,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/issues.js", () => ({
|
||||
issueService: () => mockIssueService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/live-events.js", () => ({
|
||||
publishGlobalLiveEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function manifest(apiRoutes: NonNullable<PaperclipPluginManifestV1["apiRoutes"]>): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.scoped-api-test",
|
||||
@@ -84,8 +60,8 @@ async function createApp(input: {
|
||||
workerResult?: unknown;
|
||||
}) {
|
||||
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import("../routes/plugins.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
|
||||
const workerManager = {
|
||||
@@ -118,7 +94,7 @@ async function createApp(input: {
|
||||
return { app, workerManager };
|
||||
}
|
||||
|
||||
describe("plugin scoped API routes", () => {
|
||||
describe.sequential("plugin scoped API routes", () => {
|
||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const agentId = "33333333-3333-4333-8333-333333333333";
|
||||
@@ -126,17 +102,7 @@ describe("plugin scoped API routes", () => {
|
||||
const issueId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/plugin-registry.js");
|
||||
vi.doUnmock("../services/plugin-lifecycle.js");
|
||||
vi.doUnmock("../services/issues.js");
|
||||
vi.doUnmock("../services/activity-log.js");
|
||||
vi.doUnmock("../services/live-events.js");
|
||||
vi.doUnmock("../routes/plugins.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({
|
||||
id: issueId,
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("project and goal telemetry routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("project env routes", () => {
|
||||
vi.doUnmock("../services/environments.js");
|
||||
vi.doUnmock("../services/secrets.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||
|
||||
@@ -77,7 +77,7 @@ function registerRoutineServiceMock() {
|
||||
}
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
@@ -136,13 +136,13 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerRoutineServiceMock();
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/routines.js")>("../routes/routines.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
import("../routes/routines.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("routine routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockRoutineService.create.mockResolvedValue(routine);
|
||||
mockRoutineService.get.mockResolvedValue(routine);
|
||||
|
||||
47
server/src/__tests__/setup-supertest.ts
Normal file
47
server/src/__tests__/setup-supertest.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { AddressInfo, Server as NetServer } from "node:net";
|
||||
import { Server as TlsServer } from "node:tls";
|
||||
|
||||
type SupertestServer = NetServer & {
|
||||
address(): ReturnType<NetServer["address"]>;
|
||||
listen(port: number): NetServer;
|
||||
};
|
||||
|
||||
type SupertestTestInstance = {
|
||||
_server?: SupertestServer;
|
||||
};
|
||||
|
||||
type SupertestTestConstructor = {
|
||||
prototype: {
|
||||
serverAddress(this: SupertestTestInstance, app: SupertestServer, path: string): string;
|
||||
__paperclipLoopbackPatched?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const SupertestTest = require("supertest/lib/test.js") as SupertestTestConstructor;
|
||||
|
||||
if (!SupertestTest.prototype.__paperclipLoopbackPatched) {
|
||||
SupertestTest.prototype.serverAddress = function serverAddress(app, path) {
|
||||
const addr = app.address();
|
||||
|
||||
if (!addr) {
|
||||
this._server = app.listen(0) as SupertestServer;
|
||||
}
|
||||
|
||||
const listeningAddress = app.address() as AddressInfo | string | null;
|
||||
if (!listeningAddress || typeof listeningAddress === "string") {
|
||||
throw new Error("Expected Supertest server to listen on a TCP port");
|
||||
}
|
||||
|
||||
const host = listeningAddress.address === "::"
|
||||
? "[::1]"
|
||||
: listeningAddress.address === "0.0.0.0"
|
||||
? "127.0.0.1"
|
||||
: listeningAddress.address;
|
||||
const protocol = app instanceof TlsServer ? "https" : "http";
|
||||
return `${protocol}://${host}:${listeningAddress.port}${path}`;
|
||||
};
|
||||
|
||||
SupertestTest.prototype.__paperclipLoopbackPatched = true;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ describe("sidebar preference routes", () => {
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ORDERED_IDS,
|
||||
updatedAt: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
@@ -30,36 +31,41 @@ const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
|
||||
const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
vi.mock("../services/index.js", () => ({
|
||||
environmentService: () => mockEnvironmentService,
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
secretService: () => mockSecretService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||
cleanupExecutionWorkspaceArtifacts: vi.fn(),
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForExecutionWorkspace: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
cleanupExecutionWorkspaceArtifacts: vi.fn(),
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForExecutionWorkspace: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({
|
||||
assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices,
|
||||
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
|
||||
}));
|
||||
}
|
||||
vi.mock("../routes/workspace-runtime-service-authz.js", () => ({
|
||||
assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices,
|
||||
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
|
||||
}));
|
||||
|
||||
let appImportCounter = 0;
|
||||
|
||||
async function createProjectApp(actor: Record<string, unknown>) {
|
||||
const { projectRoutes } = await import("../routes/projects.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
appImportCounter += 1;
|
||||
const routeModulePath = `../routes/projects.js?workspace-runtime-routes-authz-${appImportCounter}`;
|
||||
const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`;
|
||||
const [{ projectRoutes }, { errorHandler }] = await Promise.all([
|
||||
import(routeModulePath) as Promise<typeof import("../routes/projects.js")>,
|
||||
import(middlewareModulePath) as Promise<typeof import("../middleware/index.js")>,
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -72,8 +78,13 @@ async function createProjectApp(actor: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
async function createExecutionWorkspaceApp(actor: Record<string, unknown>) {
|
||||
const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js");
|
||||
const { errorHandler } = await import("../middleware/index.js");
|
||||
appImportCounter += 1;
|
||||
const routeModulePath = `../routes/execution-workspaces.js?workspace-runtime-routes-authz-${appImportCounter}`;
|
||||
const middlewareModulePath = `../middleware/index.js?workspace-runtime-routes-authz-${appImportCounter}`;
|
||||
const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([
|
||||
import(routeModulePath) as Promise<typeof import("../routes/execution-workspaces.js")>,
|
||||
import(middlewareModulePath) as Promise<typeof import("../middleware/index.js")>,
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -145,24 +156,14 @@ function buildExecutionWorkspace(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace runtime service route authorization", () => {
|
||||
describe.sequential("workspace runtime service route authorization", () => {
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const workspaceId = "22222222-2222-4222-8222-222222222222";
|
||||
const executionWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../telemetry.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../services/workspace-runtime.js");
|
||||
vi.doUnmock("../routes/workspace-runtime-service-authz.js");
|
||||
vi.doUnmock("../routes/projects.js");
|
||||
vi.doUnmock("../routes/execution-workspaces.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.resetAllMocks();
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
vi.clearAllMocks();
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.create.mockResolvedValue(buildProject());
|
||||
|
||||
@@ -808,13 +808,15 @@ describe("realizeExecutionWorkspace", () => {
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n");
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const previousCwd = process.cwd();
|
||||
const previousPath = process.env.PATH;
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-"));
|
||||
const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-"));
|
||||
const isolatedBin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bin-"));
|
||||
const instanceId = "worktree-base";
|
||||
const sharedConfigDir = path.join(paperclipHome, "instances", instanceId);
|
||||
const sharedConfigPath = path.join(sharedConfigDir, "config.json");
|
||||
@@ -823,6 +825,10 @@ describe("realizeExecutionWorkspace", () => {
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome;
|
||||
// Keep this server-side fixture on provision-worktree.sh's config writer path;
|
||||
// CLI/database seeding is covered by the CLI worktree tests.
|
||||
await fs.symlink(process.execPath, path.join(isolatedBin, "node"));
|
||||
process.env.PATH = `${isolatedBin}${path.delimiter}/usr/bin${path.delimiter}/bin`;
|
||||
|
||||
await fs.mkdir(sharedConfigDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
@@ -985,6 +991,11 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(reusedEnvContents).toContain('PAPERCLIP_WORKTREE_COLOR="#112233"');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
@@ -1507,7 +1518,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
});
|
||||
expect(provisionOperation?.result.stdout).toContain("[output truncated to last");
|
||||
expect(provisionOperation?.result.stdout?.length ?? 0).toBeLessThan(300000);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
@@ -1648,7 +1659,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`);
|
||||
const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim();
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("reprovisions an existing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
@@ -1732,7 +1743,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("auto-detects the default branch when baseRef is not configured", async () => {
|
||||
// Create a repo with "master" as default branch (not "main")
|
||||
@@ -1784,7 +1795,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created);
|
||||
expect(worktreeOp).toBeDefined();
|
||||
expect(worktreeOp!.metadata!.baseRef).toBe("master");
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => {
|
||||
// Create a repo with "master" as default branch
|
||||
@@ -1835,7 +1846,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created);
|
||||
expect(worktreeOp).toBeDefined();
|
||||
expect(worktreeOp!.metadata!.baseRef).toBe("master");
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("removes a created git worktree and branch during cleanup", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
@@ -1963,7 +1974,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
).resolves.toMatchObject({
|
||||
stdout: expect.stringContaining(workspace.branchName!),
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("records teardown and cleanup operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
@@ -2163,7 +2174,7 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
expect(third).toHaveLength(1);
|
||||
expect(third[0]?.reused).toBe(false);
|
||||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("does not reuse project-scoped shared services across different workspace launch contexts", async () => {
|
||||
const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-"));
|
||||
@@ -2588,7 +2599,7 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
workspaceCwd: workspace.cwd,
|
||||
runtimeServiceId: worker?.id ?? null,
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("buildWorkspaceRuntimeDesiredStatePatch", () => {
|
||||
|
||||
@@ -197,6 +197,10 @@ describe("worktree config repair", () => {
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_CONTEXT;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
@@ -3,5 +3,20 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
isolate: true,
|
||||
maxConcurrency: 1,
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
forks: {
|
||||
isolate: true,
|
||||
maxForks: 1,
|
||||
minForks: 1,
|
||||
},
|
||||
},
|
||||
sequence: {
|
||||
concurrent: false,
|
||||
hooks: "list",
|
||||
},
|
||||
setupFiles: ["./src/__tests__/setup-supertest.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||
import {
|
||||
CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS,
|
||||
isCodexLocalFastModeSupported,
|
||||
isCodexLocalManualModel,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
|
||||
const inputClass =
|
||||
@@ -37,8 +38,14 @@ export function CodexLocalConfigFields({
|
||||
const currentModel = isCreate
|
||||
? String(values!.model ?? "")
|
||||
: eff("adapterConfig", "model", String(config.model ?? ""));
|
||||
const fastModeManualModel = isCodexLocalManualModel(currentModel);
|
||||
const fastModeSupported = isCodexLocalFastModeSupported(currentModel);
|
||||
const supportedModelsLabel = CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", ");
|
||||
const fastModeMessage = fastModeManualModel
|
||||
? "Fast mode will be passed through for this manual model. If Codex rejects it, turn the toggle off."
|
||||
: fastModeSupported
|
||||
? "Fast mode consumes credits/tokens much faster than standard Codex runs."
|
||||
: `Fast mode currently only works on ${supportedModelsLabel} or manual model IDs. Paperclip will ignore this toggle until the model is switched.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -112,9 +119,7 @@ export function CodexLocalConfigFields({
|
||||
/>
|
||||
{fastModeEnabled && (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{fastModeSupported
|
||||
? "Fast mode consumes credits/tokens much faster than standard Codex runs."
|
||||
: `Fast mode currently only works on ${supportedModelsLabel}. Paperclip will ignore this toggle until the model is switched.`}
|
||||
{fastModeMessage}
|
||||
</div>
|
||||
)}
|
||||
<LocalWorkspaceRuntimeFields
|
||||
|
||||
@@ -34,7 +34,7 @@ export const help: Record<string, string> = {
|
||||
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
|
||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||
search: "Enable Codex web search capability during runs.",
|
||||
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is currently supported on GPT-5.4 only.",
|
||||
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is supported on GPT-5.4 and manual Codex model IDs.",
|
||||
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
||||
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
|
||||
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
|
||||
|
||||
Reference in New Issue
Block a user