mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected chats show safe notices when a run starts, waits, finishes, or fails. > - A recurring scan projected these notices even when no eligible work existed. > - Eligibility depends on committed run state, conversation lineage, and current authority. > - Existing database rows can reconstruct that work after restart. > - This PR wakes a scoped worker from those writes and removes lifecycle polling. > - Native progress events keep their separate existing owner until the next migration. ## Linked Issues or Issue Description **What happened?** The chat maintenance timer repeatedly scanned run lifecycle state. Empty and settled instances still queried the database. A run-status event alone cannot replace the scan: prompt receipts, continuation receipts, identity changes, and restored authority can make a run eligible later. **What did you expect?** Committed changes wake projection. Startup reconstructs eligible work. A completed pass stops until another relevant change arrives. Failed work retains recovery without scanning settled history forever. **Steps to reproduce** Configure an automatic chat endpoint. Finish its work, or leave it unused. Observe recurring lifecycle projection queries from the chat maintenance callback. Stacked on #17592. Related foundation: #15625. This PR owns lifecycle milestones only. ## What Changed - Preserve upstream coalesced Cloud membership writes together with locked membership restoration and its committed chat notification. - Signal milestone eligibility from the real Slack account-link transaction. - Register the lifecycle worker with the existing delivery coordinator. Remove lifecycle projection from the periodic chat callback. Keep native progress in a separately named lane and preserve the explicit combined API. - Reconstruct company/issue scopes from the existing selector. Drain bounded batches with company fairness. Retry failures, retain writes through drain/resume, and disarm settled work. - Signal from supported run, presentation, interaction, receipt, lineage, conversation, recovery, and authority writers. Preserve their transaction ownership, including nested savepoints and uncertain settlement. No new table is added. - Preserve provider authorization, authored-final suppression, responsible-user continuity, deduplication, the 24-hour selection window, and raw-output restrictions. Keep provider interpretation in its owner. - Preserve PostgreSQL timestamp precision in lifecycle pagination. Rejected submillisecond candidates must not repeat the same page and starve later notices. - Document producer coverage and alternate-writer dispositions in `doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`. ## Verification Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting master after #17592 merged at `7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates the actual landed parent. The entire 77-path milestone patch is binary-identical to the locally verified preparation across that ancestry reconciliation; both unrelated master paths are preserved. The four earlier producer/test integration conflicts retain scoped milestone and voice notices, current onboarding completion admission, and expanded identity query/read-scope coverage. - **330 distinct selected cases pass on the verified unchanged patch:** 305 across 14 complete suites (all five milestone worker suites, run identity, board claim, Cloud provisioning, auth session routes, Cloud actor authentication, stale-lock recovery, GitHub credentials, project Git authorization, and app admission wiring), plus 25 selected real chat producer cases. The remaining 1,590 chat integration cases were skipped, not passed. - The real producer cases cover saved milestone discovery, GitHub and Slack identity changes, account-link rollback/reconfirmation, ordinary ingress reactivation, post-close action/delivery receipt settlement, all ten authority-restoration paths including Cloud synchronization, and changed native review metadata. - Worker coverage verifies outer commit, rollback, uncertain-write recovery, restart, bounded batching/fairness, timestamp precision, retries, drain/resume, and settled SQL quiet. Authorization and existing publication deduplication remain with their owning services. - Server production typechecking, build, and module-boundary checks pass. The tests above were executed separately. Production typechecking excludes `src/__tests__` and does not replace those runs. Each database owner uses its own newly created and app-migrated PostgreSQL fixture. - [Current-parent acceptance and preserved failure ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28) records commands, counts, producer scope, conflict dispositions, and limits. Earlier combined-stack counts and failures keep their original revision scope; local acceptance does not erase old CI failures. - No new migration, queue table, dependency, workflow, or shared scheduler contract is introduced. Parent migration 0397, bounded consent recovery, voice cleanup/retention repairs, and runner checkpoint restoration remain present. - **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No Ponytail review is present, which is an explicitly authorized nonblocker; no failing review is waived. Older reviews and parent checks are not current passes. Actual failing Ponytail findings must be fixed; an absent/stopped/paused turn without a verdict is an authorized nonblocker. Active unfinished Ponytail uses its original request/start timer, with a 25-minute monitor cutoff respecting the user's 30-minute maximum. - No complete local repository matrix, live provider tenant, external alarm, physical host/database suspension, or fleet savings claim is made. Native progress remains a separate following slice. ## Risks - A future eligibility writer must notify the existing projection boundary. Independent SQL writers or another active server process are outside this process-local signal contract. - Authority and lineage remain sensitive. The worker uses the existing selectors and publication policy; it does not grant execution or disclosure authority. - Native progress and other publication maintenance still have recurring work. This PR removes one reason, not the entire chat maintenance timer. - Worker quiet does not prove database suspension, host sleep, or external alarm coverage. Cloud sleep blockers remain unchanged. - Merge eligibility requires passing current-head CI and Greptile 5/5 after this actual-master reconciliation. Subsequent stack slices retain their own gates. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context-window size are not exposed. Used repository inspection, reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub tools. ## 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 available in this session) - [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 the documented focused 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>
323 lines
26 KiB
TypeScript
323 lines
26 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 { 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 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);
|
|
});
|
|
|
|
});
|