mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed connections (Gmail, Google Workspace, GitHub, Asana, Slack manager) get and refresh their OAuth tokens through the Paperclip Cloud connector broker. > - When a provider refuses a refresh grant (the user revoked it, it expired, it was already rotated, or it no longer matches the profile), the broker sent `502 PROVIDER_OPERATION_FAILED`. That is the same answer it sends for a provider outage. > - Paperclip then kept the grant `active`. Each health check, catalog refresh, or catalog read failed again with a 502, and the server reported each failure as a crash. The user did not get a reconnect prompt. > - The broker now sends `409 PROVIDER_REAUTHORIZATION_REQUIRED` for a dead grant. The connector already maps any 409 to `REAUTHORIZATION_REQUIRED`. Thus the grant becomes `needs_reauthorization` and the caller gets a 422 reconnect response. > - This pull request adds the new reason to the diagnostic allowlist, pins the producer contract, and tests the reconnect path from end to end. > - The benefit is that a dead managed grant asks the user to reconnect. It is not reported as a server crash. Real provider outages keep their 502 response. ## Linked Issues or Issue Description **What happened?** A managed Paperclip Cloud connection refresh failed with `Paperclip Cloud connector rejected the request (operation=refresh, status=502, reason=PROVIDER_OPERATION_FAILED)`. The connection health-check and catalog routes wrap that error as an HTTP 502. The error handler reports every status of 500 or more, so the failure went to error monitoring. The grant stayed `active`, so the same failure can occur again on each use. **Expected behavior** If the provider refuses the refresh grant, Paperclip marks the grant `needs_reauthorization` and shows the reconnect path. Only a real provider or Cloud outage gives a 502. **Steps to reproduce** 1. Connect a managed Gmail connection through Paperclip Cloud. 2. Revoke the app access in the Google account, or let the refresh grant expire. 3. Wait until the access token needs a refresh. Then run a connection health check. 4. Before the broker change, the health check returns 502 and the server reports it. The grant stays `active`. **Additional context** The broker-side classification ships separately in Paperclip Cloud. This pull request is safe before or after that change, because the connector already maps any broker 409 to `REAUTHORIZATION_REQUIRED`. Without this pull request, the 409 still works, but diagnostics show `reason=UNKNOWN_BROKER_ERROR`. ## What Changed - `server/src/services/paperclip-cloud-connector.ts`: Add `PROVIDER_REAUTHORIZATION_REQUIRED` to the allowlist of broker reasons that errors can keep. - `server/src/services/fixtures/cloud-connector-refresh-reauthorization.json`: Add the producer HTTP contract (status 409 and the exact JSON error body). - `server/src/services/paperclip-cloud-connector.test.ts`: Test that a refresh with this contract gives `REAUTHORIZATION_REQUIRED`, status 409, the retained reason, and no refresh-token text. - `server/src/__tests__/tool-access-service.test.ts`: Test that `refreshOAuthGrantCredentials` changes a reauthorization refresh failure into a 422 `oauth_reauthorization_required` response and sets the grant to `needs_reauthorization`. - `doc/connections/GMAIL.md`: Add a troubleshooting row that tells the two refresh reasons apart. ## Verification - `cd server && npx vitest run src/services/paperclip-cloud-connector.test.ts`: 48 passed. Without the allowlist change, the new test fails (`reason=UNKNOWN_BROKER_ERROR`). - `cd server && npx vitest run src/__tests__/tool-access-service.test.ts -t "managed grant for reconnection|orders managed reconnect"`: 2 passed, against the embedded test database. - I did not run the full `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` locally. The sandbox has a 4 GB memory limit, and the server `tsc` process was stopped because it used too much memory. CI runs these checks. ## Risks - Low risk. The status mapping does not change: a 409 already meant `REAUTHORIZATION_REQUIRED`. This change only keeps one more fixed reason code in the error message. - The reason allowlist accepts fixed protocol codes only. The broker message, request data, and tokens stay out of errors and logs. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code agent mode with tool use (shell, file edit, code search, and a sub-agent for the code-path trace). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
798 lines
39 KiB
TypeScript
798 lines
39 KiB
TypeScript
import {
|
|
createCipheriv,
|
|
createHash,
|
|
diffieHellman,
|
|
generateKeyPairSync,
|
|
hkdfSync,
|
|
randomBytes,
|
|
type KeyObject,
|
|
} from "node:crypto";
|
|
import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
import {
|
|
createPaperclipCloudConnector,
|
|
invalidatePaperclipCloudConnectorCapabilities,
|
|
GMAIL_CONNECTOR_SCOPES,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
paperclipCloudConnectorCapabilitiesFromEnv,
|
|
paperclipCloudConnectorConfigFromEnv,
|
|
PaperclipCloudConnectorError,
|
|
type PaperclipCloudConnectorConfig,
|
|
} from "./paperclip-cloud-connector.js";
|
|
|
|
const instanceId = "inst_test";
|
|
const companyId = "company_test";
|
|
const subject = "user_test";
|
|
|
|
// Captured from the producer's in-memory HTTP integration test, using synthetic
|
|
// enrollment and a signed request with a callback outside the enrolled origins.
|
|
// Retain only the HTTP status and exact JSON error body, never request data.
|
|
const originRejectionContract = JSON.parse(readFileSync(
|
|
new URL("./fixtures/cloud-connector-origin-rejection.json", import.meta.url), "utf8",
|
|
)) as { status: number; body: { error: string } };
|
|
const refreshReauthorizationContract = JSON.parse(readFileSync(
|
|
new URL("./fixtures/cloud-connector-refresh-reauthorization.json", import.meta.url), "utf8",
|
|
)) as { status: number; body: { error: string } };
|
|
|
|
function rawPrivateKey(key: KeyObject): string {
|
|
const jwk = key.export({ format: "jwk" }) as { d?: string };
|
|
if (!jwk.d) throw new Error("missing private key bytes");
|
|
return jwk.d;
|
|
}
|
|
|
|
function config() {
|
|
const signing = generateKeyPairSync("ed25519");
|
|
const sealing = generateKeyPairSync("x25519");
|
|
return {
|
|
config: {
|
|
baseUrl: "https://my.example.test",
|
|
instanceId,
|
|
environment: "staging",
|
|
signPrivateKey: rawPrivateKey(signing.privateKey),
|
|
sealPrivateKey: rawPrivateKey(sealing.privateKey),
|
|
} satisfies PaperclipCloudConnectorConfig,
|
|
sealPublicKey: sealing.publicKey,
|
|
};
|
|
}
|
|
|
|
describe("Paperclip Cloud connector", () => {
|
|
it("accepts actual partial Workspace consent inside an envelope bound to the full requested profile", async () => {
|
|
const keys = config();
|
|
const scopes = ["https://www.googleapis.com/auth/documents"];
|
|
const credentials = { v: 1, accessToken: "test-access", refreshToken: "test-refresh", tokenType: "Bearer",
|
|
accessTokenExpiresAt: null, refreshTokenExpiresAt: null, scopes, subject, companyId, instanceId,
|
|
environment: keys.config.environment, provider: "google", profile: "workspace.all" };
|
|
let sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all");
|
|
const connector = createPaperclipCloudConnector({ config: keys.config,
|
|
request: vi.fn(async () => Response.json({ sealed })) as typeof fetch });
|
|
await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).resolves.toEqual(credentials);
|
|
sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
|
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).resolves.toEqual(credentials);
|
|
for (const invalid of [[], [...scopes, "https://www.googleapis.com/auth/gmail.send"]]) {
|
|
credentials.scopes = invalid;
|
|
sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all");
|
|
await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" });
|
|
sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
|
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" });
|
|
}
|
|
sealed = seal({ ...credentials, scopes: undefined }, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
|
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toBeInstanceOf(PaperclipCloudConnectorError);
|
|
});
|
|
it("accepts a managed test identity and rejects other environments at its broker", () => {
|
|
const identity = config().config;
|
|
const env = {
|
|
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: identity.instanceId,
|
|
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: identity.signPrivateKey,
|
|
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: identity.sealPrivateKey,
|
|
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app",
|
|
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "test",
|
|
};
|
|
expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL });
|
|
for (const environment of ["development", "staging", "production"]) {
|
|
expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment })).toThrow(/broker and environment do not match/);
|
|
}
|
|
for (const baseUrl of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) {
|
|
expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: baseUrl })).toThrow(/broker and environment do not match/);
|
|
}
|
|
});
|
|
|
|
async function rejection(response: Response) {
|
|
const connector = createPaperclipCloudConnector({
|
|
config: config().config,
|
|
request: vi.fn(async () => response) as typeof fetch,
|
|
});
|
|
return connector.startAuthorization({
|
|
subject, companyId, profile: "gmail.read",
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "private-state",
|
|
}).catch((error: unknown) => error);
|
|
}
|
|
|
|
it("accepts the producer's origin-rejection HTTP contract", async () => {
|
|
expect(await rejection(Response.json(originRejectionContract.body, {
|
|
status: originRejectionContract.status,
|
|
}))).toMatchObject({
|
|
code: "CONNECTOR_REQUEST_FAILED", status: 400,
|
|
message: "Paperclip Cloud connector rejected the request (operation=session, status=400, reason=RETURN_ORIGIN_NOT_ENROLLED)",
|
|
});
|
|
});
|
|
|
|
it("retains an allowlisted rejection reason without broker messages or credentials", async () => {
|
|
const error = await rejection(Response.json({
|
|
error: "RETURN_ORIGIN_NOT_ENROLLED",
|
|
message: "DO_NOT_REPORT private-state access-secret https://private.example.test",
|
|
}, { status: 400 }));
|
|
expect(error).toBeInstanceOf(PaperclipCloudConnectorError);
|
|
expect(error).toMatchObject({
|
|
code: "CONNECTOR_REQUEST_FAILED", status: 400,
|
|
message: "Paperclip Cloud connector rejected the request (operation=session, status=400, reason=RETURN_ORIGIN_NOT_ENROLLED)",
|
|
});
|
|
expect(JSON.stringify(error)).not.toMatch(/DO_NOT_REPORT|private-state|access-secret|private\.example/);
|
|
});
|
|
|
|
it.each([
|
|
JSON.stringify({ error: "CUSTOM_SECRET_ERROR", message: "DO_NOT_REPORT" }),
|
|
JSON.stringify({ error: { code: "RETURN_ORIGIN_NOT_ENROLLED" } }),
|
|
"<html>DO_NOT_REPORT</html>",
|
|
JSON.stringify({ error: "RETURN_ORIGIN_NOT_ENROLLED", padding: "x".repeat(4_096) }),
|
|
])("drops unknown, malformed, or oversized rejection bodies", async (body) => {
|
|
const error = await rejection(new Response(body, { status: 409 }));
|
|
expect(error).toMatchObject({
|
|
code: "REAUTHORIZATION_REQUIRED", status: 409,
|
|
message: "Paperclip Cloud connector rejected the request (operation=session, status=409, reason=UNKNOWN_BROKER_ERROR)",
|
|
});
|
|
});
|
|
|
|
it("asks for reconnection when the provider rejects a refresh grant", async () => {
|
|
const connector = createPaperclipCloudConnector({
|
|
config: config().config,
|
|
request: vi.fn(async () => Response.json(refreshReauthorizationContract.body, {
|
|
status: refreshReauthorizationContract.status,
|
|
})) as typeof fetch,
|
|
});
|
|
const error = await connector.refresh({
|
|
subject, companyId, profile: "gmail.read", refreshToken: "refresh-secret",
|
|
}).catch((caught: unknown) => caught);
|
|
expect(error).toBeInstanceOf(PaperclipCloudConnectorError);
|
|
expect(error).toMatchObject({
|
|
code: "REAUTHORIZATION_REQUIRED", status: 409,
|
|
message: "Paperclip Cloud connector rejected the request (operation=refresh, status=409, reason=PROVIDER_REAUTHORIZATION_REQUIRED)",
|
|
});
|
|
expect(JSON.stringify(error)).not.toContain("refresh-secret");
|
|
});
|
|
|
|
it("keeps the original status when the error body stream fails", async () => {
|
|
const response = new Response(new ReadableStream({
|
|
start(controller) { controller.error(new Error("DO_NOT_REPORT")); },
|
|
}), { status: 503 });
|
|
expect(await rejection(response)).toMatchObject({
|
|
code: "CONNECTOR_REQUEST_FAILED", status: 503,
|
|
message: expect.stringContaining("reason=UNKNOWN_BROKER_ERROR"),
|
|
});
|
|
});
|
|
|
|
it("keeps the original failure when a response body is absent or already locked", async () => {
|
|
const locked = Response.json({ error: "RETURN_ORIGIN_NOT_ENROLLED" }, { status: 401 });
|
|
const reader = locked.body!.getReader();
|
|
try {
|
|
for (const response of [new Response(null, { status: 401 }), locked]) {
|
|
expect(await rejection(response)).toMatchObject({
|
|
code: "CONNECTOR_REQUEST_FAILED", status: 401,
|
|
message: expect.stringContaining("reason=UNKNOWN_BROKER_ERROR"),
|
|
});
|
|
}
|
|
} finally {
|
|
await reader.cancel();
|
|
}
|
|
});
|
|
|
|
it("bounds a stalled diagnostic read and does not wait for cancellation", async () => {
|
|
vi.useFakeTimers();
|
|
const cancel = vi.fn(() => new Promise<void>(() => {}));
|
|
try {
|
|
const pending = rejection(new Response(new ReadableStream({ cancel }), { status: 400 }));
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(await pending).toMatchObject({
|
|
code: "CONNECTOR_REQUEST_FAILED", status: 400,
|
|
message: expect.stringContaining("reason=UNKNOWN_BROKER_ERROR"),
|
|
});
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("refreshes capabilities after enrollment and rejects stale cache writes", async () => {
|
|
const keys = config().config;
|
|
const env = {
|
|
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: keys.baseUrl,
|
|
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: keys.instanceId,
|
|
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: keys.environment,
|
|
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: keys.signPrivateKey,
|
|
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: keys.sealPrivateKey,
|
|
};
|
|
let completeOldRequest!: (response: Response) => void;
|
|
const request = vi.spyOn(globalThis, "fetch")
|
|
.mockResolvedValueOnce(Response.json({ status: "pending", active: false }))
|
|
.mockImplementationOnce(() => new Promise<Response>((resolve) => { completeOldRequest = resolve; }))
|
|
.mockResolvedValue(Response.json({ status: "active", active: true, profiles: ["gmail.read"] }));
|
|
invalidatePaperclipCloudConnectorCapabilities();
|
|
try {
|
|
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual([]);
|
|
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual([]);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
invalidatePaperclipCloudConnectorCapabilities();
|
|
const oldRequest = paperclipCloudConnectorCapabilitiesFromEnv(env);
|
|
invalidatePaperclipCloudConnectorCapabilities();
|
|
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual(["gmail.read"]);
|
|
completeOldRequest(Response.json({ status: "pending", active: false }));
|
|
await expect(oldRequest).resolves.toEqual([]);
|
|
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual(["gmail.read"]);
|
|
expect(request).toHaveBeenCalledTimes(3);
|
|
} finally {
|
|
request.mockRestore();
|
|
invalidatePaperclipCloudConnectorCapabilities();
|
|
}
|
|
});
|
|
|
|
it.each(["chat.read", "chat.write"] as const)("signs reduced Chat scopes for %s", async (profile) => {
|
|
const keys = config();
|
|
const scopes = ["chat.spaces.readonly", "chat.messages.readonly", ...(profile === "chat.write" ? ["chat.messages.create"] : [])]
|
|
.map((scope) => `https://www.googleapis.com/auth/${scope}`);
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
|
|
expect(claims.prf).toBe(profile);
|
|
expect(claims.scp).toEqual(scopes);
|
|
return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=chat", expiresAt: "2099-01-01T00:00:00Z" });
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
await connector.startAuthorization({ subject, companyId, profile,
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "chat-state" });
|
|
expect(request).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("signs selected Workspace scopes without substituting the full profile", async () => {
|
|
const keys = config();
|
|
const scopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
|
|
expect(claims).toMatchObject({ prf: "workspace.all", scp: scopes });
|
|
return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=workspace", expiresAt: "2099-01-01T00:00:00Z" });
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
await connector.startAuthorization({ subject, companyId, profile: "workspace.all", scopes,
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "workspace-state" });
|
|
expect(request).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("starts a signed session with exact endpoint audience and scope contract", async () => {
|
|
const keys = config();
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body)) as { request: string };
|
|
const [encodedHeader, encodedClaims] = body.request.split(".");
|
|
expect(JSON.parse(Buffer.from(encodedHeader!, "base64url").toString("utf8"))).toEqual({
|
|
alg: "EdDSA",
|
|
typ: "paperclip-cloud-connector-request+jwt",
|
|
});
|
|
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
|
|
expect(claims).toMatchObject({
|
|
iss: instanceId,
|
|
aud: "https://my.example.test/v1/connector/sessions",
|
|
sub: subject,
|
|
cid: companyId,
|
|
env: "staging",
|
|
op: "session",
|
|
prv: "google",
|
|
prf: "gmail.draft",
|
|
scp: [...GMAIL_CONNECTOR_SCOPES],
|
|
ruri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
rst: "state-1",
|
|
});
|
|
return Response.json({
|
|
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
|
handoff: {
|
|
kind: "tenant_background",
|
|
session: "broker_state_abcdefghijklmnop",
|
|
},
|
|
expiresAt: "2026-08-21T20:00:00.000Z",
|
|
}, { status: 201 });
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
|
|
await expect(connector.startAuthorization({
|
|
subject,
|
|
companyId,
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "state-1",
|
|
})).resolves.toMatchObject({
|
|
authorizationUrl: expect.stringContaining("/connections/confirm"),
|
|
handoff: {
|
|
kind: "paperclip_cloud",
|
|
session: "broker_state_abcdefghijklmnop",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("prefers a validated HTTPS provider URL over the legacy confirmation URL", async () => {
|
|
const keys = config();
|
|
const connector = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => Response.json({
|
|
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
|
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
|
|
expiresAt: "2099-08-21T20:00:00.000Z",
|
|
})) as typeof fetch,
|
|
});
|
|
|
|
await expect(connector.startAuthorization({
|
|
subject,
|
|
companyId,
|
|
profile: "github.code",
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "state-direct",
|
|
})).resolves.toMatchObject({
|
|
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["https://app.asana.com/-/oauth_authorize", true],
|
|
["https://mcp.asana.com/authorize", false],
|
|
["https://app.asana.com/other", false],
|
|
])("validates Asana's exact v2 authorization endpoint %s", async (authorizationUrl, accepted) => {
|
|
const keys = config();
|
|
const connector = createPaperclipCloudConnector({ config: keys.config,
|
|
request: vi.fn(async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
|
|
expect(claims).toMatchObject({ prv: "asana", prf: "asana.mcp", scp: ["default"] });
|
|
return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=test",
|
|
authorizationUrl, expiresAt: "2099-01-01T00:00:00Z" });
|
|
}) as typeof fetch,
|
|
});
|
|
const result = connector.startAuthorization({ subject, companyId, profile: "asana.mcp",
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "state" });
|
|
if (accepted) await expect(result).resolves.toMatchObject({ authorizationUrl });
|
|
else await expect(result).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
|
|
});
|
|
|
|
it("accepts the fixed Google authorization endpoint for Google profiles", async () => {
|
|
const keys = config();
|
|
const connector = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => Response.json({
|
|
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
|
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state",
|
|
expiresAt: "2099-08-21T20:00:00.000Z",
|
|
})) as typeof fetch,
|
|
});
|
|
|
|
await expect(connector.startAuthorization({
|
|
subject,
|
|
companyId,
|
|
profile: "gmail.draft",
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "state-direct-google",
|
|
})).resolves.toMatchObject({
|
|
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["non-string", { href: "https://github.com/login/oauth/authorize" }],
|
|
["plaintext HTTP", "http://github.com/login/oauth/authorize"],
|
|
["embedded credentials", "https://user:password@github.com/login/oauth/authorize"],
|
|
["fragment", "https://github.com/login/oauth/authorize#unexpected"],
|
|
["unapproved HTTPS origin", "https://attacker.example.test/login/oauth/authorize"],
|
|
["unapproved provider path", "https://github.com/session/authorize"],
|
|
["not a URL", "not-a-url"],
|
|
])("rejects a malformed direct provider URL: %s", async (_label, authorizationUrl) => {
|
|
const keys = config();
|
|
const connector = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => Response.json({
|
|
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
|
authorizationUrl,
|
|
expiresAt: "2099-08-21T20:00:00.000Z",
|
|
})) as typeof fetch,
|
|
});
|
|
|
|
await expect(connector.startAuthorization({
|
|
subject,
|
|
companyId,
|
|
profile: "github.code",
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "state-malformed-direct",
|
|
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
|
|
});
|
|
|
|
it("binds active GitHub installations to proof from the current user token", async () => {
|
|
const keys = config();
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body)) as { request: string; binding: string };
|
|
const claims = JSON.parse(Buffer.from(body.request.split(".")[1]!, "base64url").toString("utf8"));
|
|
const binding = JSON.parse(body.binding);
|
|
expect(binding).toEqual({
|
|
id: "binding-1",
|
|
installationId: "42",
|
|
connectionId: "connection-1",
|
|
grantId: "grant-1",
|
|
active: true,
|
|
accessToken: "ghu-user-token",
|
|
});
|
|
expect(claims.sh).toBe(createHash("sha256").update(body.binding).digest("base64url"));
|
|
return Response.json({ active: true, installationId: "42" });
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
|
|
await expect(connector.setWebhookBinding({
|
|
subject,
|
|
companyId,
|
|
id: "binding-1",
|
|
installationId: "42",
|
|
connectionId: "connection-1",
|
|
grantId: "grant-1",
|
|
active: true,
|
|
accessToken: "ghu-user-token",
|
|
})).resolves.toBeUndefined();
|
|
await expect(connector.setWebhookBinding({
|
|
subject,
|
|
companyId,
|
|
id: "binding-2",
|
|
installationId: "43",
|
|
connectionId: "connection-1",
|
|
grantId: "grant-1",
|
|
active: true,
|
|
})).rejects.toMatchObject({ code: "CONNECTOR_CONFIG_INVALID" });
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps legacy session responses compatible and rejects malformed handoff descriptors", async () => {
|
|
const keys = config();
|
|
const legacy = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => Response.json({
|
|
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
|
expiresAt: "2099-08-21T20:00:00.000Z",
|
|
})) as typeof fetch,
|
|
});
|
|
await expect(legacy.startAuthorization({
|
|
subject,
|
|
companyId,
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "state-legacy",
|
|
})).resolves.not.toHaveProperty("handoff");
|
|
|
|
const malformed = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => Response.json({
|
|
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
|
|
handoff: { kind: "tenant_background", session: "not valid" },
|
|
expiresAt: "2099-08-21T20:00:00.000Z",
|
|
})) as typeof fetch,
|
|
});
|
|
await expect(malformed.startAuthorization({
|
|
subject,
|
|
companyId,
|
|
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
|
|
returnState: "state-malformed",
|
|
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
|
|
});
|
|
|
|
it("opens Slack manager handoffs with their exact profile and personal identity", async () => {
|
|
const keys = config();
|
|
const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null,
|
|
scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager",
|
|
slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } };
|
|
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager");
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch });
|
|
await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials);
|
|
await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow();
|
|
});
|
|
it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => {
|
|
const keys = config();
|
|
const credentials = {
|
|
v: 1 as const,
|
|
accessToken: "access-secret",
|
|
refreshToken: "refresh-secret",
|
|
tokenType: "Bearer",
|
|
accessTokenExpiresAt: "2026-08-21T20:00:00.000Z",
|
|
refreshTokenExpiresAt: null,
|
|
scopes: [...GMAIL_CONNECTOR_SCOPES],
|
|
subject,
|
|
companyId,
|
|
instanceId,
|
|
environment: "staging" as const,
|
|
provider: "google" as const,
|
|
profile: "gmail.draft",
|
|
};
|
|
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "gmail.draft");
|
|
const request = vi.fn(async () => Response.json({
|
|
claimId: "clm_test",
|
|
scopes: [...GMAIL_CONNECTOR_SCOPES],
|
|
sealed,
|
|
}));
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
|
|
await expect(connector.claim({
|
|
subject,
|
|
companyId,
|
|
claimId: "clm_test",
|
|
redemptionId: "local-oauth-state-1",
|
|
})).resolves.toEqual(credentials);
|
|
});
|
|
|
|
it("binds non-Gmail credentials and requests to their exact connector profile", async () => {
|
|
const keys = config();
|
|
const profile = "drive.read" as const;
|
|
const credentials = {
|
|
v: 1 as const,
|
|
accessToken: "drive-access-secret",
|
|
refreshToken: "drive-refresh-secret",
|
|
tokenType: "Bearer",
|
|
accessTokenExpiresAt: "2026-08-21T20:00:00.000Z",
|
|
refreshTokenExpiresAt: null,
|
|
scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes],
|
|
subject,
|
|
companyId,
|
|
instanceId,
|
|
environment: "staging" as const,
|
|
provider: "google" as const,
|
|
profile,
|
|
};
|
|
const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, profile);
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body)) as { request: string };
|
|
const [, encodedClaims] = body.request.split(".");
|
|
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
|
|
expect(claims.prf).toBe(profile);
|
|
expect(claims.rid).toBe("local-oauth-state-drive");
|
|
return Response.json({ claimId: "clm_drive", scopes: credentials.scopes, sealed });
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
|
|
await expect(connector.claim({
|
|
subject,
|
|
companyId,
|
|
profile,
|
|
claimId: "clm_drive",
|
|
redemptionId: "local-oauth-state-drive",
|
|
})).resolves.toEqual(credentials);
|
|
});
|
|
|
|
it("accepts only the current capability protocol and known profiles", async () => {
|
|
const keys = config();
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body)) as { request: string };
|
|
const [, encodedClaims] = body.request.split(".");
|
|
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
|
|
expect(claims).toMatchObject({
|
|
iss: instanceId,
|
|
aud: "https://my.example.test/v1/connector/instance-status",
|
|
sub: "instance-capabilities",
|
|
cid: "instance-capabilities",
|
|
env: "staging",
|
|
op: "status",
|
|
});
|
|
expect(claims).not.toHaveProperty("prv");
|
|
expect(claims).not.toHaveProperty("prf");
|
|
expect(claims).not.toHaveProperty("scp");
|
|
return Response.json({
|
|
active: true,
|
|
status: "active",
|
|
profiles: ["gmail.read", "drive.write", "unknown.profile", "gmail.read"],
|
|
});
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
await expect(connector.getCapabilities()).resolves.toEqual(["gmail.read", "drive.write"]);
|
|
});
|
|
|
|
it("fails capability discovery closed for inactive, legacy, malformed, or rejected status responses", async () => {
|
|
const keys = config();
|
|
const responses = [
|
|
Response.json({ active: false, status: "suspended", profiles: ["gmail.read"] }),
|
|
Response.json({ active: true, status: "active" }),
|
|
Response.json({ active: true, status: "active", profiles: "gmail.read" }),
|
|
new Response("detail must not escape", { status: 403 }),
|
|
];
|
|
for (const response of responses) {
|
|
const connector = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => response) as typeof fetch,
|
|
});
|
|
await expect(connector.getCapabilities()).resolves.toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("checks Cloud enrollment status with an instance-only signed request", async () => {
|
|
const keys = config();
|
|
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body)) as { request: string };
|
|
const [, encodedClaims] = body.request.split(".");
|
|
const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8"));
|
|
expect(claims).toMatchObject({
|
|
iss: instanceId,
|
|
aud: "https://my.example.test/v1/connector/instance-status",
|
|
sub: "instance-status",
|
|
cid: "instance-status",
|
|
env: "staging",
|
|
op: "status",
|
|
});
|
|
expect(claims).not.toHaveProperty("prv");
|
|
expect(claims).not.toHaveProperty("prf");
|
|
expect(claims).not.toHaveProperty("scp");
|
|
return Response.json({ active: true, status: "active" });
|
|
});
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
|
|
await expect(connector.getInstanceStatus()).resolves.toBe("active");
|
|
});
|
|
|
|
it("treats an unknown Cloud enrollment as removed without exposing Cloud detail", async () => {
|
|
const keys = config();
|
|
const connector = createPaperclipCloudConnector({
|
|
config: keys.config,
|
|
request: vi.fn(async () => new Response("unknown instance detail", { status: 401 })) as typeof fetch,
|
|
});
|
|
|
|
await expect(connector.getInstanceStatus()).resolves.toBe("removed");
|
|
});
|
|
|
|
it("does not expose a broker response body when a request fails", async () => {
|
|
const keys = config();
|
|
const request = vi.fn(async () => new Response(JSON.stringify({
|
|
error: "provider rejected access-secret refresh-secret",
|
|
}), { status: 502 }));
|
|
const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch });
|
|
|
|
const error = await connector.refresh({ subject, companyId, refreshToken: "refresh-secret" }).catch((caught) => caught);
|
|
expect(error).toBeInstanceOf(PaperclipCloudConnectorError);
|
|
expect(String(error)).not.toContain("access-secret");
|
|
expect(String(error)).not.toContain("refresh-secret");
|
|
expect(request).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("requires an all-or-nothing environment configuration and loopback for HTTP", () => {
|
|
expect(paperclipCloudConnectorConfigFromEnv({})).toBeNull();
|
|
expect(() => paperclipCloudConnectorConfigFromEnv({
|
|
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: instanceId,
|
|
})).toThrowError(/incomplete/);
|
|
expect(() => paperclipCloudConnectorConfigFromEnv({
|
|
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: instanceId,
|
|
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: "key",
|
|
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: "key",
|
|
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "development",
|
|
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "http://my.example.test",
|
|
})).toThrowError(/HTTPS/);
|
|
const legacyError = (() => {
|
|
try {
|
|
paperclipCloudConnectorConfigFromEnv({
|
|
PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: instanceId,
|
|
PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY: "key",
|
|
PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY: "key",
|
|
PAPERCLIP_ID_CONNECTOR_ENVIRONMENT: "development",
|
|
PAPERCLIP_ID_CONNECTOR_BASE_URL: "https://id.paperclip.app",
|
|
});
|
|
return null;
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
})();
|
|
expect(legacyError).toMatchObject({ code: "CONNECTOR_MIGRATION_REQUIRED" });
|
|
expect(String(legacyError)).toContain("incompatible legacy protocol");
|
|
});
|
|
|
|
it("keeps gallery capability discovery available during incomplete enrollment", async () => {
|
|
await expect(paperclipCloudConnectorCapabilitiesFromEnv({
|
|
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app",
|
|
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging",
|
|
})).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
function seal(
|
|
payload: unknown,
|
|
recipientPublicKey: KeyObject,
|
|
purpose: "initial" | "access" | "events",
|
|
configValue: PaperclipCloudConnectorConfig,
|
|
profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code",
|
|
) {
|
|
const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google";
|
|
const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes;
|
|
const ephemeral = generateKeyPairSync("x25519");
|
|
const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string };
|
|
const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string };
|
|
const ephemeralRaw = Buffer.from(ephemeralJwk.x, "base64url");
|
|
const recipientRaw = Buffer.from(recipientJwk.x, "base64url");
|
|
const aad = Buffer.from([
|
|
1,
|
|
"X25519-HKDF-SHA256-A256GCM",
|
|
purpose,
|
|
configValue.instanceId,
|
|
configValue.environment,
|
|
provider,
|
|
profile,
|
|
[...scopes].sort().join(" "),
|
|
].join("\n"));
|
|
const key = Buffer.from(hkdfSync(
|
|
"sha256",
|
|
diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey }),
|
|
Buffer.concat([ephemeralRaw, recipientRaw]),
|
|
profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad,
|
|
32,
|
|
));
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
cipher.setAAD(aad);
|
|
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final(), cipher.getAuthTag()]);
|
|
return {
|
|
v: 1,
|
|
alg: "X25519-HKDF-SHA256-A256GCM",
|
|
purpose,
|
|
provider,
|
|
profile,
|
|
epk: ephemeralJwk.x,
|
|
iv: iv.toString("base64url"),
|
|
ct: ciphertext.toString("base64url"),
|
|
};
|
|
}
|
|
|
|
describe("dedicated App gateway delivery", () => {
|
|
it.each([1, 2])("requires the stack-owned setup protocol version (%s)", async version => {
|
|
const connector = createPaperclipCloudConnector({ config: config().config, request: vi.fn(async () => Response.json({active:true,githubApps:{version}})) as typeof fetch });
|
|
expect(await connector.githubAppsAvailable()).toBe(version === 2);
|
|
});
|
|
it("decrypts opaque webhook bytes sealed before Cloud storage", async () => {
|
|
const keys = config();
|
|
const rawBody = Buffer.from('{ "comment": { "body": "private comment" } }').toString("base64");
|
|
const packet = { v:1,instanceId,environment:"staging",registrationId:"route-a",rawBody,headers:{"x-github-event":"issue_comment","x-github-delivery":"delivery-a","x-hub-signature-256":"signature"} };
|
|
const inner = seal(packet,keys.sealPublicKey,"events",keys.config,"github.bot");
|
|
const outer = seal({v:1,instanceId,environment:"staging",leaseId:"lease-a",events:[{id:"transport-a",provider:"github",event:"gateway_delivery",payload:{githubApp:{registrationId:"route-a",sealed:inner}},bindingIds:["github-app:route-a"]}]},keys.sealPublicKey,"events",keys.config,"github.code");
|
|
const connector = createPaperclipCloudConnector({config:keys.config,request:vi.fn(async()=>Response.json({leaseId:"lease-a",sealed:outer})) as typeof fetch});
|
|
expect((await connector.leaseEvents({subject,companyId}))?.events[0].payload.githubApp).toEqual(packet);
|
|
expect(JSON.stringify(inner)).not.toContain("private comment");
|
|
});
|
|
it("retains an inner envelope swapped from another opaque route for retry", async () => {
|
|
const keys = config();
|
|
const inner = seal({v:1,instanceId,environment:"staging",registrationId:"route-b"},keys.sealPublicKey,"events",keys.config,"github.bot");
|
|
const outer = seal({v:1,instanceId,environment:"staging",leaseId:"lease-a",events:[{id:"transport-a",provider:"github",event:"gateway_delivery",payload:{githubApp:{registrationId:"route-a",sealed:inner}},bindingIds:["github-app:route-a"]}]},keys.sealPublicKey,"events",keys.config,"github.code");
|
|
const connector=createPaperclipCloudConnector({config:keys.config,request:vi.fn(async()=>Response.json({leaseId:"lease-a",sealed:outer})) as typeof fetch});
|
|
await expect(connector.leaseEvents({subject,companyId})).resolves.toMatchObject({
|
|
leaseId: "lease-a", events: [], failedEventIds: ["transport-a"],
|
|
});
|
|
});
|
|
|
|
it.each(["ciphertext", "lease"])("still rejects an invalid outer %s binding", async failure => {
|
|
const keys = config();
|
|
const outer = seal({ v: 1, instanceId, environment: "staging", leaseId: "lease-a", events: [] },
|
|
failure === "ciphertext" ? config().sealPublicKey : keys.sealPublicKey, "events", keys.config, "github.code");
|
|
const connector = createPaperclipCloudConnector({ config: keys.config,
|
|
request: vi.fn(async () => Response.json({ leaseId: failure === "lease" ? "other-lease" : "lease-a", sealed: outer })) as typeof fetch });
|
|
await expect(connector.leaseEvents({ subject, companyId })).rejects.toBeInstanceOf(PaperclipCloudConnectorError);
|
|
});
|
|
|
|
it("isolates a stale-key event while decrypting healthy siblings and legacy events", async () => {
|
|
const keys = config();
|
|
const staleKeys = config();
|
|
const packet = { v: 1, instanceId, environment: "staging", registrationId: "route-a", rawBody: "e30=" };
|
|
const event = (id: string, inner: unknown) => ({ id, provider: "github", event: "gateway_delivery",
|
|
payload: { githubApp: { registrationId: "route-a", sealed: inner } }, bindingIds: ["github-app:route-a"] });
|
|
const outer = seal({ v: 1, instanceId, environment: "staging", leaseId: "lease-a", events: [
|
|
event("stale", seal(packet, staleKeys.sealPublicKey, "events", keys.config, "github.bot")),
|
|
event("healthy", seal(packet, keys.sealPublicKey, "events", keys.config, "github.bot")),
|
|
{ id: "legacy", provider: "github", event: "installation", payload: {}, bindingIds: [] },
|
|
] }, keys.sealPublicKey, "events", keys.config, "github.code");
|
|
const connector = createPaperclipCloudConnector({ config: keys.config,
|
|
request: vi.fn(async () => Response.json({ leaseId: "lease-a", sealed: outer })) as typeof fetch });
|
|
const result = await connector.leaseEvents({ subject, companyId });
|
|
expect(result?.failedEventIds).toEqual(["stale"]);
|
|
expect(result?.events.map(event => event.id)).toEqual(["healthy", "legacy"]);
|
|
expect(result?.events[0]?.payload.githubApp).toEqual(packet);
|
|
expect(JSON.stringify(result)).not.toContain('"ct"');
|
|
});
|
|
});
|