From ed5fb64991ae4aaf5df4ac76f4bd13ca9633aea0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 22 Oct 2026 10:18:00 -0400 Subject: [PATCH] storage-cors-3: preserve newer live versions on late deletion markers --- docs/testing/storage-cors-3-integrity.md | 40 ++++++++++++ js/nostr/videoEventBuffer.js | 13 ++-- ...26-10-22_14-19-00_storage-cors-3-buffer.md | 7 ++ tests/storage-tombstone-buffer.test.mjs | 46 +++++++++++++ .../storage-tombstone-fixture.mjs | 64 +++++++++++++++++++ 5 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 docs/testing/storage-cors-3-integrity.md create mode 100644 test_logs/TEST_LOG_2026-10-22_14-19-00_storage-cors-3-buffer.md create mode 100644 tests/storage-tombstone-buffer.test.mjs create mode 100644 tests/test-helpers/storage-tombstone-fixture.mjs diff --git a/docs/testing/storage-cors-3-integrity.md b/docs/testing/storage-cors-3-integrity.md new file mode 100644 index 00000000..a9b2d102 --- /dev/null +++ b/docs/testing/storage-cors-3-integrity.md @@ -0,0 +1,40 @@ +# storage-cors-3 test integrity + +## Buffer boundary + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-storage3-buffer-newer-orders + given: "strictly newer same-root video and older deletion, in either order or one batch" + when: "actual subscribeVideos buffer ingests and replays the deletion" + then: "newer live version and unrelated content remain active; tombstone timestamp persists" + - id: SCN-storage3-buffer-zombies + given: "two versions and unrelated content" + when: "equal/newer tombstone then late older content then strictly newer content arrive" + then: "older versions stay deleted, unrelated stays active, strictly newer content may return" + - id: SCN-storage3-buffer-author + given: "legacy synthesized roots with equal d-tags from distinct authors" + when: "one author's deletion and late older event arrive" + then: "authoritative client tombstone keys suppress only the intended author's legacy series" + observable_outcomes: + - "real client active/history maps and tombstones after actual subscription transport callbacks" + determinism_controls: + - "synthetic events; explicit awaited EOSE; no sleeps, retries or real transport" + - "fake signature acceptance, metadata hydration and IndexedDB IO only; filtered localStorage remains real" + - "every fixture asserts zero network attempts and closed subscriptions" + anti_cheat_rationale: + prevents: ["unconditional deletion", "no-op deletion", "root-only legacy matching", "over-mocking ingest"] + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +Retained `tests/storage-tombstone-buffer.test.mjs`: old buffer baseline 2pass/3fail. The preliminary legacy fixture used empty roots, which real conversion replaces with distinct event IDs; it was corrected to actual synthesized `LEGACY:author:d` roots before recording baseline, preserving strict author-scoping expectations. Fixed buffer 5/5. Existing buffer6/6 and delete-flow4/4 pass naturally. + +Restored mutants: unconditional deletion fails all3 ordering scenarios; omitted authoritative tombstone fails all5 scenarios. Commands/scripts/logs: `/tmp/storage3-buffer-mutants.py`, `/tmp/storage3-unconditional-marker-delete.log`, `/tmp/storage3-omit-authoritative-tombstone.log`, `/tmp/storage3-buffer-baseline.log`, `/tmp/storage3-buffer-focused.log`, `/tmp/storage3-buffer-existing.log`. No client policy changes: `recordTombstone` remains the sole timestamp-safe active deletion authority; the buffer keeps the guarded-event identity cleanup. + +An initial focused command named nonexistent `tests/video-event-buffer.test.mjs` and failed before tests. Corrected to existing `tests/nostr/videoEventBuffer.test.mjs`; no registration/assertion changes. + +No browser/full/build/live signer/cloud/relay qualification. Coordinator stale-render remains open, read-only and outside this change. diff --git a/js/nostr/videoEventBuffer.js b/js/nostr/videoEventBuffer.js index 46f13e01..40e67c3b 100644 --- a/js/nostr/videoEventBuffer.js +++ b/js/nostr/videoEventBuffer.js @@ -168,16 +168,13 @@ export class VideoEventBuffer { this.client.allEvents.set(evt.id, video); this.client.dirtyEventIds.add(evt.id); - // If it's a "deleted" note, remove from activeMap + // recordTombstone already removes active versions at or before the + // deletion timestamp. A late/replayed marker must preserve newer ones. if (video.deleted) { - if (activeKey) { - if (wasDeletedEvent) { + if (activeKey && !wasDeletedEvent) { + const currentActive = this.client.activeMap.get(activeKey); + if (currentActive?.id === video.id) { this.client.activeMap.delete(activeKey); - } else { - const currentActive = this.client.activeMap.get(activeKey); - if (currentActive?.id === video.id) { - this.client.activeMap.delete(activeKey); - } } } updatedVideos.push(video); diff --git a/test_logs/TEST_LOG_2026-10-22_14-19-00_storage-cors-3-buffer.md b/test_logs/TEST_LOG_2026-10-22_14-19-00_storage-cors-3-buffer.md new file mode 100644 index 00000000..00c8bee5 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_14-19-00_storage-cors-3-buffer.md @@ -0,0 +1,7 @@ +- Baseline merged HEAD89c5494aaa23ae2ce2c1c044d0146c922959cdcf. +- `env -u NODE_TEST_CONTEXT node tests/storage-tombstone-buffer.test.mjs`: old buffer exit1,2pass/3fail; fixed exit0,5pass/0fail. +- `python3 /tmp/storage3-buffer-mutants.py`: two mutants each exit1, failures3 and5; original file restored in finally. Every new scenario rejects at least one mutant. +- `env -u NODE_TEST_CONTEXT node scripts/run-targeted-tests.mjs tests/storage-tombstone-buffer.test.mjs tests/nostr-delete-flow.test.mjs`: exit0,5+4 pass. +- `env -u NODE_TEST_CONTEXT node scripts/run-targeted-tests.mjs tests/nostr/videoEventBuffer.test.mjs`: exit0,6pass. +- Mistyped nonexistent existing test path failed before tests and was corrected; retained in integrity note. +- Lint pending after second scoped service fix. No full/build/browser/product submission while QA owns resource slot. Coordinator stale render unresolved. diff --git a/tests/storage-tombstone-buffer.test.mjs b/tests/storage-tombstone-buffer.test.mjs new file mode 100644 index 00000000..e2c38be1 --- /dev/null +++ b/tests/storage-tombstone-buffer.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tombstoneFixture, videoEvent, ids } from './test-helpers/storage-tombstone-fixture.mjs'; + +// Scenarios and deterministic IO boundaries: docs/testing/storage-cors-3-integrity.md. +for (const order of ['newer-first', 'delete-first', 'same-batch']) { + test(`buffer preserves strictly newer active content: ${order}`, async t => { + const f = tombstoneFixture(t); await f.load(); + const newer = videoEvent('newer', 300); + const deletion = videoEvent('deletion', 200, { deleted: true }); + if (order === 'same-batch') await f.ingest(newer, deletion); + else if (order === 'newer-first') { await f.ingest(newer); await f.ingest(deletion); } + else { await f.ingest(deletion); await f.ingest(newer); } + await f.ingest(videoEvent('unrelated', 100, { root: 'other', pubkey: 'b'.repeat(64) })); + assert.deepEqual(ids(f.client.getActiveVideos()), ['newer', 'unrelated']); + assert.equal(f.client.allEvents.get('newer').deleted, false); + assert.equal(f.client.tombstones.get('ROOT:target'), 200); + // Replayed markers must not erase a newer version either. + await f.ingest(deletion); + assert.deepEqual(ids(f.client.getActiveVideos()), ['newer', 'unrelated']); + }); +} + +test('newer/equal tombstone removes older versions, late zombies stay absent, newer content may return', async t => { + const f = tombstoneFixture(t); await f.load(); + await f.ingest(videoEvent('first', 100), videoEvent('edit', 150), videoEvent('other', 100, { root: 'other' })); + await f.ingest(videoEvent('delete', 150, { deleted: true })); + assert.deepEqual(ids(f.client.getActiveVideos()), ['other']); + assert.equal(f.client.allEvents.get('first').deleted, true); + assert.equal(f.client.allEvents.get('edit').deleted, true); + await f.ingest(videoEvent('late', 120)); + assert.equal(f.client.allEvents.get('late').deleted, true); + assert.deepEqual(ids(f.client.getActiveVideos()), ['other']); + await f.ingest(videoEvent('fresh', 151)); + assert.deepEqual(ids(f.client.getActiveVideos()), ['fresh', 'other']); +}); + +test('legacy d-tag tombstones retain authoritative author scoping', async t => { + const f = tombstoneFixture(t); await f.load(); + await f.ingest(videoEvent('target-legacy', 100, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same' }), videoEvent('other-author', 100, { root: `LEGACY:${'b'.repeat(64)}:same`, dTag: 'same', pubkey: 'b'.repeat(64) })); + await f.ingest(videoEvent('delete-legacy', 200, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same', deleted: true })); + assert.deepEqual(ids(f.client.getActiveVideos()), ['other-author']); + await f.ingest(videoEvent('target-late', 90, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same' })); + assert.deepEqual(ids(f.client.getActiveVideos()), ['other-author']); + assert.equal(f.client.allEvents.get('target-late').deleted, true); +}); diff --git a/tests/test-helpers/storage-tombstone-fixture.mjs b/tests/test-helpers/storage-tombstone-fixture.mjs new file mode 100644 index 00000000..3d1183c2 --- /dev/null +++ b/tests/test-helpers/storage-tombstone-fixture.mjs @@ -0,0 +1,64 @@ +import './setup-localstorage.mjs'; +import assert from 'node:assert/strict'; +import { NostrClient } from '../../js/nostr/client.js'; +import { NostrService } from '../../js/services/nostrService.js'; +import { getVideosMap, setVideosMap, getVideoSubscription, setVideoSubscription } from '../../js/state/appState.js'; + +export const filteredStorageKey = 'bitvid:filtered-videos:v1'; +export const author = 'a'.repeat(64); +export function videoEvent(id, created_at, { root = 'target', deleted = false, pubkey = author, dTag = root, ...payload } = {}) { + return { + id, kind: 30078, pubkey, created_at, + tags: [['t', 'video'], ['d', dTag]], + content: JSON.stringify({ version: 3, title: id, url: 'https://example.invalid/video.mp4', mode: 'live', videoRootId: root, deleted, ...payload }), + }; +} +export const ids = videos => videos.map(video => video.id).sort(); + +export function tombstoneFixture(t, { persisted = [] } = {}) { + const previous = { map: getVideosMap(), sub: getVideoSubscription(), storage: localStorage.getItem(filteredStorageKey) }; + setVideosMap(new Map()); setVideoSubscription(null); + localStorage.setItem(filteredStorageKey, JSON.stringify(persisted)); + let networkAttempts = 0; + const oldFetch = globalThis.fetch; + const oldSocket = globalThis.WebSocket; + globalThis.fetch = () => { networkAttempts++; throw new Error('Unexpected network'); }; + globalThis.WebSocket = class { constructor() { networkAttempts++; throw new Error('Unexpected socket'); } }; + const client = new NostrClient(); + // Virtualize unrelated metadata and IndexedDB IO, keeping all ingest, + // tombstone, active/history, service and filtered-localStorage logic real. + client.saveLocalData = () => {}; + client.populateNip71MetadataForVideos = async () => {}; + client.videoEventVerifier = async events => new Set(events.map(event => event.id)); + const channels = []; + client._subscriptionManager = { subscribe(config) { + const channel = { ...config, closed: false }; + channels.push(channel); + return { close() { channel.closed = true; } }; + } }; + const service = new NostrService(); + service.nostrClient = client; + service.moderationService = null; + service.accessControl = { isHydrated: () => true, ensureReady: async () => {}, canAccess: video => video.title !== 'denied' }; + const notifications = []; + const updates = []; + const off = service.on('videos:updated', detail => updates.push(structuredClone(detail))); + const load = (options = {}) => service.loadVideos({ onVideos: (videos, detail) => notifications.push({ videos: structuredClone(videos), ...detail }), ...options }); + t.after(() => { + service.clearVideoSubscription(); off(); + setVideosMap(previous.map); setVideoSubscription(previous.sub); + if (previous.storage === null) localStorage.removeItem(filteredStorageKey); + else localStorage.setItem(filteredStorageKey, previous.storage); + globalThis.fetch = oldFetch; globalThis.WebSocket = oldSocket; + assert.equal(networkAttempts, 0, 'no real network attempted'); + assert.ok(channels.every(channel => channel.closed), 'subscriptions naturally closed'); + }); + return { client, service, channels, notifications, updates, load, + async ingest(...events) { + const channel = channels.at(-1); + for (const event of events) channel.onEvent(event); + await channel.onEose(); + }, + persisted: () => JSON.parse(localStorage.getItem(filteredStorageKey) || '[]'), + }; +}