369 lines
20 KiB
Python
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())
|