"""Cookie gate: per-account login-state machine injected before workflows.""" from __future__ import annotations import json from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace from gyxx_flow.adapters.browser import BrowserCookieStore from gyxx_flow.adapters.integration import AccountBinding from gyxx_flow.catalog import WorkflowCatalog, WorkflowEntry from gyxx_flow.cookie_gate import ( GATE_MODE_BLOCKING, GATE_MODE_DEFER, GATE_MODE_RECOVER, CookieGate, CookieGateRecoveryManager, apply_cookie_gates, gate_enabled, ) from gyxx_flow.core.config import Settings from gyxx_flow.core.context import RunContext from gyxx_flow.core.layout import DataLayout from gyxx_flow.core.locks import LockManager from gyxx_flow.core.records import RunJournal from gyxx_flow.workflow.engine import WorkflowEngine from gyxx_flow.workflow.model import StepDefinition, WorkflowDefinition from gyxx_flow.workflow.steps import StepExecution _NOW = datetime(2026, 10, 8, 12, 0, 0, tzinfo=timezone.utc) def _settings(tmp_path: Path) -> Settings: return Settings(project_root=tmp_path, data_root=tmp_path / "var") def _account(tmp_path: Path, account_id: str, **overrides: object) -> AccountBinding: vault = tmp_path / "state" / "accounts" / account_id values: dict[str, object] = { "account_id": account_id, "cdp_port": 22000, "cdp_url": "http://127.0.0.1:22000", "profile_dir": vault / "profile", "cookie_file": vault / "cookies.json", "storage_state_file": vault / "storage_state.json", "required_cookie_domains": ("example.com",), "required_cookie_names": ("sessionid",), "login_identity_markers": ("示例店铺",), "keepalive_enabled": True, "keepalive_url": "https://example.com/home", "keepalive_interval_seconds": 900, } values.update(overrides) return AccountBinding(**values) # type: ignore[arg-type] def _fake_catalog(*accounts: AccountBinding, bindings: dict[str, str] | None = None): by_id = {account.account_id: account for account in accounts} binding_accounts = bindings or {} account_tuple = tuple(accounts) class _Catalog: accounts = account_tuple def account_for(self, account_id: str) -> AccountBinding: return by_id[account_id] def binding_for(self, binding_id: str): return SimpleNamespace(account=binding_accounts.get(binding_id, "")) return _Catalog() def _write_valid_vault(account: AccountBinding) -> None: store = BrowserCookieStore(account.cookie_file, account.storage_state_file) store.save_cookies( [ { "name": "sessionid", "value": "live-session", "domain": ".example.com", "path": "/", "expires": _NOW.timestamp() + 86_400, } ] ) def _write_keepalive_status( account: AccountBinding, payload: dict[str, object] | None, ) -> None: path = account.cookie_file.with_name("keepalive_status.json") if payload is None: path.unlink(missing_ok=True) return path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload), encoding="utf-8") def _gate( tmp_path: Path, *accounts: AccountBinding, probe: dict[str, object] | None = None, environment: dict[str, str] | None = None, sent: list[dict[str, object]] | None = None, bindings: dict[str, str] | None = None, ) -> CookieGate: probes = probe or {"status": "expired"} keepalive = SimpleNamespace( refresh_account=lambda account_id: dict(probes, account_id=account_id) ) sender_calls = sent if sent is not None else [] def _sender(**kwargs: object) -> dict[str, object]: sender_calls.append(dict(kwargs)) return {"message_id": "om_fake"} catalog = _fake_catalog(*accounts, bindings=bindings) gate = CookieGate( _settings(tmp_path), catalog=catalog, keepalive=keepalive, # type: ignore[arg-type] environment=environment or {}, now=lambda: _NOW, sender=_sender, ) gate._workflow_catalog = WorkflowCatalog(timezone="UTC", workflows=(), schedules=()) return gate def _context(**overrides: object) -> RunContext: values: dict[str, object] = { "workflow_id": "shop.demo.daily", "business_date": "2026-10-08", "shadow": False, "now": _NOW, "random_suffix": "aaaa", } values.update(overrides) return RunContext.create(**values) # type: ignore[arg-type] def _noop_outcome(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001 return StepExecution(exit_code=0) def _deny_outcome(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001 raise AssertionError("resolution must not run actions") class _PassAction: execute = staticmethod(_noop_outcome) class _BoundAction: """Duck-typed DeferredModuleCommandStep for account resolution tests.""" def __init__(self, command_entry: str = "collect.py") -> None: self.entry = SimpleNamespace(module="shop_intelligence", entry="fallback.py") self.command_entry = command_entry self.execute = _deny_outcome class _RecordingAction: def __init__(self, name: str, sink: list[str], command_entry: str | None = None) -> None: def run(*, context, timeout_seconds, dry_run) -> StepExecution: # noqa: ANN001 sink.append(name) return StepExecution(exit_code=0) self.execute = run if command_entry is not None: self.entry = SimpleNamespace( module="shop_intelligence", entry="fallback.py" ) self.command_entry = command_entry # ---------------------------------------------------------------------- # Mode classification # ---------------------------------------------------------------------- def test_gate_mode_classifies_accounts(tmp_path: Path) -> None: gate = _gate( tmp_path, _account(tmp_path, "qr-only"), _account(tmp_path, "cred-scripts", credential_env_names=("A_USER", "A_PASS")), _account( tmp_path, "cred-vault", login_mode="C", login_command_id="product_commerce:erp_login.py", ), _account(tmp_path, "cred-mode-b", login_mode="B"), ) assert gate.gate_mode("qr-only") == GATE_MODE_BLOCKING assert gate.gate_mode("cred-scripts") == GATE_MODE_DEFER assert gate.gate_mode("cred-vault") == GATE_MODE_RECOVER assert gate.gate_mode("cred-mode-b") == GATE_MODE_DEFER # ---------------------------------------------------------------------- # Workflow account resolution # ---------------------------------------------------------------------- def _bound_workflow(workflow_id: str = "shop.demo.daily") -> WorkflowDefinition: return WorkflowDefinition( workflow_id, ( StepDefinition("collect", _BoundAction("collect.py")), StepDefinition("sync", _BoundAction("sync.py"), depends_on=("collect",)), ), ) def test_collect_accounts_maps_bindings_and_skips_defer(tmp_path: Path) -> None: gate = _gate( tmp_path, _account(tmp_path, "douyin-shop"), _account(tmp_path, "jd-shop", credential_env_names=("JD_SHOP", "JD_PASSWORD")), bindings={ "shop_intelligence:collect.py": "douyin-shop", "shop_intelligence:other.py": "jd-shop", }, ) mapping = gate.collect_workflow_accounts(_bound_workflow()) # sync.py 无账号绑定,jd-shop 属 defer 模式:两者都不进映射 assert mapping == {"douyin-shop": {"collect"}} def test_collect_accounts_includes_declared_required_accounts(tmp_path: Path) -> None: gate = _gate(tmp_path, _account(tmp_path, "douyin-shop")) gate._workflow_catalog = WorkflowCatalog( timezone="UTC", workflows=( WorkflowEntry( workflow_id="shop.demo.daily", module="shop_intelligence", trigger="manual", entry="collect.py", required_accounts=("douyin-shop",), ), ), schedules=(), ) mapping = gate.collect_workflow_accounts(_bound_workflow()) assert mapping == {"douyin-shop": {"collect", "sync"}} def test_apply_cookie_gates_rewrites_dependencies(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate( tmp_path, account, bindings={"shop_intelligence:collect.py": "douyin-shop"}, ) gated = apply_cookie_gates( _bound_workflow(), settings=_settings(tmp_path), gate=gate ) ids = [step.step_id for step in gated.steps] assert "cookie-gate.douyin-shop" in ids collect = next(step for step in gated.steps if step.step_id == "collect") sync = next(step for step in gated.steps if step.step_id == "sync") assert collect.depends_on == ("cookie-gate.douyin-shop",) assert sync.depends_on == ("collect",) assert gated.ordered_steps() # patched DAG stays valid def test_apply_cookie_gates_returns_workflow_without_accounts(tmp_path: Path) -> None: gate = _gate(tmp_path, _account(tmp_path, "douyin-shop")) workflow = _bound_workflow() assert ( apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate) is workflow ) def test_gate_enabled_env_kill_switch() -> None: assert gate_enabled({}) assert not gate_enabled({"GYXX_COOKIE_GATE": "0"}) assert not gate_enabled({"GYXX_COOKIE_GATE": "off"}) assert gate_enabled({"GYXX_COOKIE_GATE": "1"}) # ---------------------------------------------------------------------- # State machine: check() # ---------------------------------------------------------------------- def test_check_fast_path_accepts_fresh_keepalive_without_probe(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate(tmp_path, account, probe={"status": "expired"}) _write_valid_vault(account) _write_keepalive_status( account, { "status": "success", "last_success_at": (_NOW - timedelta(minutes=10)).isoformat(), }, ) verdict = gate.check("douyin-shop", context=_context()) assert not verdict.blocked assert "keepalive" in verdict.message state = json.loads( (account.cookie_file.parent / "cookie_state.json").read_text(encoding="utf-8") ) assert state["state"] == "healthy" def test_check_stale_status_triggers_probe(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate(tmp_path, account, probe={"status": "success"}) _write_valid_vault(account) _write_keepalive_status( account, { "status": "success", "last_success_at": (_NOW - timedelta(hours=6)).isoformat(), }, ) verdict = gate.check("douyin-shop", context=_context()) assert not verdict.blocked assert verdict.message.endswith("探活通过)") def test_check_expired_blocks_and_sends_feishu_alert(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") sent: list[dict[str, object]] = [] gate = _gate( tmp_path, account, probe={"status": "expired"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) _write_keepalive_status(account, None) verdict = gate.check("douyin-shop", context=_context()) assert verdict.blocked assert verdict.notified assert len(sent) == 1 text = str(sent[0]["text"]) assert "uv run gyxx accounts login douyin-shop" in text assert "shop.demo.daily" in text assert sent[0]["profile"] == "hermes-analyzer" state = json.loads( (account.cookie_file.parent / "cookie_state.json").read_text(encoding="utf-8") ) assert state["state"] == "awaiting_login" assert state["last_notified_at"] == _NOW.isoformat(timespec="seconds") def test_check_notification_is_throttled_per_interval(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") sent: list[dict[str, object]] = [] gate = _gate( tmp_path, account, probe={"status": "expired"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) _write_keepalive_status(account, None) first = gate.check("douyin-shop", context=_context()) second = gate.check("douyin-shop", context=_context()) assert first.notified and second.blocked assert not second.notified assert len(sent) == 1 def test_check_shadow_run_never_notifies(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") sent: list[dict[str, object]] = [] gate = _gate( tmp_path, account, probe={"status": "expired"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) verdict = gate.check("douyin-shop", context=_context(shadow=True)) assert verdict.blocked assert not verdict.notified assert sent == [] def test_check_mode_c_recovery_success_and_failure_paths(tmp_path: Path) -> None: account = _account( tmp_path, "erp-jushuitan", login_mode="C", login_command_id="product_commerce:erp_login.py", ) recovered = _gate( tmp_path, account, probe={"status": "success", "login_recovered": True} ) _write_keepalive_status(account, None) verdict = recovered.check("erp-jushuitan", context=_context()) assert not verdict.blocked assert verdict.recovered assert "自动账密重登成功" in verdict.message sent: list[dict[str, object]] = [] failed = _gate( tmp_path, account, probe={"status": "not_logged_in"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) _write_keepalive_status( failed.catalog.account_for("erp-jushuitan"), { "status": "not_logged_in", "login_attempted": True, "login_error_type": "TimeoutExpired", }, ) verdict = failed.check("erp-jushuitan", context=_context()) assert verdict.blocked assert verdict.notified assert "自动账密重登已尝试但未成功" in str(sent[0]["text"]) def test_check_busy_falls_back_to_static_evidence(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate(tmp_path, account, probe={"status": "busy"}) _write_valid_vault(account) _write_keepalive_status(account, None) verdict = gate.check("douyin-shop", context=_context()) assert not verdict.blocked assert "探活锁忙" in verdict.message def test_check_probe_error_with_static_valid_vault_passes(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate(tmp_path, account, probe={"status": "timeout"}) _write_valid_vault(account) _write_keepalive_status(account, None) verdict = gate.check("douyin-shop", context=_context()) assert not verdict.blocked assert "探活异常" in verdict.message def test_acceptance_mode_is_static_only_and_never_alerts(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") sent: list[dict[str, object]] = [] gate = _gate( tmp_path, account, probe={"status": "expired"}, environment={ "GYXX_WORKFLOW_ACCEPTANCE": "1", "GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target", }, sent=sent, ) _write_keepalive_status(account, None) verdict = gate.check("douyin-shop", context=_context()) assert verdict.blocked assert sent == [] assert "验收模式" in verdict.message def test_alert_recipient_falls_back_to_constant(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate(tmp_path, account) recipients = gate._alert_recipients() assert recipients == [ ("ou_7ad5fc8012e2f741afc5346e05ffd447", "hermes-analyzer") ] # ---------------------------------------------------------------------- # Engine integration # ---------------------------------------------------------------------- def _engine(tmp_path: Path) -> WorkflowEngine: return WorkflowEngine(LockManager(tmp_path / "state" / "locks")) def test_engine_blocks_dependents_when_gate_fails(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") sent: list[dict[str, object]] = [] gate = _gate( tmp_path, account, probe={"status": "expired"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, bindings={"shop_intelligence:collect.py": "douyin-shop"}, ) executed: list[str] = [] workflow = WorkflowDefinition( "shop.demo.daily", ( StepDefinition( "collect", _RecordingAction("collect", executed, "collect.py") ), StepDefinition( "sync", _RecordingAction("sync", executed), depends_on=("collect",) ), ), ) gated = apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate) context = RunContext.create( "shop.demo.daily", "2026-10-08", now=_NOW, random_suffix="dddd" ) journal = RunJournal.create(DataLayout(_settings(tmp_path).data_root), context) engine = _engine(tmp_path) run_workflow = engine.execute result = run_workflow(gated, context=context, journal=journal) assert result.status == "failed" assert result.exit_code == 1 assert result.steps["cookie-gate.douyin-shop"].status == "failed" assert result.steps["collect"].reason == "dependency-failed" assert result.steps["sync"].reason == "dependency-failed" assert executed == [] assert len(sent) == 1 def test_engine_runs_collect_steps_when_gate_passes(tmp_path: Path) -> None: account = _account(tmp_path, "douyin-shop") gate = _gate( tmp_path, account, probe={"status": "success"}, bindings={"shop_intelligence:collect.py": "douyin-shop"}, ) _write_valid_vault(account) _write_keepalive_status(account, None) executed: list[str] = [] workflow = WorkflowDefinition( "shop.demo.daily", ( StepDefinition( "collect", _RecordingAction("collect", executed, "collect.py") ), ), ) gated = apply_cookie_gates(workflow, settings=_settings(tmp_path), gate=gate) context = RunContext.create( "shop.demo.daily", "2026-10-08", now=_NOW, random_suffix="eeee" ) journal = RunJournal.create(DataLayout(_settings(tmp_path).data_root), context) engine = _engine(tmp_path) run_workflow = engine.execute result = run_workflow(gated, context=context, journal=journal) assert result.status == "success" assert result.steps["cookie-gate.douyin-shop"].status == "success" assert result.steps["collect"].status == "success" # ---------------------------------------------------------------------- # Recovery: automatic rerun after the login recovers # ---------------------------------------------------------------------- class _FakeLauncher: def __init__(self, fail: bool = False) -> None: self.launched: list[tuple[str, str]] = [] self.closed: list[int] = [] self.fail = fail def launch(self, workflow_id, slot, business_date): # noqa: ANN001 if self.fail: raise RuntimeError("spawn denied") process = SimpleNamespace(pid=9000 + len(self.launched), poll=lambda: None) self.launched.append((workflow_id, business_date.isoformat())) return process def close(self, process) -> None: # noqa: ANN001 self.closed.append(process.pid) def _state_file(account: AccountBinding) -> Path: return account.cookie_file.parent / "cookie_state.json" def _read_state(account: AccountBinding) -> dict: return json.loads(_state_file(account).read_text(encoding="utf-8")) def _recovery_workflow_catalog() -> WorkflowCatalog: return WorkflowCatalog( timezone="UTC", workflows=( WorkflowEntry( workflow_id="shop.demo.daily", module="shop_intelligence", trigger="scheduled", entry="collect.py", ), ), schedules=(), ) def _blocking_gate( tmp_path: Path, account: AccountBinding, sent: list[dict[str, object]], ) -> CookieGate: gate = _gate( tmp_path, account, probe={"status": "expired"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) return gate def _healthy_gate( tmp_path: Path, account: AccountBinding, sent: list[dict[str, object]], ) -> CookieGate: gate = _gate( tmp_path, account, probe={"status": "success"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) gate._workflow_catalog = _recovery_workflow_catalog() return gate def _simulate_manual_login(account: AccountBinding) -> None: _write_valid_vault(account) _write_keepalive_status( account, { "status": "success", "last_success_at": (_NOW - timedelta(seconds=60)).isoformat(), }, ) def test_expire_records_pending_reruns(tmp_path: Path) -> None: sent: list[dict[str, object]] = [] account = _account(tmp_path, "douyin-shop") gate = _blocking_gate(tmp_path, account, sent) gate.check("douyin-shop", context=_context()) gate.check("douyin-shop", context=_context()) # 同工作流同日期去重 gate.check("douyin-shop", context=_context(business_date="2026-10-09")) state = _read_state(account) assert [ (item["workflow_id"], item["business_date"]) for item in state["pending_reruns"] ] == [ ("shop.demo.daily", "2026-10-08"), ("shop.demo.daily", "2026-10-09"), ] # shadow 运行的阻断不进入待重跑清单 gate.check( "douyin-shop", context=_context(business_date="2026-10-10", shadow=True) ) state = _read_state(account) assert all( item["business_date"] != "2026-10-10" for item in state["pending_reruns"] ) def test_recovery_reruns_blocked_workflows_after_login(tmp_path: Path) -> None: sent: list[dict[str, object]] = [] account = _account(tmp_path, "douyin-shop") blocked = _blocking_gate(tmp_path, account, sent) blocked.check("douyin-shop", context=_context()) _simulate_manual_login(account) healthy = _healthy_gate(tmp_path, account, sent) launcher = _FakeLauncher() outcomes = CookieGateRecoveryManager(healthy, launcher=launcher).tick() assert launcher.launched == [("shop.demo.daily", "2026-10-08")] assert launcher.closed # 父进程日志句柄立即关闭 assert outcomes and outcomes[0]["launched"] state = _read_state(account) assert state["pending_reruns"] == [] assert state["state"] == "healthy" assert state["last_rerun_launch_at"] assert state["rerun_history"][0]["workflow_id"] == "shop.demo.daily" assert any("登录态已恢复" in str(item["text"]) for item in sent) def test_recovery_skips_while_login_still_expired(tmp_path: Path) -> None: sent: list[dict[str, object]] = [] account = _account(tmp_path, "douyin-shop") blocked = _blocking_gate(tmp_path, account, sent) blocked.check("douyin-shop", context=_context()) _write_keepalive_status(account, None) still_expired = _gate( tmp_path, account, probe={"status": "expired"}, environment={"GYXX_LOGIN_ALERT_OPEN_IDS": "ou_alert_target"}, sent=sent, ) still_expired._workflow_catalog = _recovery_workflow_catalog() launcher = _FakeLauncher() outcomes = CookieGateRecoveryManager(still_expired, launcher=launcher).tick() assert launcher.launched == [] assert outcomes == [] state = _read_state(account) assert len(state["pending_reruns"]) == 1 assert state["last_recovery_probe_at"] def test_recovery_drops_adhoc_script_ids(tmp_path: Path) -> None: sent: list[dict[str, object]] = [] account = _account(tmp_path, "douyin-shop") blocked = _blocking_gate(tmp_path, account, sent) blocked.check( "douyin-shop", context=_context(workflow_id="script.shop.abcdef123456") ) assert ( _read_state(account)["pending_reruns"][0]["workflow_id"] == "script.shop.abcdef123456" ) _simulate_manual_login(account) healthy = _healthy_gate(tmp_path, account, sent) launcher = _FakeLauncher() CookieGateRecoveryManager(healthy, launcher=launcher).tick() assert launcher.launched == [] assert _read_state(account)["pending_reruns"] == [] def test_recovery_launch_failure_keeps_pending_for_retry(tmp_path: Path) -> None: sent: list[dict[str, object]] = [] account = _account(tmp_path, "douyin-shop") blocked = _blocking_gate(tmp_path, account, sent) blocked.check("douyin-shop", context=_context()) _simulate_manual_login(account) healthy = _healthy_gate(tmp_path, account, sent) launcher = _FakeLauncher(fail=True) CookieGateRecoveryManager(healthy, launcher=launcher).tick() state = _read_state(account) assert len(state["pending_reruns"]) == 1 assert "error" in state["pending_reruns"][0] assert state["state"] == "awaiting_login" assert not any("已恢复" in str(item["text"]) for item in sent) def test_recovery_respects_launch_cooldown(tmp_path: Path) -> None: sent: list[dict[str, object]] = [] account = _account(tmp_path, "douyin-shop") blocked = _blocking_gate(tmp_path, account, sent) blocked.check("douyin-shop", context=_context()) _simulate_manual_login(account) state = _read_state(account) state["last_rerun_launch_at"] = _NOW.isoformat(timespec="seconds") _state_file(account).write_text(json.dumps(state), encoding="utf-8") healthy = _healthy_gate(tmp_path, account, sent) launcher = _FakeLauncher() CookieGateRecoveryManager(healthy, launcher=launcher).tick() assert launcher.launched == []