Files
waggle-os/packages/hive-mind-core/tests/mind/suppression.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

191 lines
8.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { MindDB } from '../../src/mind/db.js';
import { RawArchive } from '../../src/mind/raw-archive.js';
import { FrameStore } from '../../src/mind/frames.js';
import { SessionStore } from '../../src/mind/sessions.js';
import { MindErasure } from '../../src/mind/erasure.js';
import { SuppressionStore } from '../../src/mind/suppression.js';
// The erased-subject suppression list (#7 Art.17 "sticky erasure"): a (source,
// source_ref) that was erased must not re-materialize on re-import. Keyed on the
// SUBJECT pair only — no content, no hash (a content-keyed tombstone would
// reintroduce the re-identification vector the archive_uid rotation removed).
describe('SuppressionStore', () => {
let db: MindDB;
let sup: SuppressionStore;
beforeEach(() => {
db = new MindDB(':memory:');
sup = new SuppressionStore(db);
});
afterEach(() => db.close());
it('records a subject and reports it suppressed', () => {
expect(sup.isSuppressed('chatgpt', 'thread-42')).toBe(false);
sup.record('chatgpt', 'thread-42', 'gdpr-art17');
expect(sup.isSuppressed('chatgpt', 'thread-42')).toBe(true);
});
it('scopes suppression to the exact (source, source_ref) pair', () => {
sup.record('chatgpt', 'thread-42', 'r');
expect(sup.isSuppressed('chatgpt', 'thread-99')).toBe(false); // other ref
expect(sup.isSuppressed('claude', 'thread-42')).toBe(false); // other source
});
it('is idempotent on re-record (UNIQUE(source, source_ref))', () => {
sup.record('chatgpt', 'thread-42', 'first');
sup.record('chatgpt', 'thread-42', 'second');
expect(sup.list()).toHaveLength(1);
expect(sup.isSuppressed('chatgpt', 'thread-42')).toBe(true);
});
it('unsuppress removes the row and reports whether one was removed (re-consent)', () => {
sup.record('chatgpt', 'thread-42', 'r');
expect(sup.unsuppress('chatgpt', 'thread-42')).toBe(true);
expect(sup.isSuppressed('chatgpt', 'thread-42')).toBe(false);
expect(sup.unsuppress('chatgpt', 'thread-42')).toBe(false); // already gone
});
it('list returns each suppressed subject with source, sourceRef, erasedAt, reason', () => {
sup.record('chatgpt', 'thread-42', 'gdpr-art17');
const rows = sup.list();
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('chatgpt');
expect(rows[0].sourceRef).toBe('thread-42');
expect(rows[0].reason).toBe('gdpr-art17');
expect(typeof rows[0].erasedAt).toBe('string');
expect(rows[0].erasedAt.length).toBeGreaterThan(0);
});
it('isSuppressed FAILS CLOSED — a read error is treated as suppressed (Art.17 wins)', () => {
// A genuine read failure means the DB is broken and the follow-on import
// INSERT fails anyway; on the ambiguous item we must NOT re-materialize.
db.getDatabase().exec('DROP TABLE erased_subjects');
expect(sup.isSuppressed('chatgpt', 'thread-42')).toBe(true);
});
it('checkSuppressed distinguishes a genuine MATCH from a fail-closed read ERROR', () => {
// not suppressed → { suppressed: false }
expect(sup.checkSuppressed('chatgpt', 'thread-1')).toEqual({ suppressed: false });
// genuine match → reason 'match'
sup.record('chatgpt', 'thread-1', 'gdpr');
expect(sup.checkSuppressed('chatgpt', 'thread-1')).toEqual({ suppressed: true, reason: 'match' });
// fail-closed error → still suppressed, but tagged 'error' with a message, so a
// caller can report "could not verify" separately instead of "confirmed erased".
db.getDatabase().exec('DROP TABLE erased_subjects');
const res = sup.checkSuppressed('chatgpt', 'thread-1');
expect(res.suppressed).toBe(true);
expect(res).toMatchObject({ suppressed: true, reason: 'error' });
if (res.suppressed && res.reason === 'error') {
expect(typeof res.error).toBe('string');
expect(res.error.length).toBeGreaterThan(0);
}
// isSuppressed stays a fail-closed boolean over the very same check.
expect(sup.isSuppressed('chatgpt', 'thread-1')).toBe(true);
});
});
describe('erased_subjects migration + backfill', () => {
it('backfills from pre-existing erased raw_archive rows (one-time upgrade)', () => {
const db = new MindDB(':memory:');
const archive = new RawArchive(db);
const { archiveUid } = archive.append({ source: 'chatgpt', sourceRef: 'thread-7', content: 'secret PII' });
archive.erase(archiveUid, 'gdpr'); // raw_archive redacted; RawArchive does NOT record suppression
const sup = new SuppressionStore(db);
expect(sup.isSuppressed('chatgpt', 'thread-7')).toBe(false); // not yet backfilled
db.backfillErasedSubjects(true); // force the one-time upgrade backfill
expect(sup.isSuppressed('chatgpt', 'thread-7')).toBe(true);
db.close();
});
it('runs the backfill automatically on DB open (real upgrade path)', () => {
const file = path.join(os.tmpdir(), `sup-mig-${process.pid}-${Date.now()}.db`);
try {
const db1 = new MindDB(file);
const archive = new RawArchive(db1);
const { archiveUid } = archive.append({ source: 'claude', sourceRef: 'conv-9', content: 'more PII' });
archive.erase(archiveUid, 'gdpr');
// Simulate a DB reaching this code for the FIRST time: an erased row exists,
// the backfill sentinel is not yet set, erased_subjects is empty.
db1.getDatabase().prepare("DELETE FROM meta WHERE key = 'erased_subjects_backfilled'").run();
db1.getDatabase().exec('DELETE FROM erased_subjects');
db1.close();
const db2 = new MindDB(file); // reopen → runMigrations → backfill runs once
expect(new SuppressionStore(db2).isSuppressed('claude', 'conv-9')).toBe(true);
db2.close();
} finally {
for (const sfx of ['', '-wal', '-shm']) { try { fs.unlinkSync(file + sfx); } catch { /* ignore */ } }
}
});
});
describe('RawArchive.append honors suppression (substrate-intrinsic backstop)', () => {
let db: MindDB;
let archive: RawArchive;
let sup: SuppressionStore;
beforeEach(() => {
db = new MindDB(':memory:');
archive = new RawArchive(db);
sup = new SuppressionStore(db);
});
afterEach(() => db.close());
it('skips the INSERT for a suppressed subject and reports created:false', () => {
sup.record('chatgpt', 'thread-5', 'gdpr');
const res = archive.append({ source: 'chatgpt', sourceRef: 'thread-5', content: 'must NOT re-materialize' });
expect(res.created).toBe(false);
expect(archive.count()).toBe(0);
});
it('still appends a subject that is not suppressed', () => {
const res = archive.append({ source: 'chatgpt', sourceRef: 'thread-6', content: 'fine to keep' });
expect(res.created).toBe(true);
expect(archive.count()).toBe(1);
});
});
describe('MindErasure captures suppression at erase time', () => {
let db: MindDB;
let erasure: MindErasure;
let sup: SuppressionStore;
beforeEach(() => {
db = new MindDB(':memory:');
new SessionStore(db).ensure('g', 'g', 'test');
erasure = new MindErasure(db);
sup = new SuppressionStore(db);
});
afterEach(() => db.close());
it('eraseBySourceRef records the subject (even when nothing currently matches)', () => {
expect(sup.isSuppressed('chatgpt', 'thread-1')).toBe(false);
erasure.eraseBySourceRef('chatgpt', 'thread-1', 'gdpr');
expect(sup.isSuppressed('chatgpt', 'thread-1')).toBe(true); // future re-import is blocked
});
it('eraseFrameComplete records each resolved subject', () => {
const archive = new RawArchive(db);
const frames = new FrameStore(db);
const { archiveUid } = archive.append({ source: 'claude', sourceRef: 'conv-2', content: 'PII body' });
const f = frames.createIFrame('g', '[Harvest:claude] summary', 'normal', 'import');
frames.setMetadata(f.id, JSON.stringify({ archiveUids: [archiveUid], sourceId: 'conv-2' }));
erasure.eraseFrameComplete(f.id, 'gdpr');
expect(sup.isSuppressed('claude', 'conv-2')).toBe(true);
});
it('a rolled-back erase leaves NO suppression row (recorded inside the txn)', () => {
const outer = db.getDatabase().transaction(() => {
erasure.eraseBySourceRef('chatgpt', 'thread-x', 'gdpr'); // nested savepoint
throw new Error('boom'); // roll the whole outer txn back
});
expect(() => outer()).toThrow('boom');
expect(sup.isSuppressed('chatgpt', 'thread-x')).toBe(false); // suppression rolled back too
});
});