138 lines
7.5 KiB
Python
138 lines
7.5 KiB
Python
"""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)
|