mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix: validate confirmed publication receipts before durable writes
This commit is contained in:
@@ -17,7 +17,8 @@ export interface ShareBindingOutbox {
|
||||
complete(task: ShareBindingTask): void;
|
||||
}
|
||||
|
||||
function confirmedReceipt(value: SharePublishReceipt): SharePublishReceipt {
|
||||
/** Validate and copy only the public, confirmed immutable publication fields. */
|
||||
export function confirmedReceipt(value: SharePublishReceipt): SharePublishReceipt {
|
||||
if (!value || [value.filePath, value.slug, value.versionId, value.entryPath].some(v => typeof v !== 'string' || !v.trim())
|
||||
|| !Number.isSafeInteger(value.version) || value.version < 1
|
||||
|| !Number.isSafeInteger(value.publishedAt) || value.publishedAt < 0) throw new Error('SHARE_RECEIPT_REQUIRED');
|
||||
|
||||
@@ -2,8 +2,8 @@ import type Database from 'better-sqlite3';
|
||||
import type { SharePublishResult } from '@open-design/contracts';
|
||||
import type { PublicFilePublication, PublicFilePublicationScope } from './public-file-publication-store.js';
|
||||
import type { RecordPublicFilePublication } from './public-file-publication-recording.js';
|
||||
import type { ShareBindingOutbox } from './share-binding-outbox.js';
|
||||
import type { ShareFileMapping } from './share-file-mapping.js';
|
||||
import { confirmedReceipt, type ShareBindingOutbox } from './share-binding-outbox.js';
|
||||
import { publishedPathForSource, type ShareFileMapping } from './share-file-mapping.js';
|
||||
|
||||
export interface SharePublicationCompletionInput {
|
||||
scope: PublicFilePublicationScope;
|
||||
@@ -34,8 +34,12 @@ export function createSharePublicationCompletion(
|
||||
...(input.result.binding.code ? { code: input.result.binding.code } : {}) } };
|
||||
});
|
||||
return (input: SharePublicationCompletionInput): SharePublishResult => {
|
||||
if (input.result.receipt.filePath !== input.scope.filePath
|
||||
|| input.result.receipt.slug !== input.publication.slug) throw new Error('SHARE_PUBLICATION_RECEIPT_MISMATCH');
|
||||
const receipt = confirmedReceipt(input.result.receipt);
|
||||
if (receipt.filePath !== input.scope.filePath || receipt.slug !== input.publication.slug
|
||||
|| receipt.entryPath !== publishedPathForSource(input.mapping, input.scope.filePath)) {
|
||||
throw new Error('SHARE_PUBLICATION_RECEIPT_MISMATCH');
|
||||
}
|
||||
input = { ...input, result: { ...input.result, receipt } };
|
||||
try { return commit(input); }
|
||||
catch (error) {
|
||||
if (input.result.status === 'published') throw error;
|
||||
|
||||
@@ -10,6 +10,30 @@ const scope = { resourceTeamId: 'w', ownerMemberId: 'o', projectId: 'p', filePat
|
||||
const publication = { slug: 'stable', url: 'https://example.test/artifact/p/stable', fileName: scope.filePath };
|
||||
const receipt = { filePath: scope.filePath, slug: 'stable', version: 2, versionId: 'immutable', publishedAt: 1234, entryPath: 'index.html' };
|
||||
const mapping = createShareFileMapping([{ sourcePath: scope.filePath, file: 'index.html' }]);
|
||||
it.each([
|
||||
{ ...receipt, version: 0 }, { ...receipt, version: 1.5 },
|
||||
{ ...receipt, publishedAt: -1 }, { ...receipt, versionId: '' },
|
||||
{ ...receipt, entryPath: 'wrong.html' },
|
||||
])('rejects invalid confirmed publication before any local write: %j', badReceipt => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
migratePublicFilePublications(db);
|
||||
const store = createSqlitePublicFilePublicationStore(db);
|
||||
store.set(scope, { ...publication, slug: 'previous' });
|
||||
const previous = store.getRevision(scope);
|
||||
const outbox = createShareBindingOutbox(db);
|
||||
let enqueues = 0;
|
||||
const record = createPublicFilePublicationRecorder(db, store, () => { enqueues++; return { enqueued: 0, skippedInbound: 0 }; });
|
||||
const complete = createSharePublicationCompletion(db, record, outbox, true);
|
||||
for (const status of ['published', 'binding_pending'] as const) {
|
||||
const result: SharePublishResult = status === 'published' ? { status, receipt: badReceipt }
|
||||
: { status, receipt: badReceipt, binding: { retrying: false } };
|
||||
expect(() => complete({ scope, publication, mapping, resourceId: 'r', result })).toThrow();
|
||||
expect(store.getRevision(scope)).toEqual(previous); expect(outbox.list()).toEqual([]); expect(enqueues).toBe(0);
|
||||
}
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each(['published', 'pending', 'no-worker', 'binding-write-fails', 'comment-write-fails', 'mismatch'] as const)('atomically completes local publication state: %s', mode => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user