From 6bbd5eb3d040c45e4e2636ea687ff26df6c3845e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:30:31 +0900 Subject: [PATCH 1/3] test(reliability): preflight guarded-restore digest capability --- ...mentEnvelopeIfMatchDigestPreflight.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/documentEnvelopeIfMatchDigestPreflight.test.ts diff --git a/src/documentEnvelopeIfMatchDigestPreflight.test.ts b/src/documentEnvelopeIfMatchDigestPreflight.test.ts new file mode 100644 index 00000000..92c25d93 --- /dev/null +++ b/src/documentEnvelopeIfMatchDigestPreflight.test.ts @@ -0,0 +1,105 @@ +import { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDocumentEnvelope } from './documentEnvelope.js'; +import { + DocumentEnvelopeRevisionError, + type DocumentEnvelopeDigestProvider, +} from './documentEnvelopeRevision.js'; +import { restoreDocumentEnvelopeIfMatch } from './documentEnvelopeIfMatch.js'; +import { buildExtensions } from './extensions/kit.js'; + +const ZERO_REVISION_TAG = `"sha256-${'00'.repeat(32)}"`; +const DIGEST_FAILURE = 'Document envelope SHA-256 digest could not be created'; +const openEditors: Editor[] = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions(), + content: '

Current guarded document

', + }); + openEditors.push(editor); + return editor; +} + +function incomingEnvelope() { + return createDocumentEnvelope({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Replacement document' }], + }, + ], + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + document.body.replaceChildren(); +}); + +describe('revision-guarded restore digest capability preflight', () => { + it('rejects an unusable provider before serializing the current document', async () => { + const editor = makeEditor(); + const toJson = vi.spyOn(ProseMirrorNode.prototype, 'toJSON'); + const provider = { digest: 7 } as unknown as DocumentEnvelopeDigestProvider; + + await expect( + restoreDocumentEnvelopeIfMatch( + editor, + ZERO_REVISION_TAG, + incomingEnvelope(), + undefined, + provider, + ), + ).rejects.toThrow(new DocumentEnvelopeRevisionError(DIGEST_FAILURE)); + + expect(toJson).not.toHaveBeenCalled(); + }); + + it('captures one accessor-backed callable for both guarded revisions', async () => { + const editor = makeEditor(); + let digestReads = 0; + let digestCalls = 0; + const provider = {} as DocumentEnvelopeDigestProvider; + Object.defineProperty(provider, 'digest', { + get() { + digestReads += 1; + return function digest( + this: DocumentEnvelopeDigestProvider, + algorithm: 'SHA-256', + source: BufferSource, + ): Promise { + expect(this).toBe(provider); + expect(algorithm).toBe('SHA-256'); + expect(ArrayBuffer.isView(source)).toBe(true); + digestCalls += 1; + return Promise.resolve(new ArrayBuffer(32)); + }; + }, + }); + + const result = await restoreDocumentEnvelopeIfMatch( + editor, + ZERO_REVISION_TAG, + incomingEnvelope(), + undefined, + provider, + ); + + expect(result).toMatchObject({ + status: 'restored', + previousRevision: { strongEntityTag: ZERO_REVISION_TAG }, + revision: { strongEntityTag: ZERO_REVISION_TAG }, + }); + expect(digestReads).toBe(1); + expect(digestCalls).toBe(2); + }); +}); From 042e8d800b567d50888904b0cb8772ba41ed0833 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:34:38 +0900 Subject: [PATCH 2/3] test(reliability): cover guarded byte restore preflight --- ...mentEnvelopeIfMatchDigestPreflight.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/documentEnvelopeIfMatchDigestPreflight.test.ts b/src/documentEnvelopeIfMatchDigestPreflight.test.ts index 92c25d93..f73e23b5 100644 --- a/src/documentEnvelopeIfMatchDigestPreflight.test.ts +++ b/src/documentEnvelopeIfMatchDigestPreflight.test.ts @@ -2,11 +2,15 @@ import { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Editor } from '@tiptap/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDocumentEnvelope } from './documentEnvelope.js'; +import { encodeDocumentEnvelope } from './documentEnvelopeCanonical.js'; import { DocumentEnvelopeRevisionError, type DocumentEnvelopeDigestProvider, } from './documentEnvelopeRevision.js'; -import { restoreDocumentEnvelopeIfMatch } from './documentEnvelopeIfMatch.js'; +import { + restoreDocumentEnvelopeBytesIfMatch, + restoreDocumentEnvelopeIfMatch, +} from './documentEnvelopeIfMatch.js'; import { buildExtensions } from './extensions/kit.js'; const ZERO_REVISION_TAG = `"sha256-${'00'.repeat(32)}"`; @@ -64,6 +68,25 @@ describe('revision-guarded restore digest capability preflight', () => { expect(toJson).not.toHaveBeenCalled(); }); + it('preflights the same capability before current-document work on the byte path', async () => { + const editor = makeEditor(); + const sourceBytes = encodeDocumentEnvelope(incomingEnvelope()); + const toJson = vi.spyOn(ProseMirrorNode.prototype, 'toJSON'); + const provider = { digest: 7 } as unknown as DocumentEnvelopeDigestProvider; + + await expect( + restoreDocumentEnvelopeBytesIfMatch( + editor, + ZERO_REVISION_TAG, + sourceBytes, + undefined, + provider, + ), + ).rejects.toThrow(new DocumentEnvelopeRevisionError(DIGEST_FAILURE)); + + expect(toJson).not.toHaveBeenCalled(); + }); + it('captures one accessor-backed callable for both guarded revisions', async () => { const editor = makeEditor(); let digestReads = 0; From a82c90598ac527ed71e9c32184783f78d1441c63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:38:12 +0900 Subject: [PATCH 3/3] fix(reliability): preflight guarded restore digest --- src/documentEnvelopeIfMatch.ts | 37 +++++++++++++++++----------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/documentEnvelopeIfMatch.ts b/src/documentEnvelopeIfMatch.ts index 0b459afd..fb0a19d0 100644 --- a/src/documentEnvelopeIfMatch.ts +++ b/src/documentEnvelopeIfMatch.ts @@ -6,11 +6,9 @@ import { type DocumentEnvelopeLimits, } from './documentEnvelope.js'; import { - createValidatedDocumentEnvelopeRevisionEvidence, -} from './documentRevisionEvidence.js'; -import { - createValidatedDocumentEnvelopeRevision, + createValidatedDocumentEnvelopeRevisionWithResolvedProvider, DocumentEnvelopeRevisionError, + resolveDocumentEnvelopeDigestProvider, type CwlEditorDocumentRevision, type DocumentEnvelopeDigestProvider, } from './documentEnvelopeRevision.js'; @@ -112,11 +110,12 @@ export function restoreDocumentEnvelopeBytesIfMatch( /** * Execute one guarded restore using a caller-selected envelope preparation path. * - * The function captures and hashes one current envelope, returns that same - * frozen envelope beside every non-null current revision, reconstructs the - * incoming source through the active schema, and hashes the exact normalized - * document that will be applied. It does not inspect the incoming source when - * the expected validator already conflicts. + * The function captures one digest capability before document serialization, + * hashes one current envelope with that capability, returns that same frozen + * envelope beside every non-null current revision, reconstructs the incoming + * source through the active schema, and reuses the captured capability for the + * exact normalized document that will be applied. It does not inspect the + * incoming source when the expected validator already conflicts. */ async function restoreIfMatch( editor: Editor, @@ -131,15 +130,17 @@ async function restoreIfMatch( return createMovedDocumentConflict(); } + const resolvedProvider = resolveDocumentEnvelopeDigestProvider(digestProvider); const capturedDocument = editor.state.doc; const currentEnvelope = createDocumentEnvelope( capturedDocument.toJSON(), limits, ); - const currentRevision = await createValidatedDocumentEnvelopeRevision( - currentEnvelope, - digestProvider, - ); + const currentRevision = + await createValidatedDocumentEnvelopeRevisionWithResolvedProvider( + currentEnvelope, + resolvedProvider, + ); if (hasEditorMoved(editor, capturedDocument)) { return createMovedDocumentConflict(); @@ -161,10 +162,10 @@ async function restoreIfMatch( prepared.documentNode.toJSON(), limits, ); - const nextEvidence = - await createValidatedDocumentEnvelopeRevisionEvidence( + const nextRevision = + await createValidatedDocumentEnvelopeRevisionWithResolvedProvider( appliedEnvelope, - digestProvider, + resolvedProvider, ); if (hasEditorMoved(editor, capturedDocument)) { return createMovedDocumentConflict(); @@ -175,8 +176,8 @@ async function restoreIfMatch( status: 'restored', previousRevision: currentRevision, previousEnvelope: currentEnvelope, - revision: nextEvidence.revision, - envelope: nextEvidence.envelope, + revision: nextRevision, + envelope: appliedEnvelope, }); }