Files
PaperClipAI/server/src/__tests__/project-git-authorization.test.ts
T
Nicky LeachandPaperclip aab87ce707 Prove workspace delivery before inspecting cleanup candidates (#18493)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Completed tasks leave workspaces that need safe cleanup.
> - Providers without ancestry inspection currently need a recorded
merged PR before cleanup can start.
> - A squash merge changes commit identity, so direct ancestry cannot
prove that delivery.
> - This change proves delivery from the finalized HEAD and captured
destination before storage inspection.
> - Fresh storage checks and a second delivery check still protect
removal.
> - This reuses the existing worker and receipts. It adds no table or
timer.

## Linked Issues or Issue Description

Refs #18410. Related: #13400 addresses workspace identity during
inspection; this change adds destination-aware delivery discovery.

## What Changed

- Resolve direct ancestry or discover an associated merged GitHub PR
without a recorded work product. Require exact HEAD, destination
repository and branch, and a reachable integration commit.
- Use the latest settled finalization receipt as a candidate. Reject
missing, failed, pending, conflicting, or changed evidence.
- Inspect storage only after positive remote proof for providers that
disable ancestry inspection. Recheck HEAD, branch, source identity and
delivery before removal intent.
- Add an optional provider capability for a specific PR recovery hint
and an optional prepared destination receipt. Older providers keep their
existing protocol.
- Wake cleanup when finalization writes settle or gain a workspace
binding. Keep remote-only retries for concrete unresolved delivery. Park
missing or unsupported candidate identity.
- Reuse the source project's explicit GitHub consent for delivery checks
through the existing authorization service. Revalidate the bound
repository and consent before removal; missing or revoked consent
retains storage without switching identities. Public sources remain
anonymous.
- Update the contracts, documentation, access map, and targeted
regressions.

## Verification

- Initial-head GitHub CI passed at
`5acd4c818be6f1764f8319f42891f9956c357c26`, including full build and
typecheck. Standard Greptile is 5/5 with no open threads. The first Apex
review scored 3/5 and found startup latency from remote proof lookups
and removal retry identity changes across provider capability changes.
Both corrections are pushed in `9d1a38df3d`, with the qualified
destination-ref length fix. Red regressions reproduced each issue.
Latest targeted checks passed: cleanup/worker 26, provider integration
41, SDK contract 8. Head `9d1a38df3d` passed standard and Apex reviews
at 5/5 with no open threads, plus all CI including full build/typecheck.
Canary Dry Run initially failed on an npm 404 fetching
`rollup-4.67.3.tgz`; the exact tarball subsequently returned HTTP 200,
and one targeted retry passed without source changes. Aggregate
verification also passed. After #18403 merged, master introduced
conflicts in shared workspace cleanup and metadata handling. Resolved
them in `7700832536`, preserving remote snapshot cleanup and Git
delivery-proof cleanup. Targeted merge verification: 93 tests passed in
the first run (59 opt-in skips), then all 16 remote lifecycle tests
passed with explicit database opt-in; map checks passed. The isolated
database is stopped. The merge head received standard review 5/5. Its CI
found two failures in unchanged test cleanup paths: native runtime
temporary-directory ENOTEMPTY and agent-chat cancellation lock timeout.
Those failures remain recorded and require successful final-head
verification. The subsequent project-consent change has targeted
coverage: authorization36, resolver33 and cleanup19 tests passed,
including revoked consent, anonymous public access and source-binding
races. The positive consent test failed before the fix. Current head
`c772679e05` has green GitHub CI, including full build/typecheck and
both previously failing test jobs, plus standard Greptile 5/5 with no
open threads. Neither cleanup failure required changes to its test path.
Apex also completed at 5/5 on this exact head (check `124651548532`,
summary updated 2026-10-31 20:45 UTC), with no open review threads. The
PR remains draft. Earlier failures remain recorded above.

- Final focused run on the rebased branch: **182 tests passed across six
files**, covering the resolver, delivery cleanup, provider bridge,
finalization notifications, existing workspace service and SDK
contracts. The disposable test database was stopped afterward.
- Targeted resolver tests: 33 passed. These cover ancestry, discovered
squash PRs, wrong HEAD/base/repository, removed integration commits,
bounded responses, cancellation and credential isolation.
- Targeted cleanup integration tests: 17 passed. Provider bridge
integration: 36 passed. SDK contract tests: 7 passed. Finalization
notification tests: 9 passed.
- A negative control that removed the final delivery check failed the
race regression as expected. The check was restored.
- Read-only live GitHub probe: the new resolver discovered PR #18410
from HEAD `3f5ade4cd42b707040e9a82fdaed343e3d736a09`, without PR hints.
It proved integration commit `6484f96c28bfb11a793ce4be6cc5ac15643a5a02`
reachable from observed master
`5bc029bf6c1db6647e96acea323f3723fd1d3eba`. GitHub also advertised the
exact original HEAD at `refs/pull/18410/head`.
- `node scripts/check-maps.mjs` passed. Structural checks do not prove
deployment behavior.
- Local tests are targeted by user request. Full build, typecheck and
test suite run in GitHub CI. A standalone server typecheck could not
complete because local Runner build declarations were absent; no
changed-file diagnostics were reported.
- Initial local execution hit a stalled pnpm wrapper, sandbox socket
denial and stale generated SDK declarations. Direct installed binaries,
the authorized isolated test database and rebuilt SDK resolved those
setup failures. No deployment was performed.

## Risks

- Destructive cleanup still requires fresh storage safety and lifecycle
checks. Remote proof alone never authorizes removal. A persisted removal
intent preserves existing recovery semantics.
- Legacy workspaces without an unambiguous destination or completion
HEAD remain retained. This does not guess the original default branch.
- Private repositories use the explicit standing GitHub consent on the
bound source project, with current membership, grant, repository access
and trust checks. No separate company token is required when that
consent is valid. Missing or revoked consent and unavailable or
rate-limited access retain storage; the worker does not borrow a task or
agent identity.
- A known undelivered commit keeps the existing five-minute retry
because external GitHub changes have no guaranteed ingress to this lane.
This can keep cleanup scheduled. Missing evidence parks instead.
- Providers need the new recovery capability to clean a squash merge
whose original HEAD is reachable only through its PR ref. Providers
without it remain conservative.
- The resolver currently supports GitHub.com. Existing local and
other-provider ancestry paths stay in place.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools and
parallel code agents. The exact runtime model ID and context-window size
were not exposed to this session.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 07:59:17 -08:00

378 lines
30 KiB
TypeScript

import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createDb, authUsers, companyMemberships, connectionGrants, executionWorkspaces, heartbeatRuns, instanceUserRoles, issues, projectAccessMembers, projects, projectWorkspaces, resourceLifecycleEvents, toolConnectionInstalls, toolConnections } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import { ghFetch } from "../services/github-fetch.js";
import { projectRoutes } from "../routes/projects.js";
import { createGitHubWorkspaceDeliveryResolver } from "../services/github-workspace-delivery.js";
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { errorHandler } from "../middleware/index.js";
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const vault = vi.hoisted(() => ({
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
}));
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
vi.mock("../services/github-fetch.js", async importOriginal => ({
...await importOriginal<typeof import("../services/github-fetch.js")>(),
ghFetch: vi.fn(async () => new Response(null, { status: 404 })),
}));
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_PROJECT_GIT_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("explicit project Git authorization", () => {
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
beforeAll(async () => {
if (!external) database = await startEmbeddedPostgresTestDatabase("paperclip-project-git-authorization-");
db = createDb(external ?? database!.connectionString);
await db.insert(authUsers).values(["A", "B"].map(id => ({
id, name: id, email: `${id}@example.invalid`, createdAt: new Date(), updatedAt: new Date(),
})));
}, 30_000);
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); }, 60_000);
async function projectAuthorizationFixture() {
const input = await seed();
const selected = await grant(input, "A");
const [project] = await db.insert(projects).values({ companyId: input.companyId, name: "Authorized project" }).returning();
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: input.companyId, projectId: project.id,
name: "Repository", repoUrl: "https://github.com/fixture/project.git" }).returning();
await db.update(issues).set({ projectId: project.id, projectWorkspaceId: workspace.id }).where(eq(issues.id, input.issueId));
await db.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: selected.connectionId, targetType: "company", targetId: input.companyId });
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
lastAccessRefreshAt: "2020-01-01T00:00:00Z", repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: true }] } } }).where(eq(connectionGrants.id, selected.id));
const service = projectGitAuthorizationService(db);
const request = { companyId: input.companyId, projectId: project.id, workspaceId: workspace.id };
return { input, selected, project, workspace, service, request, scope: { projectId: project.id } };
}
it("uses project consent for private delivery checks and rechecks revocation without a task", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
const head = "a".repeat(40);
const fetch = vi.fn<typeof globalThis.fetch>(async (_url, init) => {
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token-A");
return Response.json({ ref: "refs/heads/main", object: { type: "commit", sha: head } });
});
const resolve = createGitHubWorkspaceDeliveryResolver(db, { fetch });
const input = { companyId: f.input.companyId, repoUrl: f.workspace.repoUrl, headSha: head,
destinationRef: "refs/heads/main", projectSource: { projectId: f.project.id, workspaceId: f.workspace.id } };
expect(await resolve(input)).toMatchObject({ status: "delivered" });
expect(fetch).toHaveBeenCalledTimes(1);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
expect(await resolve(input)).toMatchObject({ status: "unknown" });
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each(["missing consent", "different repository", "different company", "different project"])("retains delivery with %s without exporting credentials", async fault => {
const f = await projectAuthorizationFixture();
if (fault !== "missing consent") await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
vault.resolveUserSecretValue.mockClear();
const fetch = vi.fn<typeof globalThis.fetch>();
const credentialProvider = vi.fn(async () => ({ token: "legacy-token", source: "company_secret" as const, secretName: "GITHUB_TOKEN" }));
const resolve = createGitHubWorkspaceDeliveryResolver(db, { fetch, credentialProvider });
expect(await resolve({ companyId: fault === "different company" ? randomUUID() : f.input.companyId,
repoUrl: fault === "different repository" ? "https://github.com/other/repo" : f.workspace.repoUrl,
headSha: "a".repeat(40), destinationRef: "refs/heads/main",
projectSource: { projectId: fault === "different project" ? randomUUID() : f.project.id, workspaceId: f.workspace.id },
})).toMatchObject({ status: "unknown" });
expect(fetch).not.toHaveBeenCalled();
expect(credentialProvider).not.toHaveBeenCalled();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("checks public project delivery anonymously without acquiring a different credential", async () => {
const f = await projectAuthorizationFixture();
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, f.selected.id));
const head = "a".repeat(40);
const fetch = vi.fn<typeof globalThis.fetch>(async (_url, init) => {
expect(new Headers(init?.headers).has("authorization")).toBe(false);
return Response.json({ ref: "refs/heads/main", object: { type: "commit", sha: head } });
});
const credentialProvider = vi.fn(async () => ({ token: "legacy-token", source: "company_secret" as const, secretName: "GITHUB_TOKEN" }));
vault.resolveUserSecretValue.mockClear();
const resolve = createGitHubWorkspaceDeliveryResolver(db, { fetch, credentialProvider });
expect(await resolve({ companyId: f.input.companyId, repoUrl: f.workspace.repoUrl, headSha: head,
destinationRef: "refs/heads/main", projectSource: { projectId: f.project.id, workspaceId: f.workspace.id },
})).toMatchObject({ status: "delivered" });
expect(credentialProvider).not.toHaveBeenCalled();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses explicit project consent without a task and journals consent changes", async () => {
const f = await projectAuthorizationFixture();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
expect(await f.service.read(f.input.companyId, f.project.id, "B")).toMatchObject({ options: [] });
await expect(f.service.set(f.input.companyId, f.project.id, "B", f.selected.id)).rejects.toThrow("own active");
const saved = await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(saved).toMatchObject({ grantId: f.selected.id, authorizedByUserId: "A" });
expect(JSON.stringify(saved)).not.toContain("test-token");
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
await db.update(issues).set({ responsibleUserId: "B" }).where(eq(issues.id, f.input.issueId));
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
expect((await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.resourceId, f.project.id))).length).toBeGreaterThan(0);
await f.service.set(f.input.companyId, f.project.id, "A", null);
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
});
it("rechecks the consenting user's project access before background credential export", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, f.project.id));
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("private");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("rechecks current Cloud owner elevation for consent on another user's private project", async () => {
const f = await projectAuthorizationFixture();
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: "B" }).where(eq(projects.id, f.project.id));
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-tenant-token");
vi.stubEnv("PAPERCLIP_CLOUD_API_ORIGIN", "https://cloud.example.invalid");
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
vi.stubEnv("PAPERCLIP_STACK_SLUG", "fixture-stack");
const managed = (enabled: boolean) => vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({
v: 1, mode: "cloud", catalogVersion: "test", features: { enableOwnerInstanceAdmin: enabled }, plugins: { autoInstall: [] },
}));
const portfolio = (role = "owner", stackSlug = "fixture-stack") => Response.json({ stacks: [{ stackSlug, role, isCurrent: true }] });
const fetch = vi.fn(async () => portfolio());
vi.stubGlobal("fetch", fetch); managed(true);
try {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "cloud_tenant", userId: "A",
companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db)); app.use(errorHandler);
expect((await request(app).put(`/api/projects/${f.project.id}/git-authorization`).send({ grantId: f.selected.id })).status).toBe(200);
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
const cleanup = { ...f.scope, workspaceId: f.workspace.id, operation: "remove" as const };
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
expect(fetch).toHaveBeenCalledWith(new URL("https://cloud.example.invalid/v1/tenant/portfolio"), expect.objectContaining({
headers: expect.objectContaining({ "x-paperclip-cloud-user-id": "A", "x-paperclip-cloud-stack-id": "fixture-stack" }),
}));
// Stale local roles must never replace current Cloud authority.
await db.insert(instanceUserRoles).values({ userId: "A", role: "instance_admin" });
for (const denied of ["role", "setting", "unavailable", "stack"] as const) {
managed(denied !== "setting");
fetch.mockImplementation(async () => denied === "unavailable" ? new Response(null, { status: 503 })
: portfolio(denied === "role" ? "admin" : "owner", denied === "stack" ? "different-stack" : "fixture-stack"));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, cleanup)).rejects.toThrow("private");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
}
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
fetch.mockImplementation(async () => new Response(null, { status: 503 }));
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
} finally {
await db.delete(instanceUserRoles).where(eq(instanceUserRoles.userId, "A"));
vi.unstubAllGlobals(); vi.unstubAllEnvs();
}
});
it("does not lend background consent to a low-trust project", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.input.companyId, projectIds: [f.project.id],
} } } }).where(eq(projects.id, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("low-trust");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses the connection's current repository access for sources added after project authorization", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
projectId: f.project.id, name: "Added repository", repoUrl: "https://github.com/fixture/added",
metadata: { githubRepositoryUrl: "https://github.com/fixture/added", githubRepositoryPrivate: true },
}).returning();
const request = { ...f.request, workspaceId: workspace.id };
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositoryCount: 2, repositories: [...saved.providerTenant!.github!.repositories!,
{ id: "2", fullName: "fixture/added", installationId: "1", private: true }],
} } }).where(eq(connectionGrants.id, f.selected.id));
expect(await f.service.resolve(request, f.scope)).toBe("test-token-A");
await db.update(connectionGrants).set({ providerTenant: saved.providerTenant }).where(eq(connectionGrants.id, f.selected.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("never requests credentials or requires a connection for a known public repository", async () => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: false } }).where(eq(projectWorkspaces.id, f.workspace.id));
await db.update(connectionGrants).set({ providerTenant: null, status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
vi.mocked(ghFetch).mockClear(); vault.resolveUserSecretValue.mockClear();
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
expect(ghFetch).not.toHaveBeenCalled(); expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["inspect", "remove"] as const)("allows authorized %s after archive while denying provisioning and revoked consent", async operation => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: true } })
.where(eq(projectWorkspaces.id, f.workspace.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
const scope = { ...f.scope, workspaceId: f.workspace.id, operation };
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
vault.resolveUserSecretValue.mockClear();
for (const deniedScope of [f.scope, { ...f.scope, operation }, { ...scope, operation: "prepare" as const },
{ ...scope, workspaceId: randomUUID() }, { ...scope, runId: f.input.runId, agentId: f.input.agentId }]) {
await expect(f.service.resolve(f.request, deniedScope)).rejects.toThrow();
}
await expect(f.service.set(f.input.companyId, f.project.id, "A", f.selected.id)).rejects.toThrow("Active project");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("authorization is unavailable");
await f.service.set(f.input.companyId, f.project.id, "A", null);
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("Authorize this project");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["revoked", "disabled"])("ignores %s private catalog entries when the current repository is public", async stale => {
const f = await projectAuthorizationFixture();
if (stale === "revoked") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
else await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
const current = await grant(f.input, "A");
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, current.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, current.id));
vault.resolveUserSecretValue.mockClear(); vi.mocked(ghFetch).mockClear();
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled(); expect(ghFetch).not.toHaveBeenCalled();
});
it.each([true, false])("uses refreshed catalog visibility after a repository becomes private=%s", async isPrivate => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: !isPrivate } }).where(eq(projectWorkspaces.id, f.workspace.id));
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: isPrivate }] } } }).where(eq(connectionGrants.id, f.selected.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(await f.service.resolve(f.request, f.scope)).toBe(isPrivate ? "test-token-A" : null);
});
it("uses repository access refresh time rather than unrelated grant updates for conflicting active catalogs", async () => {
const f = await projectAuthorizationFixture(), current = await grant(f.input, "A");
for (const [id, privateValue, refreshedAt] of [[f.selected.id, true, "2020-01-01T00:00:00Z"], [current.id, false, "2021-01-01T00:00:00Z"]] as const) {
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
lastAccessRefreshAt: refreshedAt, repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: privateValue }] } } }).where(eq(connectionGrants.id, id));
}
await db.update(connectionGrants).set({ updatedAt: new Date() }).where(eq(connectionGrants.id, f.selected.id));
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
});
it("never lets a legacy catalog's OAuth update outrank an observed repository refresh", async () => {
const f = await projectAuthorizationFixture(), legacy = await grant(f.input, "A");
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, legacy.id));
await db.update(connectionGrants).set({ updatedAt: new Date(), providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, legacy.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
});
it("discovers legacy URL-only public sources anonymously and defers unknown private sources", async () => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/legacy" }).where(eq(projectWorkspaces.id, f.workspace.id));
vi.mocked(ghFetch).mockResolvedValueOnce(Response.json({ full_name: "fixture/legacy", private: false }));
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
const [, options] = vi.mocked(ghFetch).mock.calls.at(-1)!;
expect(new Headers(options?.headers).has("Authorization")).toBe(false);
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("visibility is unavailable");
});
it.each(["grant", "connection", "membership", "repository", "archive"])("rechecks %s and never falls back to another grant", async change => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await grant(f.input, "A");
if (change === "grant") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
if (change === "connection") await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
if (change === "membership") await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.companyId, f.input.companyId));
if (change === "repository") await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/other" }).where(eq(projectWorkspaces.id, f.workspace.id));
if (change === "archive") await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses the live foreground run rather than project consent and denies mismatched scopes", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await grant(f.input, "B");
await switchTo(f.input, "B");
expect(await f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).toBe("test-token-B");
for (const scope of [undefined, { projectId: randomUUID() }, { ...f.scope, workspaceId: randomUUID() }]) {
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
}
await expect(f.service.resolve({ ...f.request, workspaceId: randomUUID() }, f.scope)).rejects.toThrow();
await expect(f.service.resolve({ ...f.request, companyId: randomUUID() }, f.scope)).rejects.toThrow();
});
it.each(["configured", "bound"])("authorizes a %s workspace selected from another project, but not an unselected source", async selection => {
const f = await projectAuthorizationFixture();
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
const [bound] = selection === "bound" ? await db.insert(executionWorkspaces).values({
companyId: f.input.companyId, projectId: f.project.id, projectWorkspaceId: f.workspace.id,
sourceIssueId: f.input.issueId, name: "Selected workspace", mode: "isolated_workspace", strategyType: "git_worktree", status: "idle",
}).returning() : [];
await db.update(issues).set({ projectId: taskProject.id,
projectWorkspaceId: selection === "configured" ? f.workspace.id : null,
executionWorkspaceId: bound?.id ?? null,
}).where(eq(issues.id, f.input.issueId));
const scope = { ...f.scope, workspaceId: f.workspace.id, runId: f.input.runId, agentId: f.input.agentId };
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
const [unselected] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
projectId: f.project.id, name: "Unselected source", repoUrl: f.workspace.repoUrl,
}).returning();
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve({ ...f.request, workspaceId: unselected.id }, { ...scope, workspaceId: unselected.id })).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["", "secret://fixture", "invalid\ntoken"])("rejects invalid transient credential material", async token => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
vault.resolveUserSecretValue.mockResolvedValueOnce({ value: token });
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("credential unavailable");
});
it.each(["task", "source"])("checks access to the %s project before exporting foreground credentials", async privateProject => {
const f = await projectAuthorizationFixture();
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
await db.update(issues).set({ projectId: taskProject.id }).where(eq(issues.id, f.input.issueId));
await db.update(projects).set({ visibility: "private" })
.where(eq(projects.id, privateProject === "task" ? taskProject.id : f.project.id));
vault.resolveUserSecretValue.mockClear();
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
} finally { vi.unstubAllEnvs(); }
});
it("never lends project consent to low-trust foreground preparation", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(issues).set({ sourceTrust: { preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined", sourceIssueId: f.input.issueId } })
.where(eq(issues.id, f.input.issueId));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow("low-trust");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("requires a board project writer and grant owner on the consent route", async () => {
const f = await projectAuthorizationFixture();
function app(userId: string, agent = false) {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = agent ? { type: "agent", agentId: f.input.agentId, companyId: f.input.companyId }
: { type: "board", source: "session", userId, companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db)); app.use(errorHandler); return app;
}
const url = `/api/projects/${f.project.id}/git-authorization`;
expect((await request(app("B")).put(url).send({ grantId: f.selected.id })).status).toBe(403);
expect((await request(app("A", true)).put(url).send({ grantId: f.selected.id })).status).toBe(403);
expect((await request(app("A")).put(url).send({ grantId: f.selected.id })).status).toBe(200);
const read = await request(app("A")).get(url);
expect(read.status).toBe(200); expect(JSON.stringify(read.body)).not.toContain("test-token");
expect((await request(app("A")).put(url).send({ grantId: null })).status).toBe(200);
});
});