mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 17:02:28 +00:00
storage-cors-3: preserve newer live versions on late deletion markers
This commit is contained in:
1 parent
89c5494aaa
commit
ed5fb64991
5 files changed
+162
-8
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) || '[]'),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user