from __future__ import annotations import base64 import asyncio import copy import platform import sqlite3 from pathlib import Path import pytest from fastapi.testclient import TestClient from apps.server.main import create_app from apps.server.worker import WorkerError PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=") PASSWORD = "Test-only-maintainer-4829" class FakeWorker: """Contract substitute only; actual browser proof is a separate E2E check.""" def __init__(self, home: Path): self.home, self.sessions, self.counter, self.calls = home, {}, 0, [] def capture(self, task_id): self.counter += 1 path = self.home / "evidence" / f"test-{self.counter}.png" path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(PNG) return {"url": "http://127.0.0.1/demo", "title": "本机测试", "text": "测试页面", "elements": [{"selector": selector, "tag": tag, "label": label, "kind": kind, "unique": unique, "sensitive": False} for selector, tag, label, kind, unique in [('[data-testid="search-input"]', "input", "搜索", "fill", True), ('[data-testid="search-button"]', "button", "搜索", "click", True), ('[data-testid="result"]', "article", "结果", "read", False), ('[data-testid="detail-link"]', "a", "查看详情", "click", True), ('[data-testid="comment"]', "article", "评论", "read", False)]], "screenshot_path": str(path)} async def call(self, method, params): self.calls.append((method, copy.deepcopy(params))) task_id = params["task_id"] if method == "open": self.sessions[task_id] = {"ready": False, "keyword": "建材"} return {"session_id": task_id, "status": "awaiting_user", "url": params["entry_url"]} session = self.sessions.get(task_id) if method == "status": return {"status": "observing" if session and session["ready"] else "awaiting_user" if session else "closed", "manual_confirmation": bool(session and session["ready"])} if not session: raise WorkerError("请先打开会话", "session_not_found") if method == "ready": session["ready"] = True return {"manual_confirmation": True} if method == "close": self.sessions.pop(task_id) return {"closed": True} if not session["ready"]: raise WorkerError("等待人工登录确认", "not_ready") if method == "capture": return self.capture(task_id) if method == "action": if params["kind"] == "fill": session["keyword"] = params["value"] return {"result": [{"text": f"{session['keyword']}采购需求"}] if params["kind"] == "read" else {"completed": True}, "before": self.capture(task_id), "after": self.capture(task_id)} if method == "verify": value = params["parameters"]["keyword"] return {"passed": True, "outputs": {"results": [{"text": value + "采购需求"}], "comments": [{"text": value + "评论"}]}, "errors": [], "evidence": [self.capture(task_id)], "os": platform.system(), "browser_version": "test-browser"} raise AssertionError(method) async def close(self): self.sessions.clear() @pytest.fixture def client(tmp_path): worker = FakeWorker(tmp_path) app = create_app(tmp_path, worker, bootstrap_admin=False, database_url="") with TestClient(app, base_url="http://127.0.0.1:8810") as test_client: test_client.app_reference = app test_client.worker_reference = worker yield test_client def initialize(client): status = client.get("/api/auth/status").json() client.headers["X-CSRF-Token"] = status["csrf_token"] response = client.post("/api/auth/setup", json={"username": "maintainer", "password": PASSWORD}) assert response.status_code == 200, response.text client.headers["X-CSRF-Token"] = response.json()["csrf_token"] return response.json() def task(client): response = client.post("/api/learning/tasks", json={"name": "只读搜索", "platform": "demo", "entry_url": "http://127.0.0.1:8810/api/learning/demo-page", "goal": "搜索和读取", "account_key": "test-account", "parameters": {"keyword": "建材"}}) assert response.status_code == 201, response.text return response.json() def working_candidate(client): created = task(client) prefix = "/api/learning/tasks/" + created["id"] assert client.post(prefix + "/browser/open").status_code == 200 assert client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"}).status_code == 200 assert client.post(prefix + "/browser/capture", json={}).status_code == 200 for action in [{"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "keyword"}, {"kind": "click", "selector": '[data-testid="search-button"]'}, {"kind": "read", "selector": '[data-testid="result"]', "output_name": "results"}]: response = client.post(prefix + "/browser/action", json=action) assert response.status_code == 200, response.text response = client.post(prefix + "/candidates", json={}) assert response.status_code == 201, response.text return created, response.json() def test_first_setup_rotates_session_and_requires_csrf(client): assert client.get("/api/learning/tasks").status_code == 401 status = client.get("/api/auth/status").json() assert not status["initialized"] and not status["authenticated"] assert client.post("/api/auth/setup", json={"username": "maintainer", "password": PASSWORD}).status_code == 403 old_cookie = client.cookies.get("assistant_maintainer") client.headers["X-CSRF-Token"] = status["csrf_token"] result = client.post("/api/auth/setup", json={"username": "maintainer", "password": PASSWORD}) assert result.status_code == 200 assert result.json()["authenticated"] assert client.cookies.get("assistant_maintainer") != old_cookie assert result.json()["csrf_token"] != status["csrf_token"] assert "httponly" in result.headers["set-cookie"].lower() assert "samesite=strict" in result.headers["set-cookie"].lower() def test_setup_is_one_time_and_password_not_returned(client): result = initialize(client) assert "password" not in str(result) assert client.post("/api/auth/setup", json={"username": "second", "password": PASSWORD}).status_code == 409 row = client.app_reference.state.store.one("SELECT password_hash FROM operators") assert row["password_hash"].startswith("scrypt$") and PASSWORD not in row["password_hash"] def test_auth_logout_login_and_bad_origin(client): initialize(client) assert client.get("/api/auth/status", headers={"Origin": "https://unrelated.example"}).status_code == 403 assert client.get("/api/health", headers={"Host": "unrelated.example"}).status_code == 400 assert client.post("/api/learning/tasks", json={}, headers={"X-CSRF-Token": "invalid"}).status_code == 403 response = client.post("/api/auth/logout") client.headers["X-CSRF-Token"] = response.json()["csrf_token"] assert client.get("/api/learning/tasks").status_code == 401 assert client.post("/api/auth/login", json={"username": "maintainer", "password": "wrong"}).status_code == 401 response = client.post("/api/auth/login", json={"username": "maintainer", "password": PASSWORD}) assert response.status_code == 200 def test_evidence_is_authenticated_and_scoped(client): initialize(client) created, candidate = working_candidate(client) detail = client.get("/api/learning/tasks/" + created["id"]).json() image_url = detail["observations"][0]["screenshot_url"] assert client.get(image_url).content.startswith(b"\x89PNG") assert "screenshot_path" not in str(detail) response = client.post("/api/auth/logout") assert client.get(image_url).status_code == 401 def test_candidate_is_immutable_and_check_records_append(client): initialize(client) created, candidate = working_candidate(client) url = "/api/learning/candidates/" + candidate["id"] first = client.post(url + "/check").json() second = client.post(url + "/check").json() assert first["status"] == second["status"] == "passed" assert first["id"] != second["id"] assert len(client.get(url).json()["validations"]) == 2 with pytest.raises(sqlite3.IntegrityError): client.app_reference.state.store.execute("UPDATE candidates SET manifest_json='{}' WHERE id=?", (candidate["id"],)) with pytest.raises(sqlite3.IntegrityError): client.app_reference.state.store.execute("UPDATE validations SET status='failed' WHERE id=?", (first["id"],)) second_candidate = client.post("/api/learning/tasks/" + created["id"] + "/candidates", json={}).json() assert second_candidate["version"] == candidate["version"] + 1 assert second_candidate["digest"] == candidate["digest"] def test_static_validation_rejects_empty_candidate(client): initialize(client) created = task(client) candidate = client.post("/api/learning/tasks/" + created["id"] + "/candidates", json={}).json() check = client.post("/api/learning/candidates/" + candidate["id"] + "/check").json() assert check["status"] == "failed" and check["errors"] response = client.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"results": "建材"}}) assert response.status_code == 409 def test_real_outputs_are_compared_independently_and_gates_keep_other_os_missing(client): initialize(client) created, candidate = working_candidate(client) prefix = "/api/learning/candidates/" + candidate["id"] assert client.post(prefix + "/check").json()["status"] == "passed" mismatch = client.post(prefix + "/verify", json={"parameters": {"keyword": "建材"}, "expected_outputs": {"results": "绝不匹配的文本"}}).json() assert mismatch["status"] == "failed" assert "未包含" in " ".join(mismatch["errors"]) for keyword in ("建材", "装修"): response = client.post(prefix + "/verify", json={"parameters": {"keyword": keyword}, "expected_outputs": {"results": keyword}}) assert response.status_code == 200, response.text assert response.json()["status"] == "passed" report = client.post(prefix + "/publish-check").json() assert not report["ready"] and not report["published"] assert next(item for item in report["checks"] if "不同参数" in item["name"])["passed"] expected_os = "macOS" if platform.system() != "Darwin" else "Windows" assert not next(item for item in report["checks"] if expected_os in item["name"])["passed"] forged = client.post(prefix + "/verify", json={"parameters": {"keyword": "建材"}, "expected_outputs": {"results": "建材"}, "target_os": "macos"}) assert forged.status_code == 422 def test_preconditions_and_bad_parameters_are_not_silently_accepted(client): initialize(client) created = task(client) prefix = "/api/learning/tasks/" + created["id"] assert client.post(prefix + "/browser/capture", json={}).status_code == 409 client.post(prefix + "/browser/open") assert client.post(prefix + "/browser/capture", json={}).status_code == 409 client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"}) client.post(prefix + "/browser/capture", json={}) assert client.post(prefix + "/browser/action", json={"kind": "fill", "selector": "#not-observed", "value": "x"}).status_code == 422 assert client.post(prefix + "/browser/action", json={"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "missing"}).status_code == 422 def test_demo_uses_worker_and_closes_account_even_after_success(client): initialize(client) response = client.post("/api/learning/demo") assert response.status_code == 200, response.text detail = client.get("/api/learning/tasks/" + response.json()["task_id"]).json() assert len(detail["steps"]) == 5 assert len(detail["validations"]) == 3 assert detail["browser_status"] == "closed" assert not client.worker_reference.sessions assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == 2 assert client.post("/api/learning/demo").status_code == 200 def test_validation_history_survives_store_reopen(client, tmp_path): initialize(client) created, candidate = working_candidate(client) client.post("/api/learning/candidates/" + candidate["id"] + "/check") from apps.server.db import Store reopened = Store(tmp_path) assert reopened.one("SELECT count(*) AS n FROM validations")["n"] == 1 assert reopened.one("SELECT count(*) AS n FROM learning_tasks")["n"] == 1 def test_fixture_is_public_but_application_data_is_private(client): fixture = client.get("/api/learning/demo-page?q=装修") assert fixture.status_code == 200 assert fixture.text.count('data-testid="detail-link"') == 1 assert "装修采购需求" in fixture.text assert client.get("/api/learning/overview").status_code == 401 def test_artifact_mutation_invalidates_the_evidence(client): initialize(client) created, candidate = working_candidate(client) url = client.get("/api/learning/tasks/" + created["id"]).json()["observations"][0]["screenshot_url"] artifact_id = url.rsplit("/", 1)[1] row = client.app_reference.state.store.one("SELECT relative_path FROM artifacts WHERE id=?", (artifact_id,)) (client.app_reference.state.store.home / row["relative_path"]).write_bytes(PNG + b"changed") assert client.get(url).status_code == 409 def test_candidate_binds_before_after_evidence_and_refuses_tampered_source(client): initialize(client) created, candidate = working_candidate(client) manifest = candidate["manifest"] assert manifest["integrity_version"] == 1 assert len(manifest["evidence_bindings"]) == 6 assert all(item["content_digest"] for item in manifest["evidence_bindings"]) from apps.server.learning_checks import evidence_graph_errors assert "动作前后" in " ".join(evidence_graph_errors([{"evidence_id": "a"}], [], {})) assert "重复" in " ".join(evidence_graph_errors([], [{"observation_id": "a"}, {"observation_id": "a"}], {})) service = client.app_reference.state.learning changed = copy.deepcopy(candidate) changed["manifest"]["evidence_bindings"][0]["content_digest"] = "0" * 64 assert any("冻结摘要" in item for item in service.source_integrity_errors(changed)) first = service.observations(created["id"])[1] artifact = service.store.one("SELECT relative_path FROM artifacts WHERE id=?", (first["artifact_id"],)) (service.store.home / artifact["relative_path"]).write_bytes(PNG + b"tamper") check = client.post("/api/learning/candidates/" + candidate["id"] + "/check").json() assert check["status"] == "failed" and any("截图摘要" in error for error in check["errors"]) def test_source_steps_candidates_and_validations_reject_deletion(client): initialize(client) created, candidate = working_candidate(client) client.post("/api/learning/candidates/" + candidate["id"] + "/check") store = client.app_reference.state.store for table in ("observations", "learning_steps", "artifacts", "validations", "candidates"): with pytest.raises(sqlite3.IntegrityError, match="immutable|append only"): store.execute(f"DELETE FROM {table}") with pytest.raises(sqlite3.IntegrityError): store.execute("UPDATE observations SET data_json='{}' WHERE task_id=?", (created["id"],)) def test_restart_invalidates_process_session_without_erasing_history(client, tmp_path): initialize(client) created, candidate = working_candidate(client) prefix = "/api/learning/candidates/" + candidate["id"] client.post(prefix + "/check") client.post("/api/learning/tasks/" + created["id"] + "/browser/ready", json={"identity_label": "测试账号"}) restarted = create_app(tmp_path, FakeWorker(tmp_path), bootstrap_admin=False, database_url="") with TestClient(restarted, base_url="http://127.0.0.1:8810") as second: second.cookies.update(client.cookies) second.headers["X-CSRF-Token"] = second.get("/api/auth/status").json()["csrf_token"] detail = second.get("/api/learning/tasks/" + created["id"]).json() assert detail["status"] == "paused" assert not detail["browser_readiness"]["ready"] assert detail["browser_readiness"]["next_action"] == "open_browser" assert len(detail["validations"]) == 1 and len(detail["steps"]) == 3 result = second.post(prefix + "/verify", json={"expected_outputs": {"results": "建材"}}) assert result.status_code == 409 and result.json()["detail"]["code"] == "browser_session_required" assert len(second.get(prefix).json()["validations"]) == 1 def test_external_browser_close_and_unconfirmed_session_are_preconditions(client): initialize(client) created, candidate = working_candidate(client) task_prefix = "/api/learning/tasks/" + created["id"] candidate_prefix = "/api/learning/candidates/" + candidate["id"] client.post(candidate_prefix + "/check") client.worker_reference.sessions.clear() detail = client.get(task_prefix).json() assert detail["browser_status"] == "closed" and not detail["browser_readiness"]["ready"] client.post(task_prefix + "/browser/open") result = client.post(candidate_prefix + "/verify", json={"expected_outputs": {"results": "建材"}}) assert result.status_code == 409 and result.json()["detail"]["code"] == "browser_confirmation_required" assert len(client.get(candidate_prefix).json()["validations"]) == 1 def test_fixed_readback_assertions_do_not_match_keywords_only_in_links(): from apps.server.learning_checks import output_assertion_checks checks = output_assertion_checks({"comments": [{"text": "不相关评论", "href": "https://example.test/?q=建材"}]}, {"comments": "建材"}) assert not all(item["passed"] for item in checks) checks = output_assertion_checks({"comments": [{"text": True}]}, {"comments": "True"}) assert not next(item for item in checks if item["name"] == "output_schema:comments")["passed"] def test_health_identifies_the_exact_started_instance(tmp_path, monkeypatch): monkeypatch.setenv("ASSISTANT_INSTANCE_ID", "isolated-test-instance") app = create_app(tmp_path / "explicit", FakeWorker(tmp_path / "explicit"), database_url="") with TestClient(app, base_url="http://127.0.0.1") as test_client: assert test_client.get("/api/health").json()["instance_id"] == "isolated-test-instance" monkeypatch.delenv("ASSISTANT_INSTANCE_ID") random_app = create_app(tmp_path / "random", FakeWorker(tmp_path / "random"), database_url="") with TestClient(random_app, base_url="http://127.0.0.1") as test_client: first = test_client.get("/api/health").json()["instance_id"] assert len(first) == 32 and first != "isolated-test-instance" assert test_client.get("/api/health").json()["instance_id"] == first def test_worker_shutdown_sends_eof_without_waiting_for_active_rpc_lock(tmp_path): from apps.server.worker import BrowserWorker class InputPipe: closed = False def close(self): self.closed = True class OwnedProcess: stdin = InputPipe() async def wait(self): assert self.stdin.closed return 0 async def check(): worker = BrowserWorker(tmp_path, tmp_path) process = OwnedProcess() worker.process = process async with worker.lock: await asyncio.wait_for(asyncio.gather(worker.close(), worker.close()), timeout=0.5) assert process.stdin.closed and worker.process is None with pytest.raises(WorkerError, match="正常退出"): await worker.call("status", {"task_id": "test"}) asyncio.run(check())