feat(client): add durable HTTP task state
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
|
||||
|
||||
from .errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
ValidationError,
|
||||
)
|
||||
from .models import (
|
||||
AssetReceipt,
|
||||
AuthorizationSnapshot,
|
||||
ClaimRequest,
|
||||
ClaimedTask,
|
||||
DeviceCredentials,
|
||||
EvidenceUpload,
|
||||
PurchaseTask,
|
||||
RenewRequest,
|
||||
RenewResult,
|
||||
SecretToken,
|
||||
)
|
||||
from .ports import EvidenceSink, TaskSource
|
||||
|
||||
__all__ = [
|
||||
"AmbiguousRemoteError",
|
||||
"AssetReceipt",
|
||||
"AuthorizationSnapshot",
|
||||
"ClaimRequest",
|
||||
"ClaimedTask",
|
||||
"CredentialRemoteError",
|
||||
"DeviceCredentials",
|
||||
"EvidenceSink",
|
||||
"EvidenceUpload",
|
||||
"ManualRemoteError",
|
||||
"ProtocolRemoteError",
|
||||
"PurchaseTask",
|
||||
"RenewRequest",
|
||||
"RenewResult",
|
||||
"SecretToken",
|
||||
"StateError",
|
||||
"TaskSource",
|
||||
"ValidationError",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""可安全呈现的客户端错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ClientError(RuntimeError):
|
||||
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
class ValidationError(ClientError):
|
||||
"""本地输入或 wire schema 不满足固定契约。"""
|
||||
|
||||
|
||||
class StateError(ClientError):
|
||||
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
|
||||
|
||||
|
||||
class ProtectionError(ClientError):
|
||||
"""秘密保护失败。"""
|
||||
|
||||
|
||||
class SingleInstanceError(ClientError):
|
||||
"""同一配置已经由另一个采购工具进程持有。"""
|
||||
|
||||
|
||||
class RemoteError(ClientError):
|
||||
"""服务端调用的稳定错误分类。"""
|
||||
|
||||
|
||||
class AmbiguousRemoteError(RemoteError):
|
||||
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
|
||||
|
||||
|
||||
class CredentialRemoteError(RemoteError):
|
||||
"""设备凭据无效或已撤销。"""
|
||||
|
||||
|
||||
class ProtocolRemoteError(RemoteError):
|
||||
"""请求/响应与固定协议不兼容,不得自动重试。"""
|
||||
|
||||
|
||||
class ManualRemoteError(RemoteError):
|
||||
"""服务端要求人工处理的确定性冲突。"""
|
||||
@@ -0,0 +1,327 @@
|
||||
"""任务领取、续租和单张证据上传的不可变值对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ValidationError
|
||||
from .validation import (
|
||||
canonical_product_url,
|
||||
require_exact_fields,
|
||||
require_goods_id,
|
||||
require_lower_hex_64,
|
||||
require_money,
|
||||
require_positive_int,
|
||||
require_rfc3339_z,
|
||||
rfc3339_z_nanoseconds,
|
||||
require_string,
|
||||
require_uuid4,
|
||||
)
|
||||
|
||||
|
||||
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
|
||||
PRIVACY_TIER = "INTERNAL_RAW"
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class SecretToken:
|
||||
"""64 位小写 token;repr 永不暴露明文。"""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_lower_hex_64(self.value, "invalid_token")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SecretToken([已隐藏])"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "[已隐藏]"
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class DeviceCredentials:
|
||||
device_id: str
|
||||
token: SecretToken
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.device_id, "invalid_device_id")
|
||||
if not isinstance(self.token, SecretToken):
|
||||
raise ValidationError("invalid_device_token")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimRequest:
|
||||
session_id: str
|
||||
claim_request_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
|
||||
|
||||
def to_wire(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurchaseTask:
|
||||
id: str
|
||||
version: int
|
||||
title: str
|
||||
product_url: str
|
||||
goods_id: str
|
||||
sku_color: str
|
||||
sku_size: str
|
||||
quantity: int
|
||||
max_total_price: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_task_id")
|
||||
require_positive_int(self.version, "invalid_task_version")
|
||||
require_string(self.title, "invalid_task_title", maximum=32 * 1024)
|
||||
if not self.title.strip():
|
||||
raise ValidationError("invalid_task_title")
|
||||
require_goods_id(self.goods_id)
|
||||
if self.product_url != canonical_product_url(self.goods_id):
|
||||
raise ValidationError("invalid_product_url")
|
||||
require_string(self.sku_color, "invalid_sku_color", maximum=32 * 1024)
|
||||
require_string(self.sku_size, "invalid_sku_size", maximum=32 * 1024)
|
||||
require_positive_int(self.quantity, "invalid_quantity")
|
||||
require_money(self.max_total_price, "invalid_max_total_price")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "PurchaseTask":
|
||||
data = require_exact_fields(
|
||||
value,
|
||||
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
|
||||
)
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationSnapshot:
|
||||
id: str
|
||||
task_version: int
|
||||
expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_authorization_id")
|
||||
require_positive_int(self.task_version, "invalid_authorization_task_version")
|
||||
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
|
||||
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttemptSnapshot:
|
||||
id: str
|
||||
claim_token: SecretToken
|
||||
claim_generation: int
|
||||
lease_expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.id, "invalid_attempt_id")
|
||||
if not isinstance(self.claim_token, SecretToken):
|
||||
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
|
||||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||||
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "AttemptSnapshot":
|
||||
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
|
||||
return cls(
|
||||
id=data["id"], # type: ignore[arg-type]
|
||||
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
|
||||
claim_generation=data["claim_generation"], # type: ignore[arg-type]
|
||||
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedTask:
|
||||
task: PurchaseTask
|
||||
authorization: AuthorizationSnapshot
|
||||
attempt: AttemptSnapshot = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.task.version != self.authorization.task_version + 1:
|
||||
raise ValidationError("task_authorization_version_mismatch")
|
||||
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
|
||||
raise ValidationError("claim_lease_exceeds_authorization")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "ClaimedTask":
|
||||
data = require_exact_fields(value, ("task", "authorization", "attempt"))
|
||||
return cls(
|
||||
task=PurchaseTask.from_wire(data["task"]),
|
||||
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
|
||||
attempt=AttemptSnapshot.from_wire(data["attempt"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class RenewRequest:
|
||||
task_id: str
|
||||
renew_request_id: str
|
||||
session_id: str
|
||||
attempt_id: str
|
||||
claim_generation: int
|
||||
claim_token: SecretToken
|
||||
expected_lease_expires_at: str
|
||||
authorization_expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||||
if not isinstance(self.claim_token, SecretToken):
|
||||
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
|
||||
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
|
||||
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
|
||||
"claim_token=[已隐藏])"
|
||||
)
|
||||
|
||||
def to_wire(self) -> dict[str, object]:
|
||||
return {
|
||||
"renew_request_id": self.renew_request_id,
|
||||
"session_id": self.session_id,
|
||||
"attempt_id": self.attempt_id,
|
||||
"claim_generation": self.claim_generation,
|
||||
"claim_token": self.claim_token.value,
|
||||
"expected_lease_expires_at": self.expected_lease_expires_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenewResult:
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
claim_generation: int
|
||||
lease_expires_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_positive_int(self.claim_generation, "invalid_claim_generation")
|
||||
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "RenewResult":
|
||||
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class EvidenceUpload:
|
||||
task_id: str
|
||||
upload_key: str
|
||||
attempt_id: str
|
||||
sha256: str
|
||||
captured_at: str
|
||||
content: bytes = field(repr=False)
|
||||
kind: str = EVIDENCE_KIND
|
||||
privacy_tier: str = PRIVACY_TIER
|
||||
width_px: int = field(init=False)
|
||||
height_px: int = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.upload_key, "invalid_upload_key")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
|
||||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||||
raise ValidationError("invalid_evidence_metadata")
|
||||
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
|
||||
raise ValidationError("invalid_evidence_size")
|
||||
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
|
||||
raise ValidationError("invalid_evidence_png")
|
||||
width = int.from_bytes(self.content[16:20], "big")
|
||||
height = int.from_bytes(self.content[20:24], "big")
|
||||
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
|
||||
raise ValidationError("invalid_evidence_dimensions")
|
||||
object.__setattr__(self, "width_px", width)
|
||||
object.__setattr__(self, "height_px", height)
|
||||
if hashlib.sha256(self.content).hexdigest() != self.sha256:
|
||||
raise ValidationError("evidence_hash_mismatch")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
|
||||
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetReceipt:
|
||||
asset_id: str
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
kind: str
|
||||
privacy_tier: str
|
||||
sha256: str
|
||||
byte_size: int
|
||||
content_type: str
|
||||
width_px: int
|
||||
height_px: int
|
||||
captured_at: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.asset_id, "invalid_asset_id")
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||||
raise ValidationError("invalid_asset_metadata")
|
||||
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
|
||||
require_positive_int(self.byte_size, "invalid_asset_byte_size")
|
||||
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
|
||||
raise ValidationError("invalid_asset_content")
|
||||
width = require_positive_int(self.width_px, "invalid_asset_width")
|
||||
height = require_positive_int(self.height_px, "invalid_asset_height")
|
||||
if width > 8192 or height > 8192 or width * height > 16_777_216:
|
||||
raise ValidationError("invalid_asset_dimensions")
|
||||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, value: object) -> "AssetReceipt":
|
||||
data = require_exact_fields(
|
||||
value,
|
||||
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
|
||||
)
|
||||
return cls(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class ScreenshotAsset:
|
||||
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
|
||||
|
||||
path: Path = field(repr=False)
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
captured_at: str
|
||||
kind: str = EVIDENCE_KIND
|
||||
privacy_tier: str = PRIVACY_TIER
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.task_id, "invalid_task_id")
|
||||
require_uuid4(self.attempt_id, "invalid_attempt_id")
|
||||
require_rfc3339_z(self.captured_at, "invalid_captured_at")
|
||||
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
|
||||
raise ValidationError("invalid_evidence_metadata")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""由 HTTP 适配器实现的窄端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult
|
||||
|
||||
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||
|
||||
|
||||
class EvidenceSink(Protocol):
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||
@@ -0,0 +1,175 @@
|
||||
"""客户端与服务端共享 wire 的严格值校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
from urllib.parse import quote
|
||||
|
||||
from .errors import ValidationError
|
||||
|
||||
|
||||
UUID4_RE = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
)
|
||||
LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}")
|
||||
RFC3339_Z_RE = re.compile(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z"
|
||||
)
|
||||
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
|
||||
GOODS_ID_RE = re.compile(r"[0-9]+")
|
||||
|
||||
|
||||
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
raise ValidationError(reason)
|
||||
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
|
||||
text = require_string(value, reason, maximum=36)
|
||||
if UUID4_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str:
|
||||
text = require_string(value, reason, maximum=64)
|
||||
if LOWER_HEX_64_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str:
|
||||
text = require_string(value, reason, maximum=40)
|
||||
if RFC3339_Z_RE.fullmatch(text) is None:
|
||||
raise ValidationError(reason)
|
||||
parsed: datetime | None = None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
|
||||
except ValueError:
|
||||
pass
|
||||
if parsed is None:
|
||||
raise ValidationError(reason)
|
||||
if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0:
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int:
|
||||
"""无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。"""
|
||||
|
||||
text = require_rfc3339_z(value, reason)
|
||||
base: datetime | None = None
|
||||
try:
|
||||
base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
if base is None:
|
||||
raise ValidationError(reason)
|
||||
fraction = "" if len(text) == 20 else text[20:-1]
|
||||
nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0
|
||||
return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds
|
||||
|
||||
|
||||
def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int:
|
||||
if not isinstance(value, datetime) or value.utcoffset() is None:
|
||||
raise ValidationError(reason)
|
||||
utc = value.astimezone(timezone.utc)
|
||||
return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000
|
||||
|
||||
|
||||
def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
|
||||
# bool 是 int 的子类;wire 中必须显式拒绝 true/false。
|
||||
if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807:
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def require_money(value: object, reason: str = "invalid_money") -> str:
|
||||
text = require_string(value, reason, maximum=32 * 1024)
|
||||
if MONEY_RE.fullmatch(text) is None or text == "0.00":
|
||||
raise ValidationError(reason)
|
||||
return text
|
||||
|
||||
|
||||
def require_goods_id(value: object) -> str:
|
||||
text = require_string(value, "invalid_goods_id", maximum=32 * 1024)
|
||||
if GOODS_ID_RE.fullmatch(text) is None:
|
||||
raise ValidationError("invalid_goods_id")
|
||||
return text
|
||||
|
||||
|
||||
def canonical_product_url(goods_id: str) -> str:
|
||||
require_goods_id(goods_id)
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="")
|
||||
|
||||
|
||||
def require_exact_fields(
|
||||
value: object,
|
||||
required: Iterable[str],
|
||||
reason: str = "invalid_schema",
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(reason)
|
||||
expected = frozenset(required)
|
||||
if frozenset(value) != expected:
|
||||
raise ValidationError(reason)
|
||||
return value
|
||||
|
||||
|
||||
def strict_json_loads(raw: bytes, *, maximum: int) -> object:
|
||||
if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum:
|
||||
raise ValidationError("invalid_json_size")
|
||||
text: str | None = None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
if text is None:
|
||||
raise ValidationError("invalid_json_utf8")
|
||||
if text.startswith("\ufeff"):
|
||||
raise ValidationError("invalid_json_bom")
|
||||
|
||||
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValidationError("duplicate_json_key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def reject_number(_: str) -> object:
|
||||
raise ValidationError("invalid_json_number")
|
||||
|
||||
def parse_integer(value: str) -> int:
|
||||
digits = value[1:] if value.startswith("-") else value
|
||||
if len(digits) > 19:
|
||||
raise ValidationError("invalid_json_integer")
|
||||
parsed = int(value)
|
||||
if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807:
|
||||
raise ValidationError("invalid_json_integer")
|
||||
return parsed
|
||||
|
||||
parsed_json: object | None = None
|
||||
failed = False
|
||||
try:
|
||||
parsed_json = json.loads(
|
||||
text,
|
||||
object_pairs_hook=pairs_hook,
|
||||
parse_int=parse_integer,
|
||||
parse_float=reject_number,
|
||||
parse_constant=reject_number,
|
||||
)
|
||||
except ValidationError:
|
||||
raise
|
||||
except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError):
|
||||
failed = True
|
||||
if failed:
|
||||
raise ValidationError("invalid_json")
|
||||
return parsed_json
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Windows 本地恢复、凭据保护和单实例底座。"""
|
||||
|
||||
from .models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from .facade import DurableClientGateway
|
||||
from .protection import DpapiProtector, SecretProtector
|
||||
from .single_instance import NamedMutex
|
||||
from .store import LocalStateStore
|
||||
|
||||
__all__ = [
|
||||
"DpapiProtector",
|
||||
"DurableClientGateway",
|
||||
"LocalStateStore",
|
||||
"NamedMutex",
|
||||
"PollingSession",
|
||||
"ProfileSettings",
|
||||
"RecoverySnapshot",
|
||||
"SecretProtector",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
)
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset
|
||||
from cmbuyer_client.core.ports import EvidenceSink, TaskSource
|
||||
|
||||
from .store import LocalStateStore
|
||||
|
||||
|
||||
class DurableClientGateway:
|
||||
"""不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。"""
|
||||
|
||||
def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None:
|
||||
self._store = store
|
||||
self._task_source = task_source
|
||||
self._evidence_sink = evidence_sink
|
||||
|
||||
def claim_next(self, profile_id: str) -> ClaimedTask | None:
|
||||
request = self._store.prepare_claim(profile_id)
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
claimed = self._task_source.claim_next(credentials, request)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_claim_terminal(profile_id, request, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_claim_terminal(profile_id, request, "MANUAL")
|
||||
raise
|
||||
if claimed is None:
|
||||
self._store.commit_claim_empty(profile_id, request)
|
||||
return None
|
||||
self._store.commit_claim_success(profile_id, request, claimed)
|
||||
return claimed
|
||||
|
||||
def renew(self, profile_id: str):
|
||||
request = self._store.prepare_renew(profile_id)
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
result = self._task_source.renew(credentials, request)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_renew_terminal(profile_id, request, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_renew_terminal(profile_id, request, "MANUAL")
|
||||
raise
|
||||
self._store.commit_renew_success(profile_id, request, result)
|
||||
return result
|
||||
|
||||
def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt:
|
||||
prepared = self._store.prepare_or_resume_evidence(profile_id, asset)
|
||||
if isinstance(prepared, AssetReceipt):
|
||||
return prepared
|
||||
credentials = self._store.load_profile(profile_id).credentials
|
||||
try:
|
||||
receipt = self._evidence_sink.upload(credentials, prepared)
|
||||
except AmbiguousRemoteError:
|
||||
raise
|
||||
except CredentialRemoteError:
|
||||
raise
|
||||
except ProtocolRemoteError:
|
||||
self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL")
|
||||
raise
|
||||
except ManualRemoteError:
|
||||
self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL")
|
||||
raise
|
||||
self._store.commit_evidence_success(profile_id, prepared, receipt)
|
||||
return receipt
|
||||
@@ -0,0 +1,86 @@
|
||||
"""供 T-304 使用的稳定本地配置与恢复快照。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest
|
||||
from cmbuyer_client.core.validation import require_string, require_uuid4
|
||||
|
||||
|
||||
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
|
||||
PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileSettings:
|
||||
profile_id: str
|
||||
service_url: str
|
||||
device_id: str
|
||||
adb_path: str
|
||||
adb_serial: str
|
||||
transport: str
|
||||
poll_interval_seconds: int = 15
|
||||
failure_threshold: int = 3
|
||||
http_timeout_seconds: int = 10
|
||||
step_timeout_seconds: int = 45
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None:
|
||||
raise ValueError("invalid_profile_id")
|
||||
if self.service_url != LOOPBACK_SERVICE_URL:
|
||||
raise ValueError("service_url_not_allowed")
|
||||
require_uuid4(self.device_id, "invalid_device_id")
|
||||
require_string(self.adb_path, "invalid_adb_path", maximum=1024)
|
||||
require_string(self.adb_serial, "invalid_adb_serial", maximum=200)
|
||||
if self.transport not in ("usb", "wifi"):
|
||||
raise ValueError("invalid_transport")
|
||||
_range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval")
|
||||
_range(self.failure_threshold, 1, 10, "invalid_failure_threshold")
|
||||
_range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout")
|
||||
_range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout")
|
||||
|
||||
|
||||
@dataclass(frozen=True, repr=False)
|
||||
class LoadedProfile:
|
||||
settings: ProfileSettings
|
||||
credentials: DeviceCredentials = field(repr=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollingSession:
|
||||
profile_id: str
|
||||
session_id: str
|
||||
accept_new: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
require_uuid4(self.session_id, "invalid_session_id")
|
||||
if type(self.accept_new) is not bool:
|
||||
raise ValueError("invalid_accept_new")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingEvidence:
|
||||
task_id: str
|
||||
attempt_id: str
|
||||
kind: str
|
||||
upload_key: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoverySnapshot:
|
||||
session: PollingSession | None
|
||||
pending_claim: ClaimRequest | None
|
||||
active_claim: ClaimedTask | None = field(repr=False)
|
||||
pending_renew: RenewRequest | None = field(repr=False)
|
||||
pending_evidence: tuple[PendingEvidence, ...]
|
||||
|
||||
|
||||
def _range(value: object, minimum: int, maximum: int, reason: str) -> None:
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise ValueError(reason)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import os
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
|
||||
|
||||
class SecretProtector(Protocol):
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ...
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ...
|
||||
|
||||
|
||||
class _DataBlob(ctypes.Structure):
|
||||
_fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte)))
|
||||
|
||||
|
||||
def _blob(data: bytes) -> tuple[_DataBlob, object]:
|
||||
buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)()
|
||||
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
|
||||
|
||||
|
||||
class DpapiProtector:
|
||||
"""使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。"""
|
||||
|
||||
_UI_FORBIDDEN = 0x1
|
||||
_ENTROPY_PREFIX = b"cmbuyer-localstate-v1:"
|
||||
_PURPOSE_RE = re.compile(
|
||||
r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|"
|
||||
r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})",
|
||||
flags=re.ASCII,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
if os.name != "nt":
|
||||
raise ProtectionError("dpapi_requires_windows")
|
||||
self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
||||
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
self._crypt32.CryptProtectData.argtypes = (
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPVOID,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
)
|
||||
self._crypt32.CryptProtectData.restype = wintypes.BOOL
|
||||
self._crypt32.CryptUnprotectData.argtypes = (
|
||||
ctypes.POINTER(_DataBlob),
|
||||
ctypes.POINTER(wintypes.LPWSTR),
|
||||
ctypes.POINTER(_DataBlob),
|
||||
wintypes.LPVOID,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DataBlob),
|
||||
)
|
||||
self._crypt32.CryptUnprotectData.restype = wintypes.BOOL
|
||||
self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,)
|
||||
self._kernel32.LocalFree.restype = wintypes.HLOCAL
|
||||
|
||||
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
|
||||
if not isinstance(plaintext, bytes) or not plaintext:
|
||||
raise ProtectionError("invalid_plaintext")
|
||||
entropy = self._entropy(purpose)
|
||||
source, source_buffer = _blob(plaintext)
|
||||
entropy_blob, entropy_buffer = _blob(entropy)
|
||||
output = _DataBlob()
|
||||
if not self._crypt32.CryptProtectData(
|
||||
ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
|
||||
):
|
||||
raise ProtectionError("dpapi_protect_failed")
|
||||
# ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。
|
||||
del source_buffer, entropy_buffer
|
||||
return self._take_output(output, "dpapi_protect_failed")
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
||||
if not isinstance(ciphertext, bytes) or not ciphertext:
|
||||
raise ProtectionError("invalid_ciphertext")
|
||||
entropy = self._entropy(purpose)
|
||||
source, source_buffer = _blob(ciphertext)
|
||||
entropy_blob, entropy_buffer = _blob(entropy)
|
||||
output = _DataBlob()
|
||||
description = wintypes.LPWSTR()
|
||||
if not self._crypt32.CryptUnprotectData(
|
||||
ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
|
||||
):
|
||||
raise ProtectionError("dpapi_unprotect_failed")
|
||||
del source_buffer, entropy_buffer
|
||||
if description:
|
||||
self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL))
|
||||
return self._take_output(output, "dpapi_unprotect_failed")
|
||||
|
||||
def _take_output(self, output: _DataBlob, reason: str) -> bytes:
|
||||
if not output.pbData or output.cbData <= 0:
|
||||
raise ProtectionError(reason)
|
||||
try:
|
||||
return ctypes.string_at(output.pbData, output.cbData)
|
||||
finally:
|
||||
self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL))
|
||||
|
||||
@classmethod
|
||||
def _entropy(cls, purpose: str) -> bytes:
|
||||
if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None:
|
||||
raise ProtectionError("invalid_protection_purpose")
|
||||
return cls._ENTROPY_PREFIX + purpose.encode("ascii")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""同一本地数据库的 Windows named mutex。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
|
||||
|
||||
class NamedMutex:
|
||||
_ALREADY_EXISTS = 183
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
raise SingleInstanceError("named_mutex_requires_windows")
|
||||
canonical = str(database_path.expanduser().resolve()).casefold().encode("utf-8")
|
||||
# Global namespace 覆盖同一 Windows 用户的多个交互 session;默认 DACL 不向其他用户泄露句柄。
|
||||
name = "Global\\cmbuyer-" + hashlib.sha256(canonical).hexdigest()
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.CreateMutexW.argtypes = (wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR)
|
||||
kernel32.CreateMutexW.restype = wintypes.HANDLE
|
||||
kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.ReleaseMutex.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
ctypes.set_last_error(0)
|
||||
handle = kernel32.CreateMutexW(None, True, name)
|
||||
if not handle:
|
||||
raise SingleInstanceError("named_mutex_failed")
|
||||
if ctypes.get_last_error() == self._ALREADY_EXISTS:
|
||||
kernel32.CloseHandle(handle)
|
||||
raise SingleInstanceError("instance_already_running")
|
||||
self._kernel32 = kernel32
|
||||
self._handle = handle
|
||||
|
||||
def close(self) -> None:
|
||||
handle = getattr(self, "_handle", None)
|
||||
if handle:
|
||||
self._kernel32.ReleaseMutex(handle)
|
||||
self._kernel32.CloseHandle(handle)
|
||||
self._handle = None
|
||||
|
||||
def __enter__(self) -> "NamedMutex":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
|
||||
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
@@ -31,7 +33,9 @@ def redact_text(message: str) -> str:
|
||||
def replace_key_value(match: re.Match[str]) -> str:
|
||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
||||
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
|
||||
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class RedactingFormatter(logging.Formatter):
|
||||
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return redact_text(super().format(record))
|
||||
|
||||
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
|
||||
return redact_text(super().formatException(exc_info))
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""只连接固定本机采购服务的 HTTP 适配器。"""
|
||||
|
||||
from .evidence_sink import HttpEvidenceSink
|
||||
from .http_transport import HttpTransport, LOOPBACK_SERVICE_URL
|
||||
from .task_source import HttpTaskSource
|
||||
|
||||
__all__ = ["HttpEvidenceSink", "HttpTaskSource", "HttpTransport", "LOOPBACK_SERVICE_URL"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""仅上传调用方显式提供的单个 PNG 的窄 EvidenceSink。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError, ValidationError
|
||||
from cmbuyer_client.core.models import AssetReceipt, DeviceCredentials, EvidenceUpload
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
from .http_transport import HttpTransport
|
||||
from .wire import SMALL_RESPONSE_LIMIT, classify_bodyless_error, common_headers, parse_json_response
|
||||
|
||||
|
||||
class HttpEvidenceSink:
|
||||
def __init__(self, transport: HttpTransport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt:
|
||||
boundary = "cmbuyer-" + evidence.upload_key.replace("-", "")
|
||||
marker = ("--" + boundary).encode("ascii")
|
||||
if marker in evidence.content:
|
||||
raise ProtocolRemoteError("multipart_boundary_collision")
|
||||
body = _multipart_body(boundary, evidence)
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
f"/api/v1/tasks/{evidence.task_id}/evidence",
|
||||
common_headers(
|
||||
credentials.device_id,
|
||||
credentials.token.value,
|
||||
"multipart/form-data; boundary=" + boundary,
|
||||
),
|
||||
body,
|
||||
response_limit=SMALL_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status not in (200, 201):
|
||||
classify_bodyless_error(response)
|
||||
try:
|
||||
receipt = AssetReceipt.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_evidence_success_response") from error
|
||||
if (
|
||||
receipt.task_id != evidence.task_id
|
||||
or receipt.attempt_id != evidence.attempt_id
|
||||
or receipt.kind != evidence.kind
|
||||
or receipt.privacy_tier != evidence.privacy_tier
|
||||
or receipt.sha256 != evidence.sha256
|
||||
or receipt.byte_size != len(evidence.content)
|
||||
or receipt.width_px != evidence.width_px
|
||||
or receipt.height_px != evidence.height_px
|
||||
or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(evidence.captured_at)
|
||||
):
|
||||
raise AmbiguousRemoteError("evidence_response_mismatch")
|
||||
return receipt
|
||||
|
||||
|
||||
def _multipart_body(boundary: str, evidence: EvidenceUpload) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
|
||||
def add_field(name: str, value: str) -> None:
|
||||
chunks.extend(
|
||||
(
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"),
|
||||
value.encode("utf-8"),
|
||||
b"\r\n",
|
||||
)
|
||||
)
|
||||
|
||||
add_field("upload_key", evidence.upload_key)
|
||||
add_field("attempt_id", evidence.attempt_id)
|
||||
add_field("kind", evidence.kind)
|
||||
add_field("privacy_tier", evidence.privacy_tier)
|
||||
add_field("sha256", evidence.sha256)
|
||||
add_field("captured_at", evidence.captured_at)
|
||||
chunks.extend(
|
||||
(
|
||||
f"--{boundary}\r\n".encode("ascii"),
|
||||
b'Content-Disposition: form-data; name="file"; filename="evidence.png"\r\n',
|
||||
b"Content-Type: image/png\r\n\r\n",
|
||||
evidence.content,
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode("ascii"),
|
||||
)
|
||||
)
|
||||
return b"".join(chunks)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""无代理、无重定向、无隐藏重试的 localhost HTTP transport。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import http.client
|
||||
import re
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
|
||||
|
||||
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
|
||||
_HOST = "127.0.0.1"
|
||||
_PORT = 8080
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResponse:
|
||||
status: int
|
||||
headers: tuple[tuple[str, str], ...]
|
||||
body: bytes
|
||||
|
||||
def header_values(self, name: str) -> tuple[str, ...]:
|
||||
wanted = name.lower()
|
||||
return tuple(value for key, value in self.headers if key.lower() == wanted)
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""每次调用只创建一个直连 TCP 请求;重试只能由持久化恢复层决定。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_url: str = LOOPBACK_SERVICE_URL,
|
||||
*,
|
||||
timeout_seconds: int = 10,
|
||||
connection_factory: Callable[..., http.client.HTTPConnection] = http.client.HTTPConnection,
|
||||
) -> None:
|
||||
if service_url != LOOPBACK_SERVICE_URL:
|
||||
raise ProtocolRemoteError("service_url_not_allowed")
|
||||
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 120:
|
||||
raise ProtocolRemoteError("invalid_http_timeout")
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._connection_factory = connection_factory
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: Iterable[tuple[str, str]],
|
||||
body: bytes,
|
||||
*,
|
||||
response_limit: int,
|
||||
) -> HttpResponse:
|
||||
if method != "POST" or not path.startswith("/api/v1/") or "?" in path or "#" in path:
|
||||
raise ProtocolRemoteError("invalid_http_target")
|
||||
if not isinstance(body, bytes) or type(response_limit) is not int or response_limit <= 0:
|
||||
raise ProtocolRemoteError("invalid_http_request")
|
||||
header_items = tuple(headers)
|
||||
normalized: dict[str, str] = {}
|
||||
for key, value in header_items:
|
||||
lowered = key.lower()
|
||||
if lowered in normalized or "\r" in key or "\n" in key or "\r" in value or "\n" in value:
|
||||
raise ProtocolRemoteError("invalid_http_headers")
|
||||
normalized[lowered] = value
|
||||
|
||||
connection: http.client.HTTPConnection | None = None
|
||||
result: HttpResponse | None = None
|
||||
failure: str | None = None
|
||||
try:
|
||||
connection = self._connection_factory(_HOST, _PORT, timeout=self._timeout_seconds)
|
||||
connection.request(method, path, body=body, headers={key: value for key, value in header_items})
|
||||
response = connection.getresponse()
|
||||
response_headers = tuple(response.getheaders())
|
||||
content_lengths = tuple(value for key, value in response_headers if key.lower() == "content-length")
|
||||
transfer_encodings = tuple(value for key, value in response_headers if key.lower() == "transfer-encoding")
|
||||
if len(content_lengths) > 1:
|
||||
raise AmbiguousRemoteError("invalid_content_length")
|
||||
if content_lengths and transfer_encodings:
|
||||
raise AmbiguousRemoteError("ambiguous_response_framing")
|
||||
if len(transfer_encodings) > 1 or (
|
||||
transfer_encodings and transfer_encodings[0].lower() != "chunked"
|
||||
):
|
||||
raise AmbiguousRemoteError("invalid_transfer_encoding")
|
||||
declared = content_lengths[0] if content_lengths else None
|
||||
declared_length: int | None = None
|
||||
if declared is not None:
|
||||
if re.fullmatch(r"[0-9]+", declared, flags=re.ASCII) is None:
|
||||
raise AmbiguousRemoteError("invalid_content_length")
|
||||
if len(declared) > 10:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
declared_length = int(declared)
|
||||
if declared_length > response_limit:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
response_body = response.read(response_limit + 1)
|
||||
if len(response_body) > response_limit:
|
||||
raise AmbiguousRemoteError("response_too_large")
|
||||
if declared_length is not None and len(response_body) != declared_length:
|
||||
raise AmbiguousRemoteError("truncated_response")
|
||||
result = HttpResponse(response.status, response_headers, response_body)
|
||||
except AmbiguousRemoteError as error:
|
||||
failure = error.reason
|
||||
except (OSError, TimeoutError, http.client.HTTPException):
|
||||
failure = "http_result_unknown"
|
||||
finally:
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
if result is None:
|
||||
failure = "http_result_unknown"
|
||||
if failure is not None:
|
||||
raise AmbiguousRemoteError(failure)
|
||||
if result is None:
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
return result
|
||||
@@ -0,0 +1,75 @@
|
||||
"""领取与续租的固定 localhost HTTP 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest, RenewResult
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
from .http_transport import HttpTransport
|
||||
from .wire import (
|
||||
JSON_RESPONSE_LIMIT,
|
||||
SMALL_RESPONSE_LIMIT,
|
||||
classify_json_error,
|
||||
common_headers,
|
||||
encode_json,
|
||||
parse_json_response,
|
||||
)
|
||||
|
||||
|
||||
class HttpTaskSource:
|
||||
def __init__(self, transport: HttpTransport) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None:
|
||||
body = encode_json(request.to_wire())
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
"/api/v1/tasks/claim-next",
|
||||
common_headers(credentials.device_id, credentials.token.value, "application/json"),
|
||||
body,
|
||||
response_limit=JSON_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status == 204:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise AmbiguousRemoteError("invalid_empty_claim_response")
|
||||
return None
|
||||
if response.status != 200:
|
||||
classify_json_error(
|
||||
response,
|
||||
allowed_409=frozenset(("idempotency_conflict", "claim_requires_manual")),
|
||||
)
|
||||
try:
|
||||
claimed = ClaimedTask.from_wire(parse_json_response(response, maximum=JSON_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_claim_success_response") from error
|
||||
if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds(claimed.authorization.expires_at):
|
||||
raise AmbiguousRemoteError("invalid_claim_lease")
|
||||
return claimed
|
||||
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult:
|
||||
response = self._transport.request(
|
||||
"POST",
|
||||
f"/api/v1/tasks/{request.task_id}/lease/renew",
|
||||
common_headers(credentials.device_id, credentials.token.value, "application/json"),
|
||||
encode_json(request.to_wire()),
|
||||
response_limit=SMALL_RESPONSE_LIMIT,
|
||||
)
|
||||
if response.status != 200:
|
||||
classify_json_error(
|
||||
response,
|
||||
allowed_409=frozenset(("idempotency_conflict", "claim_not_current")),
|
||||
)
|
||||
try:
|
||||
result = RenewResult.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
|
||||
except ValidationError as error:
|
||||
raise AmbiguousRemoteError("invalid_renew_success_response") from error
|
||||
if (
|
||||
result.task_id != request.task_id
|
||||
or result.attempt_id != request.attempt_id
|
||||
or result.claim_generation != request.claim_generation
|
||||
or rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at)
|
||||
or rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at)
|
||||
):
|
||||
raise AmbiguousRemoteError("renew_response_mismatch")
|
||||
return result
|
||||
@@ -0,0 +1,106 @@
|
||||
"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
ValidationError,
|
||||
)
|
||||
from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads
|
||||
|
||||
from .http_transport import HttpResponse
|
||||
|
||||
|
||||
JSON_REQUEST_LIMIT = 4096
|
||||
JSON_RESPONSE_LIMIT = 32 * 1024
|
||||
SMALL_RESPONSE_LIMIT = 8 * 1024
|
||||
JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8"))
|
||||
|
||||
|
||||
def encode_json(value: Mapping[str, object]) -> bytes:
|
||||
body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
if len(body) > JSON_REQUEST_LIMIT:
|
||||
raise ProtocolRemoteError("request_too_large")
|
||||
return body
|
||||
|
||||
|
||||
def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]:
|
||||
return (
|
||||
("Authorization", "Bearer " + token),
|
||||
("X-CMBuyer-Device-ID", device_id),
|
||||
("Accept", "application/json"),
|
||||
("Content-Type", content_type),
|
||||
)
|
||||
|
||||
|
||||
def parse_json_response(response: HttpResponse, *, maximum: int) -> object:
|
||||
encodings = response.header_values("Content-Encoding")
|
||||
types = response.header_values("Content-Type")
|
||||
if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES:
|
||||
raise ValidationError("invalid_response_content_type")
|
||||
return strict_json_loads(response.body, maximum=maximum)
|
||||
|
||||
|
||||
def require_empty_response(response: HttpResponse) -> None:
|
||||
if response.body or response.header_values("Content-Encoding"):
|
||||
raise ProtocolRemoteError("unexpected_error_body")
|
||||
|
||||
|
||||
def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None:
|
||||
"""抛出错误,不返回。调用方只在非成功状态使用。"""
|
||||
|
||||
if 200 <= response.status <= 299:
|
||||
# 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
# 5xx 无法证明服务端是否在提交响应前完成事务。
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
code = data["error"]
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_conflict_response") from error
|
||||
if not isinstance(code, str) or code not in allowed_409:
|
||||
raise ProtocolRemoteError("unknown_conflict")
|
||||
raise ManualRemoteError(code)
|
||||
expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"}
|
||||
if response.status in expected:
|
||||
try:
|
||||
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
|
||||
except ValidationError as error:
|
||||
raise ProtocolRemoteError("invalid_error_response") from error
|
||||
if data["error"] != expected[response.status]:
|
||||
raise ProtocolRemoteError("unexpected_error_code")
|
||||
raise ProtocolRemoteError(expected[response.status])
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
|
||||
|
||||
def classify_bodyless_error(response: HttpResponse) -> None:
|
||||
if 200 <= response.status <= 299:
|
||||
raise AmbiguousRemoteError("unknown_success_status")
|
||||
if response.status == 401:
|
||||
require_empty_response(response)
|
||||
raise CredentialRemoteError("device_credential_rejected")
|
||||
if response.status == 503 or 500 <= response.status <= 599:
|
||||
raise AmbiguousRemoteError("server_result_unknown")
|
||||
if response.status == 409:
|
||||
require_empty_response(response)
|
||||
raise ManualRemoteError("evidence_conflict")
|
||||
if response.status in (400, 403, 413, 415):
|
||||
require_empty_response(response)
|
||||
raise ProtocolRemoteError("evidence_request_rejected")
|
||||
if 300 <= response.status <= 399:
|
||||
raise ProtocolRemoteError("redirect_rejected")
|
||||
raise ProtocolRemoteError("unexpected_http_status")
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,21 +18,34 @@ class RuntimePaths:
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
state: Path
|
||||
database: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
|
||||
resolved_root = root.expanduser().resolve(strict=False)
|
||||
state = resolved_root / "state"
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
state=state,
|
||||
database=state / "client-state.sqlite3",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "RuntimePaths":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
||||
local_root = Path(local_app_data).expanduser()
|
||||
if not local_root.is_absolute():
|
||||
raise RuntimeError("local_app_data_must_be_absolute")
|
||||
return cls.from_root(local_root / "cmbuyer")
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
|
||||
raise RuntimeError("local_app_data_required")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
@@ -40,3 +54,49 @@ class RuntimePaths:
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
self.state.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalStateRuntime:
|
||||
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
|
||||
|
||||
paths: RuntimePaths
|
||||
mutex: Any
|
||||
store: Any
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
paths: RuntimePaths | None = None,
|
||||
*,
|
||||
mutex_factory: Callable[[Path], Any] | None = None,
|
||||
protector_factory: Callable[[], Any] | None = None,
|
||||
store_factory: Callable[[Path, Any], Any] | None = None,
|
||||
) -> "LocalStateRuntime":
|
||||
from .localstate.protection import DpapiProtector
|
||||
from .localstate.single_instance import NamedMutex
|
||||
from .localstate.store import LocalStateStore
|
||||
|
||||
selected = paths or RuntimePaths.default()
|
||||
selected.ensure_exists()
|
||||
make_mutex = mutex_factory or NamedMutex
|
||||
make_protector = protector_factory or DpapiProtector
|
||||
make_store = store_factory or LocalStateStore
|
||||
mutex = make_mutex(selected.database)
|
||||
try:
|
||||
protector = make_protector()
|
||||
store = make_store(selected.database, protector)
|
||||
except Exception:
|
||||
mutex.close()
|
||||
raise
|
||||
return cls(selected, mutex, store)
|
||||
|
||||
def close(self) -> None:
|
||||
self.mutex.close()
|
||||
|
||||
def __enter__(self) -> "LocalStateRuntime":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
self.close()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""core tests。"""
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ValidationError
|
||||
from cmbuyer_client.core.models import ClaimedTask, SecretToken
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads
|
||||
|
||||
|
||||
TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
TOKEN = "0123456789abcdef" * 4
|
||||
|
||||
|
||||
def claim_wire() -> dict[str, object]:
|
||||
return {
|
||||
"task": {
|
||||
"id": TASK_ID,
|
||||
"version": 3,
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
"goods_id": "937122477375",
|
||||
"sku_color": "黑色CHA(纯棉)",
|
||||
"sku_size": "M(建议100-115)",
|
||||
"quantity": 2,
|
||||
"max_total_price": "30.00",
|
||||
},
|
||||
"authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"},
|
||||
"attempt": {
|
||||
"id": ATTEMPT_ID,
|
||||
"claim_token": TOKEN,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:05:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CoreModelsTests(unittest.TestCase):
|
||||
def test_claim_wire_round_trip_and_secret_repr(self) -> None:
|
||||
claimed = ClaimedTask.from_wire(claim_wire())
|
||||
self.assertEqual(claimed.task.quantity, 2)
|
||||
self.assertNotIn(TOKEN, repr(claimed))
|
||||
self.assertNotIn(TOKEN, repr(SecretToken(TOKEN)))
|
||||
|
||||
def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None:
|
||||
mutations = []
|
||||
for mutate in (
|
||||
lambda value: value["task"].__setitem__("quantity", True),
|
||||
lambda value: value["task"].__setitem__("max_total_price", "30.0"),
|
||||
lambda value: value["task"].__setitem__("max_total_price", "0.00"),
|
||||
lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"),
|
||||
lambda value: value["task"].__setitem__("version", 2),
|
||||
):
|
||||
value = claim_wire()
|
||||
mutate(value)
|
||||
mutations.append(value)
|
||||
for value in mutations:
|
||||
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
|
||||
def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None:
|
||||
bad_values = (
|
||||
b'{"task":{"id":1,"id":2}}',
|
||||
b'{"value":1.0}',
|
||||
b'{"value":NaN}',
|
||||
b'\xef\xbb\xbf{}',
|
||||
b'\xff',
|
||||
('{"value":' + "9" * 5000 + '}').encode(),
|
||||
)
|
||||
for raw in bad_values:
|
||||
with self.subTest(raw=raw), self.assertRaises(ValidationError):
|
||||
strict_json_loads(raw, maximum=1024)
|
||||
self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1})
|
||||
|
||||
def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None:
|
||||
equal = (
|
||||
"2026-08-04T09:01:00.1Z",
|
||||
"2026-08-04T09:01:00.100000Z",
|
||||
"2026-08-04T09:01:00.100000000Z",
|
||||
)
|
||||
self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1)
|
||||
ordered = (
|
||||
"2026-08-04T09:01:00Z",
|
||||
"2026-08-04T09:01:00.000001Z",
|
||||
"2026-08-04T09:01:00.0000011Z",
|
||||
"2026-08-04T09:01:00.000001101Z",
|
||||
"2026-08-04T09:01:01Z",
|
||||
)
|
||||
self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered))
|
||||
|
||||
def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None:
|
||||
value = claim_wire()
|
||||
value["task"]["max_total_price"] = "0.01"
|
||||
self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01")
|
||||
for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
changed = claim_wire()
|
||||
changed["task"]["max_total_price"] = invalid
|
||||
ClaimedTask.from_wire(changed)
|
||||
|
||||
wide = claim_wire()
|
||||
wide_goods = "1" * 33
|
||||
wide["task"].update(
|
||||
goods_id=wide_goods,
|
||||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + wide_goods,
|
||||
max_total_price="1" * 31 + ".00",
|
||||
quantity=2_147_483_648,
|
||||
)
|
||||
self.assertEqual(ClaimedTask.from_wire(wide).task.quantity, 2_147_483_648)
|
||||
for invalid_goods in ("123", "1٢3"):
|
||||
changed = claim_wire()
|
||||
changed["task"]["goods_id"] = invalid_goods
|
||||
changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods
|
||||
with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(changed)
|
||||
too_large = claim_wire()
|
||||
too_large["task"]["quantity"] = 9_223_372_036_854_775_808
|
||||
with self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(too_large)
|
||||
|
||||
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
|
||||
for escaped in (r'"\ud800"', r'"\udc00"'):
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = json.loads(escaped)
|
||||
with self.subTest(escaped=escaped), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = json.loads(r'"\ud83d\ude00"')
|
||||
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
|
||||
|
||||
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
|
||||
for title in ("", " \t\r\n", "\u3000", " \u3000\t"):
|
||||
value = claim_wire()
|
||||
value["task"]["title"] = title
|
||||
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
|
||||
ClaimedTask.from_wire(value)
|
||||
@@ -0,0 +1 @@
|
||||
"""localstate tests。"""
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client"
|
||||
SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate"))
|
||||
|
||||
|
||||
class StaticBoundaryTests(unittest.TestCase):
|
||||
def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None:
|
||||
forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd")
|
||||
forbidden_text = (
|
||||
"ResultSink",
|
||||
"/events",
|
||||
"/fail",
|
||||
"/submission-fence",
|
||||
"/result",
|
||||
"click_permitted",
|
||||
)
|
||||
for directory in SCOPED:
|
||||
for path in directory.glob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(text)
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imports.append(node.module)
|
||||
for module in forbidden_modules:
|
||||
self.assertFalse(any(name.startswith(module) for name in imports), (path, module))
|
||||
for value in forbidden_text:
|
||||
self.assertNotIn(value, text, (path, value))
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError
|
||||
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
|
||||
from cmbuyer_client.localstate.facade import DurableClientGateway
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.localstate.store import LocalStateStore
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire
|
||||
from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response
|
||||
|
||||
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class InspectingSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
self.mode = "success"
|
||||
self.request_ids: list[str] = []
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
self.calls += 1
|
||||
self.request_ids.append(request.claim_request_id)
|
||||
# HTTP 适配器被调用时,幂等请求必须已经 durable。
|
||||
self.assert_pending(request.claim_request_id)
|
||||
if self.mode == "ambiguous":
|
||||
raise AmbiguousRemoteError("http_result_unknown")
|
||||
if self.mode == "manual":
|
||||
raise ManualRemoteError("claim_requires_manual")
|
||||
return ClaimedTask.from_wire(claim_wire())
|
||||
|
||||
def renew(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def assert_pending(self, request_id: str) -> None:
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id:
|
||||
raise AssertionError("HTTP happened before durable prepare")
|
||||
|
||||
|
||||
class InspectingSink:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def upload(self, credentials, upload):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key:
|
||||
raise AssertionError("HTTP happened before durable evidence slot")
|
||||
return AssetReceipt(
|
||||
"63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
upload.task_id,
|
||||
upload.attempt_id,
|
||||
upload.kind,
|
||||
upload.privacy_tier,
|
||||
upload.sha256,
|
||||
len(upload.content),
|
||||
"image/png",
|
||||
1,
|
||||
1,
|
||||
upload.captured_at,
|
||||
)
|
||||
|
||||
|
||||
class InspectingRenewSource:
|
||||
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
|
||||
self.store = store
|
||||
self.profile_id = profile_id
|
||||
self.calls = 0
|
||||
|
||||
def claim_next(self, credentials, request):
|
||||
raise AssertionError("not used")
|
||||
|
||||
def renew(self, credentials, request):
|
||||
self.calls += 1
|
||||
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
|
||||
if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id:
|
||||
raise AssertionError("HTTP happened before durable renew")
|
||||
return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at)
|
||||
|
||||
|
||||
class DurableClientGatewayTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.database = Path(self.directory.name) / "client-state.sqlite3"
|
||||
self.store = LocalStateStore(
|
||||
self.database,
|
||||
FakeProtector(),
|
||||
now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
profile = ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
DEVICE_ID,
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"192.168.0.173:5555",
|
||||
"wifi",
|
||||
)
|
||||
self.store.save_profile(profile, SecretToken(DEVICE_TOKEN))
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
self.source = InspectingSource(self.store, PROFILE)
|
||||
self.sink = InspectingSink(self.store, PROFILE)
|
||||
self.gateway = DurableClientGateway(self.store, self.source, self.sink)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None:
|
||||
self.source.mode = "ambiguous"
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.source.mode = "success"
|
||||
claimed = self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(self.source.request_ids[0], self.source.request_ids[1])
|
||||
self.assertIsNotNone(self.store.active_claim(PROFILE))
|
||||
|
||||
def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None:
|
||||
transport = FakeTransport(response(201, claim_wire()))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_claim
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, claim_wire())
|
||||
claimed = gateway.claim_next(PROFILE)
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
sent = [call[3] for call in transport.calls]
|
||||
self.assertEqual(sent[0], sent[1])
|
||||
|
||||
def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.claim_next(PROFILE)
|
||||
request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, claim_wire())
|
||||
gateway.claim_next(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id)
|
||||
|
||||
def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None:
|
||||
original_connect = self.store._connect
|
||||
calls = 0
|
||||
|
||||
def fail_second_connection():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
connection = original_connect()
|
||||
if calls == 2:
|
||||
connection.set_authorizer(
|
||||
lambda action, table, *_: sqlite3.SQLITE_DENY
|
||||
if action == sqlite3.SQLITE_READ and table == "profiles"
|
||||
else sqlite3.SQLITE_OK
|
||||
)
|
||||
return connection
|
||||
|
||||
with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection):
|
||||
with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 0)
|
||||
self.assertNotIn(str(self.database), repr(captured.exception))
|
||||
|
||||
def test_renew_is_durable_before_http(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
source = InspectingRenewSource(self.store, PROFILE)
|
||||
gateway = DurableClientGateway(self.store, source, self.sink)
|
||||
result = gateway.renew(PROFILE)
|
||||
self.assertEqual(result.attempt_id, ATTEMPT_ID)
|
||||
self.assertEqual(source.calls, 1)
|
||||
|
||||
def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(response(201, payload))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_renew
|
||||
self.assertIsNotNone(pending)
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
payload = {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.renew(PROFILE)
|
||||
self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
transport.response = response(200, payload)
|
||||
gateway.renew(PROFILE)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "unknown.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
pending = self.store.recovery_snapshot(PROFILE).pending_evidence
|
||||
self.assertEqual(len(pending), 1)
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "credential.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
|
||||
transport = FakeTransport(HttpResponse(401, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
with self.assertRaises(CredentialRemoteError):
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1)
|
||||
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
receipt = {
|
||||
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
|
||||
gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
|
||||
|
||||
def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "exact-replay.png"
|
||||
path.write_bytes(PNG)
|
||||
transport = FakeTransport(HttpResponse(202, (), b""))
|
||||
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
|
||||
first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z")
|
||||
equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z")
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, first)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
gateway.upload_evidence(PROFILE, equivalent)
|
||||
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
|
||||
|
||||
def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None:
|
||||
self.source.mode = "manual"
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
with self.assertRaises(Exception):
|
||||
self.gateway.claim_next(PROFILE)
|
||||
self.assertEqual(self.source.calls, 1)
|
||||
|
||||
def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None:
|
||||
self.gateway.claim_next(PROFILE)
|
||||
path = Path(self.directory.name) / "one.png"
|
||||
path.write_bytes(PNG)
|
||||
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z")
|
||||
first = self.gateway.upload_evidence(PROFILE, asset)
|
||||
path.write_bytes(PNG + b"changed")
|
||||
second = self.gateway.upload_evidence(PROFILE, asset)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(self.sink.calls, 1)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import ProtectionError
|
||||
from cmbuyer_client.localstate.protection import DpapiProtector
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证")
|
||||
class DpapiProtectorTests(unittest.TestCase):
|
||||
def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None:
|
||||
protector = DpapiProtector()
|
||||
plaintext = b"a" * 64
|
||||
device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ciphertext = protector.protect(plaintext, purpose=device_purpose)
|
||||
self.assertNotIn(plaintext, ciphertext)
|
||||
self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext)
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(ciphertext, purpose=claim_purpose)
|
||||
damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,))
|
||||
with self.assertRaises(ProtectionError):
|
||||
protector.unprotect(damaged, purpose=device_purpose)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import SingleInstanceError
|
||||
from cmbuyer_client.localstate.single_instance import NamedMutex
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证")
|
||||
class NamedMutexTests(unittest.TestCase):
|
||||
def test_second_process_for_same_database_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "state.sqlite3"
|
||||
first = NamedMutex(database)
|
||||
try:
|
||||
with self.assertRaises(SingleInstanceError):
|
||||
NamedMutex(database)
|
||||
code = (
|
||||
"from pathlib import Path; "
|
||||
"from cmbuyer_client.localstate.single_instance import NamedMutex; "
|
||||
"from cmbuyer_client.core.errors import SingleInstanceError; "
|
||||
f"p=Path({str(database)!r}); "
|
||||
"\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)"
|
||||
)
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src")
|
||||
result = subprocess.run([sys.executable, "-c", code], env=environment, check=False)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
with NamedMutex(database):
|
||||
pass
|
||||
@@ -0,0 +1,786 @@
|
||||
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()))
|
||||
@@ -0,0 +1 @@
|
||||
"""remote tests。"""
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
|
||||
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN
|
||||
from tests.remote.test_task_source import DEVICE_ID, FakeTransport
|
||||
|
||||
|
||||
UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class EvidenceSinkTests(unittest.TestCase):
|
||||
def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.asset_id, ASSET_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
body = transport.calls[0][3]
|
||||
self.assertIn(b'filename="evidence.png"', body)
|
||||
self.assertNotIn(b"C:\\", body)
|
||||
self.assertNotIn(b"manifest", body)
|
||||
self.assertNotIn(b".xml", body)
|
||||
names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body)
|
||||
self.assertEqual(
|
||||
names,
|
||||
[b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"],
|
||||
)
|
||||
self.assertEqual(body.count(b'filename="evidence.png"'), 1)
|
||||
self.assertNotIn(b"claim_token", body)
|
||||
self.assertNotIn(b"session_id", body)
|
||||
boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1]
|
||||
self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", ""))
|
||||
|
||||
def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG)
|
||||
payload = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": digest,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00.12Z",
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
for status in (200, 201):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw))
|
||||
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z")
|
||||
|
||||
def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None:
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
with self.assertRaises(ValidationError):
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG)
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
def test_unknown_2xx_is_ambiguous(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
for status in (202, 204, 206):
|
||||
with self.subTest(status=status):
|
||||
transport = FakeTransport(HttpResponse(status, (), b""))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
|
||||
def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None:
|
||||
marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode()
|
||||
collision_content = PNG + marker
|
||||
collision = EvidenceUpload(
|
||||
TASK_ID,
|
||||
UPLOAD_ID,
|
||||
ATTEMPT_ID,
|
||||
hashlib.sha256(collision_content).hexdigest(),
|
||||
"2026-08-04T09:01:00Z",
|
||||
collision_content,
|
||||
)
|
||||
transport = FakeTransport(HttpResponse(500, (), b""))
|
||||
from cmbuyer_client.core.errors import ProtocolRemoteError
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision)
|
||||
self.assertEqual(transport.calls, [])
|
||||
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
mismatch = {
|
||||
"asset_id": ASSET_ID,
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": "f" * 64,
|
||||
"byte_size": len(PNG),
|
||||
"content_type": "image/png",
|
||||
"width_px": 1,
|
||||
"height_px": 1,
|
||||
"captured_at": "2026-08-04T09:01:00Z",
|
||||
}
|
||||
raw = json.dumps(mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
dimension_mismatch = dict(mismatch)
|
||||
dimension_mismatch["sha256"] = digest
|
||||
dimension_mismatch["width_px"] = 2
|
||||
raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode()
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
|
||||
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
|
||||
)
|
||||
|
||||
def test_evidence_error_status_matrix(self) -> None:
|
||||
digest = hashlib.sha256(PNG).hexdigest()
|
||||
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
|
||||
credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
|
||||
cases = (
|
||||
(401, CredentialRemoteError),
|
||||
(400, ProtocolRemoteError),
|
||||
(403, ProtocolRemoteError),
|
||||
(409, ManualRemoteError),
|
||||
(413, ProtocolRemoteError),
|
||||
(415, ProtocolRemoteError),
|
||||
(500, AmbiguousRemoteError),
|
||||
(503, AmbiguousRemoteError),
|
||||
)
|
||||
for status, expected in cases:
|
||||
with self.subTest(status=status), self.assertRaises(expected):
|
||||
HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import http.client
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.remote.http_transport import HttpTransport
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
|
||||
self.body = body
|
||||
self.headers = headers or [("Content-Type", "application/json")]
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return str(len(self.body)) if name == "Content-Length" else None
|
||||
|
||||
def getheaders(self) -> list[tuple[str, str]]:
|
||||
return self.headers
|
||||
|
||||
def read(self, maximum: int) -> bytes:
|
||||
return self.body[:maximum]
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, host: str, port: int, timeout: int) -> None:
|
||||
self.created = (host, port, timeout)
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
self.sent_headers: dict[str, str] = {}
|
||||
self.response = FakeResponse()
|
||||
|
||||
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
|
||||
self.calls += 1
|
||||
self.sent_headers = headers
|
||||
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class HttpTransportTests(unittest.TestCase):
|
||||
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
|
||||
result = HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(result.status, 200)
|
||||
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
self.assertTrue(made[0].closed)
|
||||
|
||||
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
|
||||
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
|
||||
HttpTransport(url)
|
||||
|
||||
def test_network_failure_is_ambiguous_without_retry(self) -> None:
|
||||
class Broken(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise OSError("offline")
|
||||
|
||||
made: list[Broken] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> Broken:
|
||||
connection = Broken(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
|
||||
)
|
||||
self.assertEqual(made[0].calls, 1)
|
||||
|
||||
def test_generator_headers_and_content_length_framing(self) -> None:
|
||||
made: list[FakeConnection] = []
|
||||
|
||||
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||||
connection = FakeConnection(*args, **kwargs)
|
||||
made.append(connection)
|
||||
return connection
|
||||
|
||||
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
|
||||
HttpTransport(connection_factory=factory).request(
|
||||
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
|
||||
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
|
||||
|
||||
legal = FakeConnection("127.0.0.1", 8080, 10)
|
||||
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
|
||||
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted.body, b"{}")
|
||||
chunked = FakeConnection("127.0.0.1", 8080, 10)
|
||||
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
|
||||
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
self.assertEqual(accepted_chunked.body, b"{}")
|
||||
|
||||
cases = (
|
||||
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
|
||||
([('Transfer-Encoding', 'gzip')], b'{}'),
|
||||
([('Transfer-Encoding', ' chunked ')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
|
||||
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
|
||||
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
|
||||
([("Content-Length", "+2")], b"{}"),
|
||||
([("Content-Length", "-0")], b""),
|
||||
([("Content-Length", "2x")], b"{}"),
|
||||
([("Content-Length", "3")], b"{}"),
|
||||
([("Content-Length", "1")], b"{}"),
|
||||
([("Content-Length", "999")], b"{}"),
|
||||
([], b"0123456789"),
|
||||
)
|
||||
for response_headers, body in cases:
|
||||
with self.subTest(headers=response_headers, body=body):
|
||||
connection = FakeConnection("127.0.0.1", 8080, 10)
|
||||
connection.response = FakeResponse(body, response_headers)
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
|
||||
token = ("a" * 64).encode()
|
||||
|
||||
class Incomplete(FakeResponse):
|
||||
def read(self, maximum: int) -> bytes:
|
||||
raise http.client.IncompleteRead(token, 1)
|
||||
|
||||
class Connection(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return Incomplete()
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
raise OSError("close failed")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTransport(connection_factory=Connection).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
|
||||
)
|
||||
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
|
||||
|
||||
class Timeout(FakeConnection):
|
||||
def getresponse(self) -> FakeResponse:
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTransport(connection_factory=Timeout).request(
|
||||
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||||
)
|
||||
|
||||
|
||||
def _exception_graph(error: BaseException) -> str:
|
||||
seen: set[int] = set()
|
||||
values: list[str] = []
|
||||
pending: list[object] = [error]
|
||||
while pending:
|
||||
value = pending.pop()
|
||||
if id(value) in seen:
|
||||
continue
|
||||
seen.add(id(value))
|
||||
values.append(repr(value))
|
||||
if isinstance(value, BaseException):
|
||||
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
|
||||
pending.extend(value.__dict__.values())
|
||||
return "\n".join(values)
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
|
||||
from cmbuyer_client.core.models import ClaimRequest, DeviceCredentials, RenewRequest, SecretToken
|
||||
from cmbuyer_client.remote.http_transport import HttpResponse
|
||||
from cmbuyer_client.remote.task_source import HttpTaskSource
|
||||
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
|
||||
from tests.remote.test_http_transport import _exception_graph
|
||||
|
||||
|
||||
DEVICE_ID = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
SESSION_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
REQUEST_ID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
RENEW_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, response: HttpResponse) -> None:
|
||||
self.response = response
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
|
||||
def request(self, *args: object, **kwargs: object) -> HttpResponse:
|
||||
self.calls.append(args + (kwargs,))
|
||||
return self.response
|
||||
|
||||
|
||||
def response(status: int, value: object | None = None) -> HttpResponse:
|
||||
body = b"" if value is None else json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
headers = () if value is None else (("Content-Type", "application/json; charset=utf-8"),)
|
||||
return HttpResponse(status, headers, body)
|
||||
|
||||
|
||||
class TaskSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
|
||||
|
||||
def test_claim_success_and_empty_each_send_once_with_exact_headers(self) -> None:
|
||||
transport = FakeTransport(response(200, claim_wire()))
|
||||
claimed = HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(claimed.task.id, TASK_ID)
|
||||
self.assertEqual(len(transport.calls), 1)
|
||||
args = transport.calls[0]
|
||||
self.assertEqual(args[1], "/api/v1/tasks/claim-next")
|
||||
headers = dict(args[2])
|
||||
self.assertEqual(headers["Authorization"], "Bearer " + TOKEN)
|
||||
self.assertEqual(headers["X-CMBuyer-Device-ID"], DEVICE_ID)
|
||||
|
||||
empty = FakeTransport(response(204))
|
||||
self.assertIsNone(HttpTaskSource(empty).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)))
|
||||
self.assertEqual(len(empty.calls), 1)
|
||||
|
||||
def test_invalid_2xx_is_unknown_and_redirect_is_not_followed(self) -> None:
|
||||
malformed = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), b'{"task":'))
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(malformed).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(malformed.calls), 1)
|
||||
|
||||
secret_body = b'{"claim_token":"' + TOKEN.encode()
|
||||
leaking = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), secret_body))
|
||||
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||||
HttpTaskSource(leaking).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertNotIn(TOKEN, _exception_graph(captured.exception))
|
||||
|
||||
for status in (201, 202, 206):
|
||||
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(status, claim_wire()))).claim_next(
|
||||
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
)
|
||||
|
||||
redirect = FakeTransport(HttpResponse(302, (("Location", "http://example.invalid"),), b""))
|
||||
with self.assertRaises(ProtocolRemoteError):
|
||||
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
self.assertEqual(len(redirect.calls), 1)
|
||||
|
||||
def test_fixed_conflict_and_renew_cas(self) -> None:
|
||||
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
|
||||
with self.assertRaises(ManualRemoteError):
|
||||
HttpTaskSource(conflict).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
|
||||
|
||||
request = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T09:05:00Z", "2026-08-04T10:00:00Z")
|
||||
renewed = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:06:00Z"})
|
||||
result = HttpTaskSource(FakeTransport(renewed)).renew(self.credentials, request)
|
||||
self.assertEqual(result.claim_generation, 1)
|
||||
|
||||
capped = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T10:00:00.000000000Z", "2026-08-04T10:00:00Z")
|
||||
capped_result = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00Z"})
|
||||
self.assertEqual(HttpTaskSource(FakeTransport(capped_result)).renew(self.credentials, capped).lease_expires_at, "2026-08-04T10:00:00Z")
|
||||
|
||||
beyond_cap = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00.000000001Z"})
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(beyond_cap)).renew(self.credentials, request)
|
||||
|
||||
stale = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:04:00Z"})
|
||||
with self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(stale)).renew(self.credentials, request)
|
||||
for status in (201, 204):
|
||||
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(status, None if status == 204 else {
|
||||
"task_id": TASK_ID,
|
||||
"attempt_id": ATTEMPT_ID,
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:06:00Z",
|
||||
}))).renew(self.credentials, request)
|
||||
|
||||
def test_claim_and_renew_error_status_matrix(self) -> None:
|
||||
claim_request = ClaimRequest(SESSION_ID, REQUEST_ID)
|
||||
claim_cases = (
|
||||
(HttpResponse(401, (), b""), CredentialRemoteError),
|
||||
(response(400, {"error": "invalid_request"}), ProtocolRemoteError),
|
||||
(HttpResponse(403, (), b""), ProtocolRemoteError),
|
||||
(response(413, {"error": "request_too_large"}), ProtocolRemoteError),
|
||||
(response(415, {"error": "unsupported_media_type"}), ProtocolRemoteError),
|
||||
(HttpResponse(500, (), b""), AmbiguousRemoteError),
|
||||
(HttpResponse(503, (), b""), AmbiguousRemoteError),
|
||||
(HttpResponse(418, (), b""), ProtocolRemoteError),
|
||||
)
|
||||
for wire_response, expected in claim_cases:
|
||||
with self.subTest(status=wire_response.status), self.assertRaises(expected):
|
||||
HttpTaskSource(FakeTransport(wire_response)).claim_next(self.credentials, claim_request)
|
||||
|
||||
renew_request = RenewRequest(
|
||||
TASK_ID,
|
||||
RENEW_ID,
|
||||
SESSION_ID,
|
||||
ATTEMPT_ID,
|
||||
1,
|
||||
SecretToken(TOKEN),
|
||||
"2026-08-04T09:05:00Z",
|
||||
"2026-08-04T10:00:00Z",
|
||||
)
|
||||
for code in ("idempotency_conflict", "claim_not_current"):
|
||||
with self.subTest(code=code), self.assertRaises(ManualRemoteError):
|
||||
HttpTaskSource(FakeTransport(response(409, {"error": code}))).renew(self.credentials, renew_request)
|
||||
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
|
||||
self.assertNotIn("not-for-log", content)
|
||||
self.assertNotIn("13900139000", content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
|
||||
token = "a" * 64
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory))
|
||||
logger = configure_application_logger(paths)
|
||||
try:
|
||||
try:
|
||||
raise RuntimeError("credential=" + token)
|
||||
except RuntimeError:
|
||||
logger.exception("Authorization: Bearer %s bare=%s", token, token)
|
||||
for handler in logger.handlers:
|
||||
handler.flush()
|
||||
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.assertNotIn(token, content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
|
||||
|
||||
|
||||
class RuntimePathsTests(unittest.TestCase):
|
||||
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(paths.logs.is_dir())
|
||||
self.assertTrue(paths.artifacts.is_dir())
|
||||
self.assertTrue(paths.state.is_dir())
|
||||
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
|
||||
|
||||
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
runtime = LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=lambda: events.append("protector") or object(),
|
||||
store_factory=lambda path, protector: events.append("store") or object(),
|
||||
)
|
||||
runtime.close()
|
||||
self.assertEqual(events, ["mutex", "protector", "store", "close"])
|
||||
|
||||
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Mutex:
|
||||
def __init__(self, path: Path) -> None:
|
||||
events.append("mutex")
|
||||
|
||||
def close(self) -> None:
|
||||
events.append("close")
|
||||
|
||||
def fail() -> object:
|
||||
raise RuntimeError("failed")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
|
||||
LocalStateRuntime.open(
|
||||
RuntimePaths.from_root(Path(directory)),
|
||||
mutex_factory=Mutex,
|
||||
protector_factory=fail,
|
||||
)
|
||||
self.assertEqual(events, ["mutex", "close"])
|
||||
|
||||
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
|
||||
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
|
||||
RuntimePaths.default()
|
||||
|
||||
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
|
||||
paths = RuntimePaths.from_root(Path("relative-runtime"))
|
||||
self.assertTrue(paths.root.is_absolute())
|
||||
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
|
||||
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
|
||||
RuntimePaths.default()
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
|
||||
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
|
||||
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
|
||||
| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 |
|
||||
| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 |
|
||||
| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 |
|
||||
| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 |
|
||||
| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session |
|
||||
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
|
||||
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
|
||||
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
|
||||
@@ -55,6 +58,10 @@
|
||||
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
|
||||
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
|
||||
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
|
||||
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
|
||||
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
|
||||
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
|
||||
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
|
||||
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
+20
-1
@@ -381,6 +381,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
|
||||
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
|
||||
|
||||
#### 采购工具本地恢复状态
|
||||
|
||||
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
|
||||
以服务端“单设备最多一个 claim”替代本机单实例。
|
||||
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
|
||||
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
|
||||
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
|
||||
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
|
||||
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
|
||||
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
|
||||
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
|
||||
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
|
||||
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
|
||||
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
|
||||
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
|
||||
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
|
||||
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
|
||||
|
||||
### 5.4 证据分层
|
||||
|
||||
| 数据 | 位置 | 边界 |
|
||||
@@ -458,7 +476,8 @@ cmbuyer/
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
|
||||
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
|
||||
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
|
||||
|
||||
## 九、架构纪律
|
||||
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
| --- | --- | --- | --- |
|
||||
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
|
||||
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
|
||||
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | 严格 claim/renew/evidence HTTP、DPAPI/SQLite、单实例和原子恢复槽;不伪造完整 ResultSink |
|
||||
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | `http.client` 单次回环请求、DPAPI/SQLite append-only 状态图、Global 单实例和 durable gateway;不伪造完整 ResultSink |
|
||||
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
|
||||
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
|
||||
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
|
||||
|
||||
+36
-11
@@ -372,23 +372,48 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
|
||||
## 三、采购工具本地模块合约
|
||||
|
||||
### `TaskSource` / `ResultSink`
|
||||
### T-303 已实现端口
|
||||
|
||||
```python
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
|
||||
def renew_lease(self, claim: Claim) -> Lease: ...
|
||||
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
|
||||
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
|
||||
|
||||
class ResultSink(Protocol):
|
||||
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
|
||||
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
|
||||
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
|
||||
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
|
||||
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
|
||||
class EvidenceSink(Protocol):
|
||||
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
|
||||
```
|
||||
|
||||
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
|
||||
业务代码不能手工 new 一个许可。
|
||||
T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
|
||||
events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把
|
||||
同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复
|
||||
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
|
||||
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
|
||||
|
||||
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零);wire
|
||||
整数为正 int64,拒绝 bool。claim 成功响应总上限 32 KiB;因此客户端不额外发明 goods/title/SKU/金额
|
||||
的单字段业务上限。RFC3339Nano 按 0--9 位小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
|
||||
|
||||
### 本地恢复合约
|
||||
|
||||
- 数据库固定为 `%LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3`,WAL + `synchronous=FULL`;Windows
|
||||
缺少 `LOCALAPPDATA` 时失败,不回退到 home 创建第二套状态。
|
||||
- device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定
|
||||
profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有
|
||||
pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer;
|
||||
idle 时切换 device id 也必须同时提供新 token。
|
||||
- `Global\cmbuyer-<db-path-hash>` named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows
|
||||
session 只允许一个采购工具进程。
|
||||
- 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。
|
||||
- polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用
|
||||
`closed_at IS NULL` partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、
|
||||
snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。
|
||||
- evidence 槽唯一键是 `(attempt_id, kind)`。首次发送前固定显式路径的 regular/non-reparse 文件 identity、
|
||||
size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放
|
||||
相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt
|
||||
尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。
|
||||
|
||||
完整 `ResultSink` 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;`SubmissionPermit` 只能由
|
||||
服务端首次明确成功响应构造,业务代码不能手工创建。
|
||||
|
||||
### 真机能力分层
|
||||
|
||||
|
||||
+5
-1
@@ -28,7 +28,7 @@ write_paths:
|
||||
- docs/06-tasks.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=36 synced=2026-08-04T15:26:26Z sha256=c2a6a90372ea64ffd52684cc3832d25cfb6c0160bfe03463b7d67351964dc72c -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=36 synced=2026-08-04T16:54:47Z sha256=440e657c80301d7ef4cfcddd8a20bcd5b5672c06df30953246766ac02c67007d -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租任务;T-204 已提供受控截图上传接口。但 client 当前只有真机能力和最小窗口,没有 HTTP 抽象、设备凭据存储或断网/重启幂等状态。T-303 建立 `HttpTaskSource`、窄 `HttpEvidenceSink` 与 Windows 本地恢复底座;事件/fail/fence/result 尚分别依赖 T-205/T-208,本任务不伪造完整 `HttpResultSink` 或占位请求。
|
||||
@@ -78,6 +78,10 @@ T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租
|
||||
### 2026-08-04T15:25:33Z · ila
|
||||
|
||||
2026-08-04 开始 T-303:基于 main@3a0a41d,在独立 worktree 实现客户端 HTTP 任务源、证据 sink 与可恢复本地状态;严格限制为 localhost 服务端契约,不触碰真机、页面操作、提交订单或付款能力。
|
||||
|
||||
### 2026-08-04T16:54:36Z · ila
|
||||
|
||||
2026-08-04 T-303 实现完成并进入审阅冻结:已落地严格 localhost HttpTaskSource/HttpEvidenceSink、durable gateway、DPAPI 身份绑定密文、SQLite append-only 状态图与 Global named mutex。claim/renew/evidence 均先持久化后最多一次 HTTP;business snapshot、renew response、evidence receipt 使用不可变约束与摘要,renew 从初始租约锚重放;证据绑定 PNG IHDR 尺寸并精确重放首次 metadata。独立对抗审计已通过。验证:client unittest 189/189,compileall、diff-check、agent-context 通过;根 init.ps1 通过 admin test/vet/build、client install/test/compile 与上下文门禁。任务保持 DOING,等待大脑固定提交复审,不标 DONE。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user