811 lines
39 KiB
Python
811 lines
39 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_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
|
|
class NoUnprotectProtector(FakeProtector):
|
|
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
|
raise AssertionError("metadata_read_must_not_unprotect")
|
|
|
|
summary_store = LocalStateStore(
|
|
self.database,
|
|
NoUnprotectProtector(),
|
|
now=lambda: self.clock[0],
|
|
)
|
|
summary = summary_store.load_profile_summary(PROFILE)
|
|
self.assertEqual(summary.settings, settings())
|
|
self.assertTrue(summary.has_stored_device_token)
|
|
self.assertNotIn(DEVICE_TOKEN, repr(summary))
|
|
|
|
def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None:
|
|
with self.assertRaisesRegex(StateError, "profile_not_found"):
|
|
self.store.load_profile_summary("missing")
|
|
connection = sqlite3.connect(self.database)
|
|
try:
|
|
self.assertEqual(connection.execute("SELECT count(*) FROM profiles").fetchone()[0], 1)
|
|
finally:
|
|
connection.close()
|
|
|
|
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()))
|