"""Opt-in real PostgreSQL contract tests, each confined to a new random schema. Run with ASSISTANT_TEST_DATABASE_URL pointing at the dedicated assistant test DB. No test reads ASSISTANT_DATABASE_URL or an existing application schema. """ from concurrent.futures import ThreadPoolExecutor import os from uuid import uuid4 import pytest from fastapi.testclient import TestClient from apps.server.auth import Auth, password_hash, password_matches from apps.server.db import PostgresStore, StoreConflict, StoreError from apps.server.main import create_app from apps.server.tests.test_learning import FakeWorker, initialize, working_candidate @pytest.fixture def pg_client(tmp_path): database_url = os.environ.get("ASSISTANT_TEST_DATABASE_URL") if not database_url: pytest.skip("ASSISTANT_TEST_DATABASE_URL not provided; PostgreSQL tests are opt-in") psycopg = pytest.importorskip("psycopg") from psycopg import sql schema = "assistant_learning_test_" + uuid4().hex try: app = create_app(tmp_path, FakeWorker(tmp_path), bootstrap_admin=False, database_url=database_url, database_schema=schema) with TestClient(app, base_url="http://127.0.0.1:8810") as client: client.app_reference = app client.worker_reference = app.state.browser client.test_database_url = database_url client.test_schema = schema yield client finally: # The name is locally generated above and never reused by application # configuration. Only this test-owned namespace may be removed. with psycopg.connect(database_url, connect_timeout=5) as conn: conn.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema))) def test_postgres_auth_learning_versions_validation_and_evidence(pg_client): client = pg_client initialize(client) assert client.get("/api/health").json()["database_backend"] == "postgresql" assert not client.app_reference.state.store.path assert not (client.app_reference.state.store.home / "learning.sqlite").exists() created, candidate = working_candidate(client) prefix = "/api/learning/candidates/" + candidate["id"] first = client.post(prefix + "/check").json() second = client.post(prefix + "/check").json() assert first["status"] == second["status"] == "passed" and first["id"] != second["id"] for keyword in ("建材", "装修"): result = client.post(prefix + "/verify", json={"parameters": {"keyword": keyword}, "expected_outputs": {"results": keyword}}) assert result.status_code == 200 and result.json()["status"] == "passed" mismatch = client.post(prefix + "/verify", json={"expected_outputs": {"results": "绝不会匹配"}}) assert mismatch.status_code == 200 and mismatch.json()["status"] == "failed" detail = client.get("/api/learning/tasks/" + created["id"]).json() assert len(detail["validations"]) == 5 assert detail["validations"][0]["id"] == mismatch.json()["id"] assert client.get(detail["observations"][0]["screenshot_url"]).content.startswith(b"\x89PNG") assert not client.post(prefix + "/publish-check").json()["ready"] assert "ordinal" not in candidate and "ordinal" not in created client.post("/api/auth/logout") assert client.get("/api/learning/tasks").status_code == 401 def test_postgres_immutable_facts_and_concurrent_candidate_versions(pg_client): client = pg_client initialize(client) created, candidate = working_candidate(client) service = client.app_reference.state.learning store = service.store owner_id = store.one("SELECT id FROM operators")["id"] client.post("/api/learning/candidates/" + candidate["id"] + "/check") for table in ("observations", "learning_steps", "artifacts", "validations", "candidates"): for operation in (f"DELETE FROM {table}", f"TRUNCATE {table} CASCADE"): with pytest.raises(StoreConflict): store.execute(operation) with pytest.raises(StoreConflict): store.execute("UPDATE candidates SET manifest_json='{}' WHERE id=$1", (candidate["id"],)) with ThreadPoolExecutor(max_workers=4) as pool: versions = list(pool.map(lambda _: service.create_candidate(created["id"], owner_id)["version"], range(4))) assert sorted(versions) == [2, 3, 4, 5] assert store.one("SELECT count(*) AS n FROM candidates")["n"] == 5 assert store.one("SELECT count(*) AS n FROM validations")["n"] == 1 def test_postgres_bootstrap_is_atomic_and_restart_preserves_changed_password(pg_client): client = pg_client store = client.app_reference.state.store with ThreadPoolExecutor(max_workers=4) as pool: created = list(pool.map(lambda _: Auth(store).bootstrap_admin(), range(4))) assert created.count(True) == 1 row = store.one("SELECT * FROM operators") assert row["username"] == "admin" and password_matches("123456", row["password_hash"]) changed = password_hash("Changed-local-password-4831") store.execute("UPDATE operators SET password_hash=$1 WHERE id=$2", (changed, row["id"])) restarted = create_app(store.home, FakeWorker(store.home), database_url=client.test_database_url, database_schema=client.test_schema) assert restarted.state.store.one("SELECT password_hash FROM operators")["password_hash"] == changed assert restarted.state.store.one("SELECT count(*) AS n FROM operators")["n"] == 1 status = client.get("/api/auth/status").json() client.headers["X-CSRF-Token"] = status["csrf_token"] assert client.post("/api/auth/login", json={"username": "admin", "password": "123456"}).status_code == 401 assert client.post("/api/auth/login", json={"username": "admin", "password": "Changed-local-password-4831"}).status_code == 200 def test_postgres_restart_and_database_diagnostics_remain_explicit(pg_client): client = pg_client initialize(client) created, candidate = working_candidate(client) prefix = "/api/learning/tasks/" + created["id"] client.post("/api/learning/candidates/" + candidate["id"] + "/check") client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"}) store = client.app_reference.state.store restarted = create_app(store.home, FakeWorker(store.home), database_url=client.test_database_url, database_schema=client.test_schema) 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(prefix).json() assert detail["status"] == "paused" and not detail["browser_readiness"]["ready"] assert len(detail["steps"]) == 3 and len(detail["validations"]) == 1 assert second.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"results": "建材"}}).status_code == 409 with pytest.raises(StoreError) as caught: store.one("SELECT private_sql_marker FROM nonexistent_private_table") assert "private_sql_marker" not in str(caught.value) assert client.test_database_url not in str(caught.value) assert "PostgreSQL" in str(caught.value) def test_postgres_schema_name_rejects_foreign_or_injected_names(tmp_path): for schema in ("public", "learning_frontier", "assistant_learning;DROP SCHEMA public", "assistant_learning_" + "a" * 70): with pytest.raises(StoreError, match="命名空间"): PostgresStore(tmp_path, "postgresql://not-used.invalid/not-used", schema=schema)