mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
80 lines
5.0 KiB
TypeScript
80 lines
5.0 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, companies, createDb, heartbeatRunEvents, heartbeatRuns } from "@paperclipai/db";
|
|
import type { PrpEvent } from "../../vendor/paperclip-runner/index.js";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
|
|
import { appendNativeCredentialUpdate } from "./native-credential-updates.js";
|
|
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
|
|
const runnerInstanceId = randomUUID(), normalizedSessionId = randomUUID();
|
|
const event: PrpEvent = {
|
|
schema: "paperclip.prp.event.v3", schemaVersion: 3, eventType: "auth.credentials.updated",
|
|
sourceKind: "runner", sourceInstanceId: runnerInstanceId, sourceEventId: "credential_update_1", sourceSeq: 9,
|
|
runId, normalizedSessionId, priority: 0, emittedAt: "2026-10-15T19:00:00.000Z",
|
|
payload: { schema: "paperclip.auth.credentials.updated.v1", provider: "codex",
|
|
bindingId: "harness-auth", revision: 1, detection: "credential_store" },
|
|
};
|
|
const binding = { companyId, agentId, runnerInstanceId, normalizedSessionId };
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-credential-updates-");
|
|
db = createDb(database.connectionString);
|
|
await db.insert(companies).values({ id: companyId, name: "Credential delivery", issuePrefix: "CRED" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Observer", adapterType: "codex_local" });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", runtimeMode: "native",
|
|
nativeSessionId: normalizedSessionId, runnerInstanceId });
|
|
}, 60_000);
|
|
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
|
|
|
it("persists idle updates idempotently without colliding with normalized events", async () => {
|
|
await appendHeartbeatRunEvent(db, { companyId, agentId, runId, eventType: "item.completed", nativeSource: {
|
|
sourceInstanceId: runnerInstanceId, sourceEventId: "normalized-event", sourceSeq: event.sourceSeq,
|
|
protocolSchemaVersion: 3, canonicalPayload: { normalized: true },
|
|
} });
|
|
await appendNativeCredentialUpdate(db, { ...binding, event });
|
|
await appendNativeCredentialUpdate(db, { ...binding, event: structuredClone(event) });
|
|
const rows = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.find(row => row.eventType === event.eventType)).toMatchObject({
|
|
sourceInstanceId: `${runnerInstanceId}:credentials`, sourceEventId: `credentials:${event.sourceEventId}`,
|
|
payload: { prpEvent: event },
|
|
});
|
|
await expect(appendNativeCredentialUpdate(db, { ...binding, event: {
|
|
...event, payload: { ...event.payload, revision: 2 },
|
|
} })).rejects.toThrow("native_event_replay_conflict");
|
|
});
|
|
|
|
it.each(["companyId", "agentId", "runnerInstanceId", "normalizedSessionId"] as const)(
|
|
"rejects credential updates outside the bound %s", async field => {
|
|
await expect(appendNativeCredentialUpdate(db, { ...binding, [field]: randomUUID(), event }))
|
|
.rejects.toThrow("native_credential_update_binding_mismatch");
|
|
},
|
|
);
|
|
|
|
it("rejects unbound runs and credential payloads with secret fields", async () => {
|
|
await expect(appendNativeCredentialUpdate(db, { ...binding, event: { ...event, runId: randomUUID() } }))
|
|
.rejects.toThrow("native_credential_update_binding_mismatch");
|
|
await expect(appendNativeCredentialUpdate(db, { ...binding, event: { ...event,
|
|
payload: { ...event.payload, access_token: "synthetic-secret" },
|
|
} })).rejects.toThrow("native_credential_update_binding_mismatch");
|
|
});
|
|
|
|
it("retries credential writeback after the event is durably recorded", async () => {
|
|
const synchronizeCredentials = vi.fn().mockRejectedValueOnce(new Error("synthetic writeback failure"))
|
|
.mockResolvedValue(undefined);
|
|
const update = { ...event, sourceSeq: 10, sourceEventId: "credential_update_retry", payload: { ...event.payload, revision: 2 } };
|
|
await expect(appendNativeCredentialUpdate(db, { ...binding, event: update, synchronizeCredentials }))
|
|
.rejects.toThrow("synthetic writeback failure");
|
|
await appendNativeCredentialUpdate(db, { ...binding, event: update, synchronizeCredentials });
|
|
expect(synchronizeCredentials).toHaveBeenCalledTimes(2);
|
|
const rows = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId));
|
|
expect(rows.filter(row => row.sourceEventId === `credentials:${update.sourceEventId}`)).toHaveLength(1);
|
|
synchronizeCredentials.mockClear();
|
|
await expect(appendNativeCredentialUpdate(db, { ...binding, companyId: randomUUID(), event: update, synchronizeCredentials }))
|
|
.rejects.toThrow("binding_mismatch");
|
|
expect(synchronizeCredentials).not.toHaveBeenCalled();
|
|
});
|