assistant/apps/server/tests/test_learning.py

369 lines
20 KiB
Python

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())