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, }); } diff --git a/src/documentEnvelopeIfMatchDigestPreflight.test.ts b/src/documentEnvelopeIfMatchDigestPreflight.test.ts new file mode 100644 index 00000000..f73e23b5 --- /dev/null +++ b/src/documentEnvelopeIfMatchDigestPreflight.test.ts @@ -0,0 +1,128 @@ +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 { + restoreDocumentEnvelopeBytesIfMatch, + 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('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; + 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); + }); +});