Files
cmbuyer/client/tests/localstate/test_store.py
T

787 lines
38 KiB
Python

from __future__ import annotations
import base64
from dataclasses import replace
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest import mock
from cmbuyer_client.core.errors import ProtectionError, StateError
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.localstate.store import LocalStateStore, _read_stable_png
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
from tests.remote.test_task_source import DEVICE_ID
DEVICE_TOKEN = "b" * 64
PROFILE = "default"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class FakeProtector:
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
key = hashlib.sha256(("test:" + purpose).encode()).digest()
encrypted = bytes(value ^ key[index % len(key)] for index, value in enumerate(plaintext))
return hmac.digest(key, plaintext, "sha256") + encrypted
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
if len(ciphertext) < 33:
raise ProtectionError("fake_unprotect_failed")
key = hashlib.sha256(("test:" + purpose).encode()).digest()
plaintext = bytes(value ^ key[index % len(key)] for index, value in enumerate(ciphertext[32:]))
if not hmac.compare_digest(ciphertext[:32], hmac.digest(key, plaintext, "sha256")):
raise ProtectionError("fake_unprotect_failed")
return plaintext
def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
return ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
device_id,
"D:/Portable/adb/adb.exe",
"192.168.0.173:5555",
"wifi",
)
class LocalStateStoreTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.database = Path(self.directory.name) / "state" / "client-state.sqlite3"
self.clock = [datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc)]
self.store = self.new_store()
self.store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
def tearDown(self) -> None:
self.directory.cleanup()
def new_store(self) -> LocalStateStore:
return LocalStateStore(self.database, FakeProtector(), now=lambda: self.clock[0])
def test_claim_unknown_restart_stop_and_atomic_success(self) -> None:
session = self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.assertEqual(self.store.prepare_claim(PROFILE), request)
# 模拟 HTTP 已成功但进程在落库前退出;重启只能恢复原 key。
restarted = self.new_store()
self.assertEqual(restarted.recovery_snapshot(PROFILE).pending_claim, request)
self.assertEqual(restarted.prepare_claim(PROFILE), request)
# stop 不能丢弃飞行中的 claim,返回结果仍必须落 active。
restarted.request_stop(PROFILE)
claimed = ClaimedTask.from_wire(claim_wire())
restarted.commit_claim_success(PROFILE, request, claimed)
snapshot = self.new_store().recovery_snapshot(PROFILE)
self.assertFalse(snapshot.session.accept_new)
self.assertIsNone(snapshot.pending_claim)
self.assertEqual(snapshot.active_claim.task.id, TASK_ID)
self.assertEqual(snapshot.active_claim.attempt.claim_token.value, TOKEN)
with self.assertRaises(StateError):
restarted.prepare_claim(PROFILE)
def test_relative_database_path_is_frozen_across_cwd_changes(self) -> None:
original_cwd = Path.cwd()
first = Path(self.directory.name) / "first-cwd"
second = Path(self.directory.name) / "second-cwd"
first.mkdir()
second.mkdir()
try:
os.chdir(first)
relative_store = LocalStateStore(Path("relative/state.sqlite3"), FakeProtector(), now=lambda: self.clock[0])
relative_store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
frozen_path = relative_store.database_path
os.chdir(second)
self.assertEqual(relative_store.load_profile(PROFILE).settings, settings())
self.assertEqual(relative_store.database_path, frozen_path)
self.assertTrue(frozen_path.is_absolute())
self.assertFalse((second / "relative" / "state.sqlite3").exists())
finally:
os.chdir(original_cwd)
def test_empty_allows_new_key_but_terminal_does_not(self) -> None:
self.store.start_or_resume_polling(PROFILE)
first = self.store.prepare_claim(PROFILE)
self.store.commit_claim_empty(PROFILE, first)
second = self.store.prepare_claim(PROFILE)
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
self.store.mark_claim_terminal(PROFILE, second, "MANUAL")
with self.assertRaises(StateError):
self.store.prepare_claim(PROFILE)
def test_idle_profile_identity_change_creates_new_session_and_key(self) -> None:
first_session = self.store.start_or_resume_polling(PROFILE)
first = self.store.prepare_claim(PROFILE)
self.store.commit_claim_empty(PROFILE, first)
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
self.assertIsNone(self.store.recovery_snapshot(PROFILE).session)
second_session = self.store.start_or_resume_polling(PROFILE)
second = self.store.prepare_claim(PROFILE)
self.assertNotEqual(first_session.session_id, second_session.session_id)
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
self.assertEqual(second.session_id, second_session.session_id)
def test_device_change_requires_new_token_because_cipher_is_identity_bound(self) -> None:
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaisesRegex(StateError, "device_token_required_for_device_change"):
self.store.save_profile(settings(other_id), None)
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
def test_tampered_profile_session_mismatch_is_not_auto_repaired(self) -> None:
self.store.start_or_resume_polling(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_id=? WHERE profile_id=?",
("f3c9f507-7473-4fa6-8d71-8786c34c6301", PROFILE),
)
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
self.store.start_or_resume_polling(PROFILE)
def test_pending_and_active_freeze_service_device_and_token_identity(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.store.save_profile(settings(), SecretToken("c" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaises(StateError):
self.store.save_profile(settings(other_id), None)
base = settings()
changed_profiles = (
replace(base, adb_path="D:/other/adb.exe"),
replace(base, adb_serial="usb-other"),
replace(base, transport="usb"),
replace(base, poll_interval_seconds=16),
replace(base, failure_threshold=4),
replace(base, http_timeout_seconds=11),
replace(base, step_timeout_seconds=46),
)
for changed in changed_profiles:
with self.subTest(changed=changed), self.assertRaises(StateError):
self.store.save_profile(changed, None)
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
self.store.save_profile(settings(), SecretToken("d" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "d" * 64)
def test_renew_reuses_exact_payload_across_restart_and_cas_updates_only_lease(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
self.clock[0] = datetime(2026, 8, 4, 9, 7, tzinfo=timezone.utc)
recovered = self.new_store().prepare_renew(PROFILE)
self.assertEqual(recovered.renew_request_id, request.renew_request_id)
self.assertEqual(recovered.to_wire(), request.to_wire())
result = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")
self.store.commit_renew_success(PROFILE, request, result)
active = self.new_store().active_claim(PROFILE)
self.assertEqual(active.attempt.claim_generation, 1)
self.assertEqual(active.attempt.claim_token.value, TOKEN)
self.assertEqual(active.attempt.lease_expires_at, "2026-08-04T09:06:00Z")
with self.assertRaises(StateError):
self.store.prepare_renew(PROFILE)
def test_evidence_slot_is_persisted_before_send_and_rejects_file_change(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "explicit.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
recovered = self.new_store().prepare_or_resume_evidence(PROFILE, asset)
self.assertEqual(recovered.upload_key, upload.upload_key)
self.assertEqual(recovered.content, upload.content)
image.write_bytes(PNG + b"changed")
with self.assertRaises(StateError):
self.store.prepare_or_resume_evidence(PROFILE, asset)
image.write_bytes(PNG)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
TASK_ID,
ATTEMPT_ID,
"SKU_PANEL_GATE_1",
"INTERNAL_RAW",
upload.sha256,
len(PNG),
"image/png",
1,
1,
"2026-08-04T09:01:00Z",
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
image.write_bytes(PNG + b"different-after-success")
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
image.unlink()
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
def test_evidence_terminal_outcome_retains_slot_and_blocks_resend(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "manual.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
snapshot = self.store.recovery_snapshot(PROFILE)
self.assertEqual(snapshot.pending_evidence[0].status, "MANUAL")
with self.assertRaises(StateError):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_receipt_dimensions_must_match_local_png_and_pending_slot_survives(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "dimension.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
wrong = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 2, 2, upload.captured_at,
)
with self.assertRaisesRegex(StateError, "evidence_response_mismatch"):
self.store.commit_evidence_success(PROFILE, upload, wrong)
pending = self.new_store().recovery_snapshot(PROFILE).pending_evidence
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0].upload_key, upload.upload_key)
def test_evidence_slot_is_profile_owned_and_missing_half_fails_closed(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "owned.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
self.store.prepare_or_resume_evidence(PROFILE, asset)
other = replace(settings(), profile_id="other")
self.store.save_profile(other, SecretToken("c" * 64))
with self.assertRaisesRegex(StateError, "evidence_slot_not_owned"):
self.store.prepare_or_resume_evidence("other", asset)
connection = sqlite3.connect(self.database)
connection.execute("DROP TRIGGER evidence_slots_no_delete")
connection.execute("DELETE FROM evidence_slots WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
with self.assertRaisesRegex(StateError, "evidence_marker_mismatch"):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_evidence_reader_rejects_reparse_and_path_identity_change(self) -> None:
image = Path(self.directory.name) / "stable.png"
image.write_bytes(PNG)
real = os.lstat(image)
reparse = SimpleNamespace(
st_mode=real.st_mode,
st_file_attributes=0x400,
st_dev=real.st_dev,
st_ino=real.st_ino,
st_size=real.st_size,
st_mtime_ns=real.st_mtime_ns,
)
with mock.patch("cmbuyer_client.localstate.store.os.lstat", return_value=reparse):
with self.assertRaisesRegex(StateError, "evidence_file_not_regular"):
_read_stable_png(image)
replaced = SimpleNamespace(
st_mode=real.st_mode,
st_file_attributes=0,
st_dev=real.st_dev,
st_ino=real.st_ino + 1,
st_size=real.st_size,
st_mtime_ns=real.st_mtime_ns,
)
with mock.patch("cmbuyer_client.localstate.store.os.lstat", side_effect=(real, replaced)):
with self.assertRaisesRegex(StateError, "evidence_changed_during_read"):
_read_stable_png(image)
def test_corrupt_database_and_ciphertext_fail_closed(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_identity_immutable")
connection.execute("UPDATE active_claims SET claim_token_cipher=?", (sqlite3.Binary(b"corrupt"),))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().active_claim(PROFILE)
corrupt = Path(self.directory.name) / "corrupt.sqlite3"
corrupt.write_bytes(b"not-a-sqlite-database")
with self.assertRaises(StateError):
LocalStateStore(corrupt, FakeProtector())
def test_claim_token_cipher_cannot_be_swapped_between_attempt_histories(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
request = self.store.prepare_claim(PROFILE)
wire = claim_wire()
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["claim_token"] = "f" * 64
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_identity_immutable")
rows = connection.execute("SELECT attempt_id,claim_token_cipher FROM active_claims ORDER BY created_at,attempt_id").fetchall()
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[1][1], rows[0][0]))
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[0][1], rows[1][0]))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().active_claim(PROFILE)
def test_device_token_cipher_cannot_be_swapped_between_profiles(self) -> None:
other = replace(settings(), profile_id="other", device_id="f3c9f507-7473-4fa6-8d71-8786c34c6301")
self.store.save_profile(other, SecretToken("c" * 64))
connection = sqlite3.connect(self.database)
try:
rows = connection.execute(
"SELECT profile_id,device_token_cipher FROM profiles WHERE profile_id IN (?,?) ORDER BY profile_id",
(PROFILE, "other"),
).fetchall()
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[1][1], rows[0][0]))
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[0][1], rows[1][0]))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().load_profile(PROFILE)
def test_state_graph_rejects_missing_active_pending_overlap_and_snapshot_drift(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute(
"INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)",
(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
PROFILE,
self.store.recovery_snapshot(PROFILE).session.session_id,
"2026-08-04T09:00:00Z",
"2026-08-04T09:00:00Z",
),
)
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "claim_state_conflict"):
self.store.prepare_claim(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER claim_requests_no_delete")
connection.execute("DELETE FROM claim_requests WHERE status='PENDING'")
connection.execute("DROP TRIGGER active_claims_identity_immutable")
connection.execute("UPDATE active_claims SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
self.store.active_claim(PROFILE)
def test_succeeded_claim_tombstone_detects_missing_history(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_no_delete")
connection.execute("DELETE FROM active_claims")
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "active_claim_request_mismatch"):
self.store.prepare_claim(PROFILE)
def test_closed_history_is_retained_but_does_not_block_next_claim(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z'")
connection.commit()
finally:
connection.close()
self.assertIsNone(self.store.active_claim(PROFILE))
changed = replace(settings(), poll_interval_seconds=16)
self.store.save_profile(changed, None)
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.assertIsNotNone(request.claim_request_id)
def test_pending_renew_and_evidence_revalidate_active_graph(self) -> None:
self._claim_active()
renew = self.store.prepare_renew(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER renew_requests_identity_immutable")
connection.execute("UPDATE renew_requests SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "renew_active_mismatch"):
self.store.prepare_renew(PROFILE)
def test_success_receipt_must_match_immutable_slot(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "receipt.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
TASK_ID,
ATTEMPT_ID,
upload.kind,
upload.privacy_tier,
upload.sha256,
len(upload.content),
"image/png",
1,
1,
upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
connection = sqlite3.connect(self.database)
try:
wrong = dict(receipt.__dict__)
wrong["asset_id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
connection.rollback()
connection.execute("DROP TRIGGER evidence_slots_receipt_immutable")
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "evidence_receipt_mismatch"):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_marker_and_slot_append_only_triggers_prevent_erasing_history(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "append-only.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
self.store.prepare_or_resume_evidence(PROFILE, asset)
connection = sqlite3.connect(self.database)
try:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM evidence_slot_markers")
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE evidence_slot_markers SET upload_key=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM evidence_slots")
finally:
connection.close()
def test_capped_equal_renew_is_success_but_authorization_overrun_is_rejected(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
equal = RenewResult(TASK_ID, ATTEMPT_ID, 1, request.expected_lease_expires_at)
self.store.commit_renew_success(PROFILE, request, equal)
self.assertEqual(self.store.active_claim(PROFILE).attempt.lease_expires_at, request.expected_lease_expires_at)
next_request = self.store.prepare_renew(PROFILE)
beyond = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T10:00:00.000000001Z")
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
self.store.commit_renew_success(PROFILE, next_request, beyond)
def test_sqlite_database_wal_and_shm_never_contain_plaintext_tokens(self) -> None:
self._claim_active()
# 强制 checkpoint 后同时扫描主文件和可能存在的 WAL/SHM。
connection = sqlite3.connect(self.database)
connection.execute("PRAGMA wal_checkpoint(FULL)")
connection.close()
for path in (self.database, Path(str(self.database) + "-wal"), Path(str(self.database) + "-shm")):
if path.exists():
raw = path.read_bytes()
self.assertNotIn(DEVICE_TOKEN.encode(), raw)
self.assertNotIn(bytes.fromhex(DEVICE_TOKEN), raw)
self.assertNotIn(TOKEN.encode(), raw)
self.assertNotIn(bytes.fromhex(TOKEN), raw)
def test_concurrent_stop_and_claim_commit_never_loses_result(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
barrier = threading.Barrier(2)
failures: list[BaseException] = []
def stop() -> None:
try:
barrier.wait()
self.store.request_stop(PROFILE)
except BaseException as error:
failures.append(error)
thread = threading.Thread(target=stop)
thread.start()
barrier.wait()
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
thread.join()
self.assertEqual(failures, [])
snapshot = self.store.recovery_snapshot(PROFILE)
self.assertFalse(snapshot.session.accept_new)
self.assertIsNotNone(snapshot.active_claim)
def test_recovery_snapshot_uses_one_sqlite_read_snapshot(self) -> None:
self.store.start_or_resume_polling(PROFILE)
writer = self.new_store()
failures: list[BaseException] = []
class PausingStore(LocalStateStore):
armed = False
def _connect(inner_self):
connection = super(PausingStore, inner_self)._connect()
if inner_self.armed:
def trace(statement: str) -> None:
if inner_self.armed and "FROM claim_requests" in statement:
inner_self.armed = False
thread = threading.Thread(target=do_stop)
thread.start()
thread.join()
connection.set_trace_callback(trace)
return connection
def do_stop() -> None:
try:
writer.request_stop(PROFILE)
except BaseException as error:
failures.append(error)
reader = PausingStore(self.database, FakeProtector(), now=lambda: self.clock[0])
reader.armed = True
snapshot = reader.recovery_snapshot(PROFILE)
self.assertEqual(failures, [])
self.assertTrue(snapshot.session.accept_new)
self.assertFalse(writer.recovery_snapshot(PROFILE).session.accept_new)
def test_business_snapshot_is_immutable_hashed_and_renew_never_rewrites_it(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
raw, digest, initial_lease, current_lease = connection.execute(
"""SELECT snapshot_json,snapshot_digest,initial_lease_expires_at,lease_expires_at
FROM active_claims WHERE closed_at IS NULL"""
).fetchone()
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE active_claims SET snapshot_json='{}' WHERE closed_at IS NULL")
connection.rollback()
connection.execute("DROP TRIGGER active_claims_identity_immutable")
for field, changed in (
("sku_color", "白色"),
("sku_size", "XL"),
("quantity", 99),
("max_total_price", "999.00"),
):
payload = json.loads(raw)
payload["task"][field] = changed
tampered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
connection.execute("UPDATE active_claims SET snapshot_json=? WHERE closed_at IS NULL", (tampered,))
connection.commit()
with self.subTest(field=field), self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
self.store.active_claim(PROFILE)
connection.execute(
"UPDATE active_claims SET snapshot_json=?,snapshot_digest=? WHERE closed_at IS NULL", (raw, digest)
)
connection.commit()
connection.execute(
"""UPDATE active_claims SET initial_lease_expires_at='2026-08-04T09:59:00Z',
lease_expires_at='2026-08-04T09:59:00Z' WHERE closed_at IS NULL"""
)
connection.commit()
with self.assertRaisesRegex(StateError, "invalid_stored_claim"):
self.store.active_claim(PROFILE)
connection.execute(
"""UPDATE active_claims SET initial_lease_expires_at=?,lease_expires_at=?
WHERE closed_at IS NULL""",
(initial_lease, current_lease),
)
connection.commit()
finally:
connection.close()
renew = self.store.prepare_renew(PROFILE)
self.store.commit_renew_success(PROFILE, renew, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
connection = sqlite3.connect(self.database)
try:
self.assertEqual(
connection.execute("SELECT snapshot_json,snapshot_digest FROM active_claims WHERE closed_at IS NULL").fetchone(),
(raw, digest),
)
finally:
connection.close()
def test_open_session_detects_tampering_of_every_non_token_profile_setting(self) -> None:
self._claim_active()
changed_values = {
"service_url": "http://127.0.0.1:9999",
"device_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"adb_path": "D:/other/adb.exe",
"adb_serial": "usb-other",
"transport": "usb",
"poll_interval_seconds": 16,
"failure_threshold": 4,
"http_timeout_seconds": 11,
"step_timeout_seconds": 46,
}
connection = sqlite3.connect(self.database)
try:
for field, changed in changed_values.items():
original = connection.execute(f"SELECT {field} FROM profiles WHERE profile_id=?", (PROFILE,)).fetchone()[0]
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (changed, PROFILE))
connection.commit()
with self.subTest(field=field), self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
self.store.active_claim(PROFILE)
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (original, PROFILE))
connection.commit()
finally:
connection.close()
def test_closed_attempt_evidence_is_history_not_current_recovery_work(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "old-manual.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
next_request = self.store.prepare_claim(PROFILE)
self.assertIsNotNone(next_request.claim_request_id)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_evidence, ())
def test_succeeded_evidence_history_does_not_block_next_claim(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "old-success.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
self.assertIsNotNone(self.store.prepare_claim(PROFILE))
def test_evidence_slot_without_corresponding_claim_history_fails_closed(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "orphan.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
orphan = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER evidence_slot_markers_immutable")
connection.execute("DROP TRIGGER evidence_slots_identity_immutable")
connection.execute("UPDATE evidence_slot_markers SET attempt_id=?", (orphan,))
connection.execute("UPDATE evidence_slots SET attempt_id=?", (orphan,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "evidence_claim_history_mismatch"):
self.store.recovery_snapshot(PROFILE)
def test_terminal_renew_is_scoped_to_old_closed_attempt(self) -> None:
self._claim_active()
old_renew = self.store.prepare_renew(PROFILE)
self.store.mark_renew_terminal(PROFILE, old_renew, "MANUAL")
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
request = self.store.prepare_claim(PROFILE)
wire = claim_wire()
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
next_renew = self.store.prepare_renew(PROFILE)
self.assertNotEqual(next_renew.renew_request_id, old_renew.renew_request_id)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_renew, next_renew)
def test_successful_renew_response_is_write_once_and_digest_checked(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
self.store.commit_renew_success(PROFILE, request, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
connection = sqlite3.connect(self.database)
try:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
connection.rollback()
connection.execute("DROP TRIGGER renew_requests_response_immutable")
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
self.store.active_claim(PROFILE)
def test_invalid_or_reversed_session_and_claim_timestamps_fail_closed(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T08:59:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "invalid_claim_timeline"):
self.store.recovery_snapshot(PROFILE)
def test_invalid_session_closed_at_fails_closed(self) -> None:
self.store.start_or_resume_polling(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE polling_sessions SET closed_at='not-a-time' WHERE profile_id=?", (PROFILE,))
connection.commit()
finally:
connection.close()
with self.assertRaises(StateError):
self.store.recovery_snapshot(PROFILE)
def test_store_rejects_forged_claim_whose_lease_exceeds_authorization(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
claimed = ClaimedTask.from_wire(claim_wire())
object.__setattr__(claimed.attempt, "lease_expires_at", "2026-08-04T10:00:00.000000001Z")
with self.assertRaisesRegex(StateError, "claim_lease_exceeds_authorization"):
self.store.commit_claim_success(PROFILE, request, claimed)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_claim, request)
def _claim_active(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))