mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-30 21:36:47 +08:00
ed93e7f1ac
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.
132 lines
5.0 KiB
Python
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
|