From c1a103a6e56c147d4c714f7e4794690e8544d479 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Sun, 23 Aug 2026 18:13:27 +0200 Subject: [PATCH] test(documents): end-to-end sign-off against a real database and real keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Sign-Off had no integration test. Every existing test covers one piece of it in isolation, and the chain those pieces form — create, draft, publish, freeze the signer set, sign, reach the threshold, export a proof, verify it — had never been executed. That is exactly where this feature's worst bug lived. `sign()` rejected every valid signature for as long as the feature existed, and its unit test did not catch it because it mocked `checkSignature` — the one thing it was meant to prove. So this test signs with a real key, through the real helper, against a real Postgres. The wallet is seeded with an address whose mnemonic the test holds, which is what makes a genuine CIP-8 signature possible instead of a fixture pretending to be one. Three cases: - The whole lifecycle, ending in `verifyProof({ valid: true })` through the public procedure. It also asserts the published contentHash equals sha256 of the bytes the server stored, which is the claim that makes "what you sign is what was published" true rather than asserted, and that publishing is refused outright when server storage is off. - A forged payload is rejected and the version stays InReview. - A new version supersedes the signed one and resets approvals to zero. Two bugs found writing it were mine, not the product's, and both are worth recording. `publishDraft` creates a version without bumping the draft revision, so my second save presented a stale expectedRevision. And the first tamper attempt edited prose in the body — which proved nothing, because the payload binds the content HASH, not the text. It now tampers with the hash itself, and asserts the string actually changed so the test cannot silently go vacuous again. Runs in the existing trpc-integration-tests workflow. 88 integration tests pass, 1161 unit tests pass, tsc clean, next build exit 0. Co-Authored-By: Claude Opus 5 --- src/__tests__/trpc/documentLifecycle.test.ts | 284 +++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 src/__tests__/trpc/documentLifecycle.test.ts diff --git a/src/__tests__/trpc/documentLifecycle.test.ts b/src/__tests__/trpc/documentLifecycle.test.ts new file mode 100644 index 00000000..57445719 --- /dev/null +++ b/src/__tests__/trpc/documentLifecycle.test.ts @@ -0,0 +1,284 @@ +import { + afterEach, + beforeAll, + describe, + expect, + it, + jest, +} from "@jest/globals"; + +import { cleanupFixtures, seedWallet } from "./fixtures"; +import { makeWalletCtx } from "./helpers"; + +/** + * The document sign-off feature, end to end, against a real database and with + * real cryptography. + * + * Everything else that covers this feature is a unit test over one piece of it. + * Nothing exercised the actual chain — create, draft, publish, freeze the signer + * set, sign, reach the threshold, export a proof, verify it — and that chain is + * where the interesting failures live. The bug that made `sign()` reject every + * valid signature survived precisely because the test around it mocked the + * verification it was meant to prove. + * + * So this signs with a real key through the real helper. The wallet is seeded + * with an address whose mnemonic the test holds, which is what makes a genuine + * CIP-8 signature possible rather than a fixture pretending to be one. + */ + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +const HAVE_DB = !!process.env.DATABASE_URL; +const describeWithDb = HAVE_DB ? describe : describe.skip; + +let createCaller: typeof import("@/server/api/root").createCaller; +let db: typeof import("@/server/db").db; +let sign: typeof import("@/utils/signing").sign; +let sha256Hex: typeof import("@/lib/documents/payload").sha256Hex; + +// Deterministic throwaway key. Never used for anything but this test. +const MNEMONIC = Array(24).fill("solution"); + +let wallet: import("@meshsdk/core").MeshWallet; +let ADDRESS: string; +let walletIds: string[] = []; + +const BODY = "# Supply agreement\n\nThe ceiling is 50000 EUR.\n"; + +describeWithDb("document sign-off, end to end", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + ({ db } = await import("@/server/db")); + ({ sign } = await import("@/utils/signing")); + ({ sha256Hex } = await import("@/lib/documents/payload")); + + const { MeshWallet } = await import("@meshsdk/core"); + wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: MNEMONIC }, + }); + await wallet.init(); + ADDRESS = + (await wallet.getUsedAddresses())[0] ?? + (await wallet.getUnusedAddresses())[0]!; + }, 60_000); + + afterEach(async () => { + for (const walletId of walletIds) { + // Document has no foreign key to Wallet, so cleanupFixtures cannot reach + // it — everything below Document cascades from here. + await db.document.deleteMany({ where: { walletId } }); + await cleanupFixtures(db, { walletId }); + } + walletIds = []; + }); + + async function seed() { + const { walletId } = await seedWallet(db, ADDRESS); + walletIds.push(walletId); + // Spread the helper and put the real client back on top rather than casting. + // makeWalletCtx widens `db` to a union so it can also serve mock-db tests, + // and `as any` — the pattern the neighbouring suites use — would discard the + // caller's whole type, which is most of what this test is checking. + return { walletId, caller: createCaller({ ...makeWalletCtx(ADDRESS), db }) }; + } + + it("carries a draft through to a proof a third party can verify", async () => { + const { walletId, caller } = await seed(); + + // --- create ------------------------------------------------------------- + const document = await caller.document.createDocument({ + walletId, + title: "Supply agreement", + documentType: "Legal", + }); + expect(document.status).toBe("Draft"); + + // --- draft, then publish ------------------------------------------------ + // Server storage is opt-in; publishing without it must be refused, because + // the server would have no bytes to hash. + await expect( + caller.document.publishDraft({ documentId: document.id }), + ).rejects.toThrow(/not stored on the server/i); + + const saved = await caller.document.saveDraft({ + documentId: document.id, + body: BODY, + storeBody: true, + }); + expect(saved.revision).toBe(1); + + const version = await caller.document.publishDraft({ + documentId: document.id, + }); + + // The hash is the SERVER's, over the bytes it stored — not something the + // client asserted. That is what makes "what you sign is what was + // published" true rather than claimed. + expect(version.contentHash).toBe(sha256Hex(Buffer.from(BODY, "utf8"))); + expect(version.storageMode).toBe("inline"); + expect(version.versionNumber).toBe(1); + + // --- freeze the signer set --------------------------------------------- + await caller.document.startReview({ versionId: version.id }); + const snapshot = await db.documentSignerSnapshot.findUnique({ + where: { versionId: version.id }, + }); + expect(snapshot?.signersAddresses).toEqual([ADDRESS]); + expect(snapshot?.requiredSigners).toBe(1); + + // --- sign, exactly the way the review page does ------------------------- + const fresh = await caller.document.getVersionForReview({ + versionId: version.id, + action: "approve", + signerAddress: ADDRESS, + }); + expect(fresh.payloadToSign).toBeTruthy(); + + // The real helper and a real key. Before the signature fix this line threw + // "Signature failed verification" for every honest signature. + const signature = await sign(fresh.payloadToSign!, wallet, 0, ADDRESS); + + await caller.document.submitSignerAction({ + versionId: version.id, + action: "approve", + signerAddress: ADDRESS, + signedAt: fresh.payload!.signedAt, + payload: fresh.payloadToSign!, + signature: signature.signature, + signatureKey: signature.key, + }); + + // --- the threshold resolves -------------------------------------------- + const decided = await db.documentVersion.findUnique({ + where: { id: version.id }, + }); + expect(decided?.status).toBe("Approved"); + expect(decided?.decidedAt).toBeTruthy(); + + // --- and the proof verifies, through the public procedure --------------- + const proof = await caller.document.exportProof({ versionId: version.id }); + const verified = await caller.document.verifyProof({ + proof, + expectedContentHash: version.contentHash, + }); + expect(verified.valid).toBe(true); + }, 120_000); + + it("refuses a signature over bytes the server did not build", async () => { + const { walletId, caller } = await seed(); + const document = await caller.document.createDocument({ + walletId, + title: "Tampered", + }); + await caller.document.saveDraft({ + documentId: document.id, + body: BODY, + storeBody: true, + }); + const version = await caller.document.publishDraft({ + documentId: document.id, + }); + await caller.document.startReview({ versionId: version.id }); + + const fresh = await caller.document.getVersionForReview({ + versionId: version.id, + action: "approve", + signerAddress: ADDRESS, + }); + + // Sign a statement of our own choosing, then present it as the review. The + // server rebuilds the payload from its own records and byte-compares, so + // the substitution must be caught before the signature is even checked. + // + // Tamper with the content hash rather than the prose: the payload binds the + // DIGEST, not the body, so altering body text would leave the signed bytes + // identical and prove nothing. + const forged = fresh.payloadToSign!.replace( + version.contentHash, + "b".repeat(64), + ); + expect(forged).not.toBe(fresh.payloadToSign); + const signature = await sign(forged, wallet, 0, ADDRESS); + + await expect( + caller.document.submitSignerAction({ + versionId: version.id, + action: "approve", + signerAddress: ADDRESS, + signedAt: fresh.payload!.signedAt, + payload: forged, + signature: signature.signature, + signatureKey: signature.key, + }), + ).rejects.toThrow(); + + const still = await db.documentVersion.findUnique({ + where: { id: version.id }, + }); + expect(still?.status).toBe("InReview"); + }, 120_000); + + it("resets approvals when a new version supersedes a signed one", async () => { + const { walletId, caller } = await seed(); + const document = await caller.document.createDocument({ + walletId, + title: "Reissued", + }); + await caller.document.saveDraft({ + documentId: document.id, + body: BODY, + storeBody: true, + }); + const v1 = await caller.document.publishDraft({ documentId: document.id }); + await caller.document.startReview({ versionId: v1.id }); + + const fresh = await caller.document.getVersionForReview({ + versionId: v1.id, + action: "approve", + signerAddress: ADDRESS, + }); + const signature = await sign(fresh.payloadToSign!, wallet, 0, ADDRESS); + await caller.document.submitSignerAction({ + versionId: v1.id, + action: "approve", + signerAddress: ADDRESS, + signedAt: fresh.payload!.signedAt, + payload: fresh.payloadToSign!, + signature: signature.signature, + signatureKey: signature.key, + }); + expect( + (await db.documentVersion.findUnique({ where: { id: v1.id } }))?.status, + ).toBe("Approved"); + + // A new version is a new agreement. Approval binds the content hash, not + // the title, so the old signature must not carry over. + await caller.document.saveDraft({ + documentId: document.id, + body: `${BODY}\nAmended.\n`, + expectedRevision: 1, + }); + const v2 = await caller.document.publishDraft({ documentId: document.id }); + + expect(v2.versionNumber).toBe(2); + expect(v2.contentHash).not.toBe(v1.contentHash); + expect( + (await db.documentVersion.findUnique({ where: { id: v1.id } }))?.status, + ).toBe("Superseded"); + expect(await db.documentReview.count({ where: { versionId: v2.id } })).toBe( + 0, + ); + }, 120_000); +});