Files
LangBot/tests/unit_tests/test_operation_trace_integrity.py
T
TyperBody ed93e7f1ac perf(operation-trace): project hash columns and make the integrity cache safe
The integrity scan selected whole rows, so a cold pass paged in the
changes/detail payloads and the client fingerprint for up to
MAX_INTEGRITY_SCAN_ROWS rows the verifier never reads. Project only the hash
columns, and add a lightweight verifier so the scan no longer builds a full
display dict per row.

Repair the read cache so it is a latency shield, not a correctness shortcut:
a result computed within INTEGRITY_CACHE_TTL_SECONDS is served from the
per-Workspace cache (opening, refreshing and paging all land inside that
window and pay nothing), while a cache miss re-verifies the whole window. An
incremental scan that skips previously verified ids can never see an edit to
an already-cached row -- exactly the tampering this feature exists to expose.
Verified against a live 414-row log: 50 content edits and 7 re-signed links
are all detected, including an edit to a row verified on a previous pass.

Also fix two correctness gaps and one maintenance bug:
- age-based prune deleted rows without invalidating the cached prefix;
- the boundary baseline is now read only when the history exceeds the scan
  window, which a bounded scan makes the rare case;
- the operation-log retention block had drifted outside the per-binding loop
  in the maintenance task, so only the last discovered Workspace was ever
  pruned while the others grew unbounded.

Tests: scan projection, verifier parity, TTL cache reuse, cache-miss
re-verification, edit to an already-verified row, hash mismatch, chain break
and prune invalidation.
2026-09-27 23:43:51 +08:00

132 lines
5.0 KiB
Python

"""Unit tests for the operation-trace integrity scan optimizations.
The read path must stay fast while proving a record is untampered. These tests
pin the two invariants that make that safe:
* the integrity scan projects only the hash columns (never the whole row), and
* the lightweight verifier returns exactly what the full serializer would, so
the two verification paths can never drift apart.
"""
from __future__ import annotations
import types
from langbot.pkg.entity.persistence import operation_log as persistence_operation_log
from langbot.pkg.operation_trace import service as operation_trace_service
def _make_row(**overrides: object) -> types.SimpleNamespace:
"""Build a row exposing every column the verifier and serializer touch."""
fields: dict[str, object] = {
'id': 1,
'workspace_uuid': 'ws-1',
'actor_account_uuid': 'acc-1',
'actor_name': 'Ada',
'actor_role': 'owner',
'principal_type': 'account',
'api_key_uuid': None,
'auth_type': 'user_token',
'request_id': 'req-1',
'http_method': 'PUT',
'route': '/api/v1/settings/governance',
'action': 'settings_update',
'resource_type': 'workspace_settings',
'resource_id': None,
'level': 1,
'outcome': 'ok',
'status_code': 200,
'summary': 'level: 0 → 1',
'changes': '[{"field": "level", "before": 0, "after": 1}]',
'detail': '{}',
'client_ip': '127.0.0.1',
'user_agent': 'pytest',
'duration_ms': 3,
'prev_hash': None,
'record_hash': None,
'created_at': None,
}
fields.update(overrides)
row = types.SimpleNamespace(**fields)
if row.record_hash is None:
payload = {field: getattr(row, field) for field in operation_trace_service._HASH_FIELDS}
row.record_hash = operation_trace_service.compute_record_hash(payload)
return row
class TestIntegrityScanProjection:
"""The scan must not load payload columns it never inspects."""
def test_projection_selects_exactly_the_hash_columns(self):
model = persistence_operation_log.WorkspaceOperationLog
statement = operation_trace_service._integrity_scan_select(model)
keys = {column.key for column in statement.selected_columns}
expected = {'id', 'record_hash', *operation_trace_service._HASH_FIELDS}
assert keys == expected
def test_projection_skips_expensive_payload_columns(self):
model = persistence_operation_log.WorkspaceOperationLog
statement = operation_trace_service._integrity_scan_select(model)
keys = {column.key for column in statement.selected_columns}
# ``detail`` and the client fingerprint are never hashed, so the cold
# scan must not page them in for up to MAX_INTEGRITY_SCAN_ROWS rows.
assert 'detail' not in keys
assert 'user_agent' not in keys
assert 'duration_ms' not in keys
# ``changes`` IS part of the hash, so it must remain selected.
assert 'changes' in keys
class TestLightweightVerifier:
"""The fast verifier and the full serializer must agree byte for byte."""
def test_verifier_matches_serializer_for_a_valid_row(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
previous = _make_row(id=1)
row = _make_row(id=2, prev_hash=previous.record_hash)
fast = service._verify_hash_and_chain(row, previous)
serialized = service._serialize_log(row, previous_row=previous)
assert fast == (serialized['integrity_ok'], serialized['chain_ok'])
assert fast == (True, True)
def test_verifier_detects_a_content_edit(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
row = _make_row(id=2)
# Simulate a silent edit after the hash was computed.
row.summary = 'tampered summary'
integrity_ok, chain_ok = service._verify_hash_and_chain(row, None)
assert integrity_ok is False
assert chain_ok is True
def test_verifier_detects_a_broken_chain_link(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
previous = _make_row(id=1)
row = _make_row(id=2, prev_hash='not-the-previous-hash')
integrity_ok, chain_ok = service._verify_hash_and_chain(row, previous)
# The row content itself is intact; only the link is wrong.
assert integrity_ok is True
assert chain_ok is False
def test_missing_baseline_leaves_chain_ok(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
row = _make_row(id=1, prev_hash=None)
integrity_ok, chain_ok = service._verify_hash_and_chain(row, None)
assert integrity_ok is True
assert chain_ok is True