Files
PaperClipAI/server/src/__tests__/tool-access-oauth-protocol.integration.test.ts
T
DottaandPaperclip c67909cb0f fix: keep tool approvals valid through OAuth refresh and delayed review (#20340)
## Thinking Path

> - Paperclip lets people manage agent work and approve app actions.
> - An approval must bind the action that the person reviewed.
> - OAuth access tokens can expire while that person is away.
> - The old target snapshot treated each token refresh as a new
authorization. It also imposed a one-hour review deadline.
> - This pull request separates token renewal from authorization
identity and keeps the human decision durable.
> - Current access, the reviewed arguments, meaningful target changes,
task closure, and execution receipts still control dispatch.
> - People can approve later without repeating a decision after routine
connection maintenance.

## Linked Issues or Issue Description

**What happened?**

A pending app action returned `approved_tool_target_changed` after its
OAuth tokens refreshed. The tool and connection settings were unchanged.
The same card also expired after one hour. A sign-in problem could
discard an accepted decision before provider dispatch.

**Expected behavior**

The original card remains reviewable after routine OAuth refresh and a
server restart. A later approval runs the exact reviewed action once. A
connection repair preserves the decision while Paperclip verifies the
same authorization. Meaningful changes require a new review.

**Steps to reproduce**

1. Connect an OAuth MCP app and configure an action to require approval.
2. Request the action, then refresh its access and refresh tokens before
approving.
3. Approve the original card. Before this change, it returns HTTP 409
even when the authorization is unchanged.
4. Wait more than one hour. Before this change, the card expires without
a task-specific deadline.

**Paperclip version or commit**

Reproduced from master at `76254ead2229`. The final branch is rebased
onto current master.

## What Changed

- Add versioned approval target comparison. New snapshots bind
credential identity epochs, consent, permissions, ownership, connection
configuration, tool contracts, and credential references. Authenticated
OAuth refresh preserves identity. Manual credential replacement does
not.
- Remove the implicit one-hour deadline from ordinary app action
reviews. Keep explicit deadlines and terminal-task expiry. Synchronize
the nullable interaction deadline in shared contracts and UI.
- Keep accepted, undispatched actions when sign-in is required. Show a
saved-approval repair state. Resume on committed connection changes
after target and access revalidation, without an idle retry loop.
- Preserve the existing execution claim, result receipt, and
uncertain-outcome no-replay rules.
- Explain meaningful target changes without inviting the person to retry
the stale card.
- Add a production task-thread Storybook example and document
compatibility and recovery behavior.

## Verification

- Red: production-refresher invalid-grant handling and formal-approval
withdrawal during a sign-in wait also reproduced as failing regressions
before their fixes.
- Red: before implementation, the local OAuth HTTP fixture failed for
both public and confidential clients with `approved_tool_target_changed`
after token refresh.
- Green: the extended fixture passes for public and confidential clients
with personal and shared grants. It covers actual local HTTP token
exchanges, multiple refreshes, a rebuilt gateway, an ended originating
run, approval after 25 hours, duplicate approval, preserved sharing
arguments, sign-in waiting, and event-driven repair. The gateways use
the production OAuth refresher; an invalid refresh preserves the
accepted decision before provider dispatch. Omitted-scope responses
cover equivalent scope-reporting changes. The provider is an isolated
local fixture, not a deployed Notebook service.
- Negative controls refuse changed consent, scopes, tool definition,
destination, and manual credential replacement before provider
execution.
- Existing gateway service, connected-tool acceptance, secret owner,
secret service, unsigned-review queue, and connection-health suites
passed. Explicit-deadline fixtures still prove expiry behavior,
including deadlines on accepted sign-in waits. A withdrawn formal
approval cancels a waiting action before connection recovery.
- Approval card, task-chat, error-copy and target-comparison tests
passed. Server and UI typechecks passed. Token gates and Storybook
catalog checks passed.
- Inspected the new saved-approval repair card in local Storybook using
the production task thread. It shows a reconnect link and no second
approval button or false running state.
- `pnpm verify --explain` returned exit 2 and requested broad CI
coverage. [Full
CI](https://github.com/paperclipai/paperclip/actions/runs/42677855507)
passed on `eb4f1c7caa9ee6e802c314ffc5eadec475a5de0c`, including build,
typecheck/release registry, general and serialized tests, and all eight
browser shards.
- Greptile reviewed the same final head at 5/5. Both findings from the
first review are fixed and their threads resolved; no open follow-ups
remain.

## Risks

- No database migration is needed. The new comparison reuses the vault
identity epoch. Only authenticated OAuth refresh code can preserve that
epoch; HTTP credential-write schemas cannot select that behavior.
- Earlier signed requests keep their original exact-version checks and
saved deadlines. Already-expired cards are not revived. A historical
card whose credentials already changed needs a fresh review.
- A new consent, account, permission set, destination, or manually
replaced credential must not inherit the old authorization. Reconnection
continues automatically only when revalidation proves the same saved
authorization.
- The interaction enrichment now permits a null deadline. Existing
string deadlines remain valid. The web client and shared validator
change together.
- This PR does not deploy to staging or perform a live Notebook
publication.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools, code execution, and
browser inspection. The runtime does not expose the exact model ID,
context-window limit, or reasoning-mode label; those values are not
asserted.

## 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-09 13:46:28 -06:00

353 lines
23 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, approvals, companies, companyMemberships, createDb, connectionGrants, heartbeatRuns, issues, toolActionRequests, toolCatalogEntries, toolConnections, toolOauthStates, toolPolicies } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { toolAccessService } from "../services/tool-access.js";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { initializeRunIdentity } from "../services/run-identity.js";
import { secretService } from "../services/secrets.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerToolReviewRecoveryWork } from "../services/tool-review-recovery-work.js";
import { writeToolConnectionHealthChange } from "../services/tool-connection-health-notifications.js";
import { readSignedToolArgumentsPayload } from "../services/tool-content-guards.js";
import { errorHandler } from "../middleware/index.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("tool OAuth over local HTTP", () => {
let fixture: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let keyDir: string;
beforeAll(async () => {
keyDir = await mkdtemp(join(tmpdir(), "tool-oauth-protocol-"));
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key"));
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://127.0.0.1:3100");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
fixture = await startEmbeddedPostgresTestDatabase("tool-oauth-protocol-");
db = createDb(fixture.connectionString);
});
afterAll(async () => {
await fixture?.cleanup();
vi.unstubAllEnvs();
if (keyDir) await rm(keyDir, { recursive: true, force: true });
});
it.each([
{ authMethod: "none", grantKind: "organization", scopeMode: "omitted_initial" },
{ authMethod: "client_secret_basic", grantKind: "organization", scopeMode: "omitted_refresh" },
{ authMethod: "none", grantKind: "user", scopeMode: "omitted_initial" },
{ authMethod: "client_secret_basic", grantKind: "user", scopeMode: "explicit" },
] as const)("approves a $grantKind $authMethod OAuth action after refresh and delayed review ($scopeMode)", async ({ authMethod, grantKind, scopeMode }) => {
const userId = `oauth-user-${randomUUID()}`;
const [company] = await db.insert(companies).values({
name: "OAuth protocol fixture", issuePrefix: `O${randomUUID().slice(0, 5)}`,
}).returning();
await db.insert(companyMemberships).values({
companyId: company.id, principalType: "user", principalId: userId,
status: "active", membershipRole: "admin",
});
const calls: Array<{ path: string; authorization?: string; body: string }> = [];
let origin: string;
let currentAccessToken = "fixture-access";
let currentRefreshToken = "fixture-refresh";
let rejectRefresh = false;
const provider = createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
const path = new URL(req.url!, origin).pathname;
calls.push({ path, authorization: req.headers.authorization, body });
const json = (value: unknown, status = 200) => {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(value));
};
if (path.startsWith("/.well-known/oauth-protected-resource")) {
return json({ resource: `${origin}/mcp`, authorization_servers: [origin] });
}
if (path === "/.well-known/oauth-authorization-server") {
return json({
issuer: origin, authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`,
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: [authMethod],
scopes_supported: ["tools.write", "offline_access"],
grant_types_supported: ["authorization_code", "refresh_token"],
});
}
if (path === "/register") {
return json({ ...JSON.parse(body), client_id: "fixture-client",
...(authMethod === "client_secret_basic" ? { client_secret: "fixture-secret" } : {}),
});
}
if (path === "/token") {
const refreshing = new URLSearchParams(body).get("grant_type") === "refresh_token";
if (refreshing) {
if (rejectRefresh) return json({ error: "invalid_grant" }, 400);
if (new URLSearchParams(body).get("refresh_token") !== currentRefreshToken) {
return json({ error: "invalid_grant" }, 400);
}
currentAccessToken = "fixture-access-rotated";
currentRefreshToken = "fixture-refresh-rotated";
}
return json({ access_token: currentAccessToken, refresh_token: currentRefreshToken,
expires_in: 3600, token_type: "Bearer",
...((scopeMode === "omitted_initial" && !refreshing) || (scopeMode === "omitted_refresh" && refreshing)
? {} : { scope: "tools.write offline_access" }) });
}
if (path === "/mcp") {
if (req.headers.authorization !== `Bearer ${currentAccessToken}`) {
res.setHeader("www-authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`);
return json({}, 401);
}
const rpc = JSON.parse(body);
if (rpc.method === "notifications/initialized") {
res.writeHead(202).end();
return;
}
if (rpc.method === "tools/call") {
return json({ jsonrpc: "2.0", id: rpc.id,
result: { content: [{ type: "text", text: "Reviewed action completed" }] } });
}
return json({ jsonrpc: "2.0", id: rpc.id, result: rpc.method === "initialize"
? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
: { tools: [{ name: "create_fixture", description: "Create fixture page", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: false } }] },
});
}
json({}, 404);
});
await new Promise<void>((resolve) => provider.listen(0, "127.0.0.1", resolve));
origin = `http://127.0.0.1:${(provider.address() as AddressInfo).port}`;
try {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", userId, source: "local_implicit", isInstanceAdmin: true };
next();
});
app.use("/api", toolAccessRoutes(db, { deploymentMode: "local_trusted", deploymentExposure: "private", paperclipCloudConnector: null }));
app.use(errorHandler);
const connected = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`)
.send({ link: `${origin}/mcp`, name: "Local OAuth", grantKind }).expect(201);
const authorization = new URL(connected.body.auth.startUrl);
expect(authorization.origin).toBe(origin);
expect(authorization.searchParams.get("client_id")).toBe("fixture-client");
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
expect(authorization.searchParams.get("resource")).toBe(`${origin}/mcp`);
const state = authorization.searchParams.get("state")!;
const [pending] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state));
expect(pending.companyId).toBe(company.id);
const completed = await request(app).get("/api/tools/oauth/callback")
.query({ state, code: "fixture-code", iss: origin }).expect(200);
expect(completed.body.catalog).toEqual([expect.objectContaining({ toolName: "create_fixture", riskLevel: "write" })]);
if (grantKind === "user") {
await request(app).post(`/api/companies/${company.id}/tools/apps/${connected.body.connectionId}/finalize-oauth-access`)
.send({ grantKind }).expect(200);
}
await request(app).post(`/api/companies/${company.id}/tools/apps/${connected.body.connectionId}/finish`)
.send({ enabledCatalogEntryIds: completed.body.catalog.map((entry: { id: string }) => entry.id), askFirstCatalogEntryIds: [], access: "all_agents" }).expect(200);
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.body.connectionId));
expect(connection).toMatchObject({ companyId: company.id, status: "active", enabled: true, ownership: "dcr" });
expect(connection.config.oauth).toMatchObject({ clientId: "fixture-client", clientTokenEndpointAuthMethod: authMethod, clientIssuer: origin });
expect(JSON.stringify(connection.config)).not.toMatch(/fixture-(access|refresh|secret)/);
if (grantKind === "organization") expect(connection.credentialSecretRefs.map((ref) => ref.configPath))
.toEqual(expect.arrayContaining(["oauth.access_token", "oauth.refresh_token"]));
expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection.id)))
.toEqual([expect.objectContaining({ toolName: "create_fixture", status: "active", reviewedByUserId: userId })]);
const tokenCall = calls.find((call) => call.path === "/token")!;
const tokenBody = new URLSearchParams(tokenCall.body);
expect(tokenBody.get("code")).toBe("fixture-code");
expect(tokenBody.get("code_verifier")).toBe(pending.codeVerifier);
expect(tokenBody.get("resource")).toBe(`${origin}/mcp`);
expect(tokenCall.authorization).toBe(authMethod === "client_secret_basic"
? `Basic ${Buffer.from("fixture-client:fixture-secret").toString("base64")}` : undefined);
expect(tokenBody.has("client_secret")).toBe(false);
expect(calls.some((call) => call.path === "/mcp" && call.authorization === "Bearer fixture-access")).toBe(true);
expect(calls.filter((call) => call.path === "/register")).toHaveLength(1);
const [grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.kind, grantKind)));
const consentedAt = grant.providerTenant?.oauth?.consentedAt;
expect(consentedAt).toEqual(expect.any(String));
expect(Number.isFinite(Date.parse(consentedAt!))).toBe(true);
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "OAuth approval agent",
role: "engineer", adapterType: "process" }).returning();
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Delayed OAuth approval",
status: "in_progress", assigneeAgentId: agent.id }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
invocationSource: "assignment", status: "running", contextSnapshot: { issueId: issue.id } }).returning();
await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id,
responsibleUserId: userId, cause: "instruction" });
await db.insert(toolPolicies).values({ companyId: company.id, name: "Review OAuth actions",
policyType: "require_approval", selectors: { connectionId: connection.id } });
const options = { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const, paperclipCloudConnector: null };
const first = toolAccessService(db, options);
const second = toolAccessService(db, options);
const signingSecret = "oauth-protocol-approval-signing-secret";
const gatewayOptions = { toolActionSigningSecret: signingSecret,
oauthGrantRefresher: first.refreshOAuthGrantCredentials };
const gateway = createToolGatewayService(db, gatewayOptions);
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find(entry => entry.connectionId === connection.id)!;
expect(tool).toBeDefined();
const reviewActions = ["after refresh", "tomorrow", "connection repair", "changed authorization", "withdrawn formal approval", "expired authorization"];
for (const action of reviewActions) {
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
parameters: { action, sharingPolicy: { mode: "domain", domain: "paperclip.ing" } } })).rejects.toMatchObject({ reasonCode: "approval_required" });
}
const savedActions = await db.select().from(toolActionRequests).where(eq(toolActionRequests.issueId, issue.id));
expect(savedActions).toHaveLength(reviewActions.length);
const pendingActions = reviewActions.map(action => savedActions.find(saved => {
const payload = readSignedToolArgumentsPayload({ signedArguments: saved.signedArguments,
invocationId: saved.invocationId, toolName: tool.name, signingSecret });
return (payload?.arguments as { action: string })?.action === action;
})!);
await db.update(connectionGrants).set({ providerTenant: {
...grant.providerTenant,
oauth: { ...grant.providerTenant?.oauth, accessTokenExpiresAt: new Date(0).toISOString() },
} }).where(eq(connectionGrants.id, grant.id));
const refreshInput = { companyId: company.id, connectionId: connection.id, grantId: grant.id, actor: { actorType: "user" as const, actorId: userId } };
const refreshed = await Promise.all([
first.refreshOAuthGrantCredentials(refreshInput),
second.refreshOAuthGrantCredentials(refreshInput),
]);
expect(refreshed.map(row => row.id)).toEqual([grant.id, grant.id]);
const exchanges = calls.filter(call => call.path === "/token");
expect(exchanges).toHaveLength(2);
const refreshBody = new URLSearchParams(exchanges[1].body);
expect(refreshBody.get("grant_type")).toBe("refresh_token");
expect(refreshBody.get("refresh_token")).toBe("fixture-refresh");
expect(refreshBody.get("resource")).toBe(`${origin}/mcp`);
expect(exchanges[1].authorization).toBe(tokenCall.authorization);
const [persistedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id));
expect(persistedGrant.providerTenant?.oauth).not.toHaveProperty("refreshLease");
expect(persistedGrant.providerTenant?.oauth?.consentedAt).toBe(consentedAt);
expect(Date.parse(persistedGrant.providerTenant!.oauth!.accessTokenExpiresAt!)).toBeGreaterThan(Date.now());
expect(JSON.stringify(persistedGrant)).not.toMatch(/fixture-(access|refresh|secret)/);
await first.checkHealth(connection.id, { actorType: "user", actorId: userId });
expect(calls.some(call => call.path === "/mcp" && call.authorization === "Bearer fixture-access-rotated")).toBe(true);
await db.update(connectionGrants).set({ providerTenant: {
...persistedGrant.providerTenant,
oauth: { ...persistedGrant.providerTenant?.oauth, accessTokenExpiresAt: new Date(0).toISOString() },
} }).where(eq(connectionGrants.id, grant.id));
await second.refreshOAuthGrantCredentials(refreshInput);
const allExchanges = calls.filter(call => call.path === "/token");
expect(allExchanges).toHaveLength(3);
expect(new URLSearchParams(allExchanges[2].body).get("refresh_token")).toBe("fixture-refresh-rotated");
expect(calls.filter(call => call.path === "/register")).toHaveLength(1);
// Rebuild the gateway after the requesting run ends. The durable request,
// rather than the original session or its old tokens, owns execution.
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
const restarted = createToolGatewayService(db, gatewayOptions);
const approve = (id: string) => restarted.approveActionRequest({ companyId: company.id,
actionRequestId: id, actor: { userId } });
const [currentGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id));
const catalog = completed.body.catalog[0];
for (const change of ["tool", "destination", "scopes", "consent"]) {
try {
if (change === "tool") await db.update(toolCatalogEntries).set({ versionHash: "new tool definition" })
.where(eq(toolCatalogEntries.id, catalog.id));
else if (change === "destination") await db.update(toolConnections).set({
config: { ...connection.config, url: `${origin}/different-target` },
}).where(eq(toolConnections.id, connection.id));
else await db.update(connectionGrants).set({ providerTenant: { ...currentGrant.providerTenant,
oauth: { ...currentGrant.providerTenant?.oauth,
...(change === "scopes" ? { scopes: ["tools.admin"] } : { consentedAt: "2026-11-10T00:00:00Z" }) },
} }).where(eq(connectionGrants.id, grant.id));
await expect(approve(pendingActions[3].id)).rejects.toMatchObject({ reasonCode: "approved_tool_target_changed" });
expect(calls.filter(call => call.path === "/mcp" && JSON.parse(call.body).method === "tools/call")).toHaveLength(0);
} finally {
await db.update(toolCatalogEntries).set({ versionHash: catalog.versionHash }).where(eq(toolCatalogEntries.id, catalog.id));
await db.update(toolConnections).set({ config: connection.config }).where(eq(toolConnections.id, connection.id));
await db.update(connectionGrants).set({ providerTenant: currentGrant.providerTenant }).where(eq(connectionGrants.id, grant.id));
}
}
await expect(approve(pendingActions[0].id)).resolves.toMatchObject({ status: "executed" });
await expect(approve(pendingActions[0].id)).resolves.toMatchObject({ status: "executed" });
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(Date.now() + 25 * 60 * 60_000);
await expect(approve(pendingActions[1].id)).resolves.toMatchObject({ status: "executed" });
} finally { vi.useRealTimers(); }
await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
await expect(approve(pendingActions[2].id)).resolves.toMatchObject({ status: "approved" });
const [formal] = await db.insert(approvals).values({ companyId: company.id,
type: "request_board_approval", status: "approved", payload: {} }).returning();
await db.update(toolActionRequests).set({ approvalId: formal.id }).where(eq(toolActionRequests.id, pendingActions[4].id));
await expect(approve(pendingActions[4].id)).resolves.toMatchObject({ status: "approved" });
await db.update(approvals).set({ status: "rejected" }).where(eq(approvals.id, formal.id));
await restarted.recoverApprovedActionReviews(company.id);
expect((await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, pendingActions[4].id)))[0].status)
.toBe("cancelled");
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true,
canReconcile: () => true, onError: error => { throw error; } });
try {
await registerToolReviewRecoveryWork(coordinator, restarted, () => true).ready;
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 5000 });
await expect(restarted.recoverApprovedActionReviews(company.id)).resolves.toMatchObject({ retry: false });
// The supported connection writer wakes recovery after commit. No
// second approval or agent run is needed for the same authorization.
await writeToolConnectionHealthChange(db, { companyId: company.id, id: connection.id }, async tx => {
await tx.update(connectionGrants).set({ status: "active", updatedAt: new Date(Date.now() + 1) })
.where(eq(connectionGrants.id, grant.id));
});
await vi.waitFor(async () => {
expect((await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, pendingActions[2].id)))[0].status)
.toBe("executed");
}, { timeout: 5000, interval: 25 });
} finally { await coordinator.stop(); }
// A production refresh failure is a pre-dispatch sign-in wait, even
// when the grant looked active when the person clicked Approve.
const [beforeReauthConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
const [beforeReauthGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id));
rejectRefresh = true;
await db.update(connectionGrants).set({ providerTenant: { ...beforeReauthGrant.providerTenant,
oauth: { ...beforeReauthGrant.providerTenant?.oauth, accessTokenExpiresAt: new Date(0).toISOString() },
} }).where(eq(connectionGrants.id, grant.id));
await expect(approve(pendingActions[5].id)).resolves.toMatchObject({ status: "approved" });
await restarted.recoverApprovedActionReviews(company.id);
expect((await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, pendingActions[5].id)))[0].status)
.toBe("approved");
const deadline = new Date(Date.now() - 1);
await db.update(toolActionRequests).set({ expiresAt: deadline }).where(eq(toolActionRequests.id, pendingActions[5].id));
expect(await restarted.nextActionReviewExpiryAt()).toBe(deadline.getTime());
await restarted.sweepExpiredActionReviews();
expect((await db.select().from(toolActionRequests).where(eq(toolActionRequests.id, pendingActions[5].id)))[0].status)
.toBe("expired");
rejectRefresh = false;
// Restore this fixture's original authorization metadata to exercise
// manual replacement separately. The recovery worker is already stopped.
await db.update(toolConnections).set({ status: beforeReauthConnection.status,
enabled: beforeReauthConnection.enabled, credentialRefs: beforeReauthConnection.credentialRefs,
credentialSecretRefs: beforeReauthConnection.credentialSecretRefs,
}).where(eq(toolConnections.id, connection.id));
await db.update(connectionGrants).set({ status: "active", credentialSecretRefs: beforeReauthGrant.credentialSecretRefs,
providerTenant: beforeReauthGrant.providerTenant }).where(eq(connectionGrants.id, grant.id));
const accessRef = currentGrant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token")!;
await secretService(db).rotate(accessRef.secretId, { value: "manually-replaced-token" }, { userId });
await expect(approve(pendingActions[3].id)).rejects.toMatchObject({ reasonCode: "approved_tool_target_changed" });
const executions = calls.filter(call => call.path === "/mcp" && JSON.parse(call.body).method === "tools/call");
expect(executions).toHaveLength(3);
expect(executions.map(call => JSON.parse(call.body).params.arguments.action).sort())
.toEqual(["after refresh", "connection repair", "tomorrow"]);
for (const execution of executions) expect(JSON.parse(execution.body).params.arguments.sharingPolicy)
.toEqual({ mode: "domain", domain: "paperclip.ing" });
} finally {
provider.closeAllConnections();
await new Promise<void>((resolve, reject) => provider.close((error) => error ? reject(error) : resolve()));
}
});
});