fix: repair chained imports and track blocked evidence dependencies
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1046e-465c-7d43-8c4c-d10f0a1feadd
This commit is contained in:
parent
28b567e588
commit
146f4e1dd3
21 changed files with 869 additions and 95 deletions
|
|
@ -488,3 +488,28 @@ def test_a_stale_mounted_credential_fails_closed(tmp_path):
|
|||
finally:
|
||||
admin.execute(f"DROP ROLE IF EXISTS {role}")
|
||||
admin.close()
|
||||
|
||||
|
||||
def test_instance_reconciliation_counts_only_stored_records_in_window(backend):
|
||||
for event_id, instance, source, tenant in [
|
||||
('a-1', 'a', 'user-engine', 'tenant:friendly:binky'),
|
||||
('a-2', 'a', 'user-engine', 'tenant:friendly:binky'),
|
||||
('b-1', 'b', 'user-engine', 'tenant:friendly:binky'),
|
||||
('sibling-source', 'a', 'other-source', 'tenant:friendly:binky'),
|
||||
('sibling-tenant', 'a', 'user-engine', 'tenant:other'),
|
||||
('numeric-instance', 1, 'user-engine', 'tenant:friendly:binky'),
|
||||
('text-instance', '1', 'user-engine', 'tenant:friendly:binky'),
|
||||
]:
|
||||
event = make_event(event_id, source=source, tenant=tenant,
|
||||
details={'data': {'stream_instance_id': instance, 'stream_sequence': 1}})
|
||||
backend.accept(event, digest(event))
|
||||
backend.accept(event, digest(event))
|
||||
assert backend.event_counts('user-engine', '2020-01-01T00:00:00+00:00',
|
||||
'2030-01-01T00:00:00+00:00', 'tenant:friendly:binky',
|
||||
stream_instance_id='a') == [{'class': 'membership.added', 'count': 2}]
|
||||
assert backend.event_counts('user-engine', '2020-01-01T00:00:00+00:00',
|
||||
'2020-01-02T00:00:00+00:00', 'tenant:friendly:binky',
|
||||
stream_instance_id='a') == []
|
||||
assert backend.event_counts('user-engine', '2020-01-01T00:00:00+00:00',
|
||||
'2030-01-01T00:00:00+00:00', 'tenant:friendly:binky',
|
||||
stream_instance_id='1') == [{'class': 'membership.added', 'count': 1}]
|
||||
|
|
|
|||
|
|
@ -342,3 +342,17 @@ def test_postgres_policy_drops_the_claim_when_the_chain_cannot_be_walked(tmp_pat
|
|||
backend.verify_chain = _unavailable
|
||||
assert backend.retention_policy.tamper_evidence is False
|
||||
assert backend.tamper_evidence_state().reason == "chain_unreadable"
|
||||
|
||||
|
||||
def test_link_order_retains_forks_and_unreachable_rows_as_chain_breaks():
|
||||
from audit_core.integrity import ChainRow, order_chain_rows, verify_rows
|
||||
|
||||
first = ChainRow('first', 'a', chain_link(GENESIS, 'a', 'first'), GENESIS)
|
||||
second = ChainRow('second', 'b', chain_link(first.chain_hash, 'b', 'second'), first.chain_hash)
|
||||
assert verify_rows(order_chain_rows([second, first])).intact
|
||||
fork = ChainRow('fork', 'c', chain_link(first.chain_hash, 'c', 'fork'), first.chain_hash)
|
||||
orphan = ChainRow('orphan', 'd', chain_link('absent', 'd', 'orphan'), 'absent')
|
||||
for extra in (fork, orphan):
|
||||
ordered = order_chain_rows([second, extra, first])
|
||||
assert len(ordered) == 3
|
||||
assert not verify_rows(ordered).intact
|
||||
|
|
|
|||
|
|
@ -168,3 +168,115 @@ def test_empty_source_migrates_cleanly(tmp_path, destination):
|
|||
assert report.events_read == 0
|
||||
assert report.events_written == 0
|
||||
assert report.ok is True
|
||||
|
||||
|
||||
def test_historical_import_extends_existing_chain_and_preserves_attestation(
|
||||
populated_sqlite, destination
|
||||
):
|
||||
path, _ = populated_sqlite
|
||||
event = make_event('native-before-import')
|
||||
destination.accept(event, 'native-hash')
|
||||
attestation = destination.attest_chain()
|
||||
old_link = destination._query(
|
||||
f"SELECT chain_hash FROM {destination._events} WHERE event_id = %s",
|
||||
(event.event_id,),
|
||||
)[0][0]
|
||||
|
||||
report = migrate(path, destination)
|
||||
assert report.ok
|
||||
assert destination.verify_chain(attestation).intact
|
||||
assert destination.verify_chain(attestation).attestation_match
|
||||
assert destination._query(
|
||||
f"SELECT chain_hash FROM {destination._events} WHERE event_id = %s",
|
||||
(event.event_id,),
|
||||
)[0][0] == old_link
|
||||
destination.accept(make_event('native-after-import'), 'after-hash')
|
||||
assert destination.verify_chain(attestation).intact
|
||||
|
||||
|
||||
def test_replaying_side_tables_does_not_duplicate_evidence(populated_sqlite, destination):
|
||||
path, _ = populated_sqlite
|
||||
migrate(path, destination, source_id='stable-source')
|
||||
replay = migrate(path, destination, source_id='stable-source')
|
||||
assert replay.ok
|
||||
assert replay.dead_letters_written == replay.findings_written == 0
|
||||
assert len(destination.dead_letters()) == 1
|
||||
assert destination.secret_findings()[0]['occurrences'] == 1
|
||||
|
||||
|
||||
def test_incremental_finding_import_adds_only_new_occurrences(populated_sqlite, destination):
|
||||
from audit_core.redaction import Finding
|
||||
|
||||
path, source = populated_sqlite
|
||||
migrate(path, destination)
|
||||
source.count_secret_findings(
|
||||
sender='user-engine', source='user-engine', action='membership.added',
|
||||
outcome='redacted', findings=[Finding('data.auth_token', True)],
|
||||
)
|
||||
assert migrate(path, destination).ok
|
||||
assert migrate(path, destination).ok
|
||||
assert destination.secret_findings()[0]['occurrences'] == 2
|
||||
|
||||
|
||||
def test_import_and_native_accept_share_one_chain_lock(populated_sqlite, destination):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
path, _ = populated_sqlite
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [executor.submit(migrate, path, destination) for _ in range(2)]
|
||||
futures.append(executor.submit(destination.accept, make_event('concurrent'), 'native'))
|
||||
results = [future.result(timeout=20) for future in futures]
|
||||
assert all(result.ok for result in results[:2])
|
||||
assert sum(result.events_written for result in results[:2]) == 3
|
||||
assert destination.verify_chain().intact
|
||||
assert destination.verify_chain().events == 4
|
||||
assert len(destination.dead_letters()) == 1
|
||||
assert destination.secret_findings()[0]['occurrences'] == 1
|
||||
|
||||
|
||||
def test_matching_hash_does_not_hide_a_different_acceptance_time(populated_sqlite, destination):
|
||||
path, source = populated_sqlite
|
||||
migrate(path, destination)
|
||||
source.db.execute("UPDATE events SET accepted_at = '2020-01-01T00:00:00+00:00' WHERE event_id = 'legacy-1'")
|
||||
report = migrate(path, destination)
|
||||
assert not report.ok
|
||||
assert any('different historical evidence' in conflict for conflict in report.conflicts)
|
||||
assert destination.verify_chain().intact
|
||||
|
||||
|
||||
def test_failed_import_rolls_back_events_and_replay_receipts(
|
||||
populated_sqlite, destination, monkeypatch
|
||||
):
|
||||
import audit_core.migrate_store as importer
|
||||
import psycopg
|
||||
from audit_core.interface import BackendUnavailableError
|
||||
|
||||
path, _ = populated_sqlite
|
||||
original = importer._copy_findings
|
||||
def fail_after_side_tables(*args):
|
||||
original(*args)
|
||||
raise psycopg.OperationalError('injected import failure')
|
||||
monkeypatch.setattr(importer, '_copy_findings', fail_after_side_tables)
|
||||
with pytest.raises(BackendUnavailableError):
|
||||
migrate(path, destination)
|
||||
assert destination.verify_chain().events == 0
|
||||
assert destination.dead_letters() == []
|
||||
assert destination.secret_findings() == []
|
||||
assert destination._query(f'SELECT count(*) FROM "{destination.schema}".import_receipts', ())[0][0] == 0
|
||||
monkeypatch.setattr(importer, '_copy_findings', original)
|
||||
assert migrate(path, destination).ok
|
||||
|
||||
|
||||
def test_source_relocation_with_stable_id_preserves_side_table_replay(
|
||||
populated_sqlite, destination, tmp_path
|
||||
):
|
||||
path, _ = populated_sqlite
|
||||
# Backup captures WAL-backed source data too.
|
||||
import sqlite3
|
||||
moved = str(tmp_path / 'moved.db')
|
||||
with sqlite3.connect(path) as source, sqlite3.connect(moved) as copy:
|
||||
source.backup(copy)
|
||||
migrate(path, destination, source_id='same-source')
|
||||
replay = migrate(moved, destination, source_id='same-source')
|
||||
assert replay.ok
|
||||
assert replay.events_written == replay.dead_letters_written == replay.findings_written == 0
|
||||
|
|
|
|||
|
|
@ -270,3 +270,28 @@ def test_the_overlay_may_shorten_a_heartbeat_interval_but_not_lengthen_it(tmp_pa
|
|||
"AUDIT_CORE_SENDERS": json.dumps(base),
|
||||
"AUDIT_CORE_SENDERS_SCOPE_PATH": str(scope),
|
||||
})
|
||||
|
||||
|
||||
def test_reconciliation_separates_process_instances_and_keeps_scope_checks(app):
|
||||
for event_id, instance in [('a-1', 'a'), ('a-2', 'a'), ('b-1', 'b')]:
|
||||
emit(app, event_id, 'audit.deny', data={'stream_instance_id': instance, 'stream_sequence': 1})
|
||||
# A delivery retry remains one accepted event.
|
||||
assert emit(app, 'a-1', 'audit.deny', data={'stream_instance_id': 'a', 'stream_sequence': 1})[0].startswith('200')
|
||||
base = ('/v1/reconciliation?source=approval-engine&tenant=tenant:platform'
|
||||
'&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00')
|
||||
status, body = invoke(app, base + '&stream_instance_id=a')
|
||||
assert status.startswith('200')
|
||||
assert body['stream_instance_id'] == 'a'
|
||||
assert body['counts'] == [{'class': 'audit.deny', 'count': 2}]
|
||||
assert 'stream_sequence' not in body
|
||||
assert invoke(app, base + '&stream_instance_id=absent')[1]['counts'] == []
|
||||
assert invoke(app, base.replace('tenant:platform', 'tenant:other') + '&stream_instance_id=a')[0].startswith('403')
|
||||
assert invoke(app, base.replace('source=approval-engine', 'source=other') + '&stream_instance_id=a')[0].startswith('403')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('suffix', ['&stream_instance_id=', '&stream_instance_id=a&stream_instance_id=b'])
|
||||
def test_reconciliation_refuses_ambiguous_instance_filters(app, suffix):
|
||||
status, body = invoke(app, '/v1/reconciliation?source=approval-engine&tenant=tenant:platform'
|
||||
'&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00' + suffix)
|
||||
assert status.startswith('400')
|
||||
assert body['error'] == 'invalid_stream_instance_id'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue