from __future__ import annotations import pytest from fastapi.testclient import TestClient from tests.auth_provider import TestAuthProvider, install_test_auth @pytest.fixture() def secure_app(tmp_path, monkeypatch): monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db")) monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json")) from server.db.database import reset_engine from server.state import store as world_store install_test_auth(monkeypatch, "tenant-a-000000000000000000000001") world_store._stores.clear() reset_engine() from server.gateway.app import create_app app = create_app() yield app reset_engine() world_store._stores.clear() def login(client: TestClient, username: str) -> None: response = client.post("/api/auth/login", json={ "method": "password", "username": username, "password": "test", }) assert response.status_code == 200 def test_anonymous_business_routes_are_closed(secure_app): client = TestClient(secure_app) assert client.get("/api/health").status_code == 200 assert client.get("/api/workspace").status_code == 401 assert client.get("/api/world/summary").status_code == 401 assert client.post("/api/chat", json={"text": "hello"}).status_code == 401 def test_public_health_exposes_interface_handshake(secure_app): from server.contracts import INTERFACE_VERSION response = TestClient(secure_app).get("/api/health") assert response.status_code == 200 assert response.json()["interfaceVersion"] == INTERFACE_VERSION def test_shared_project_is_collaborative_but_personal_chat_is_private(secure_app): owner = TestClient(secure_app) editor = TestClient(secure_app) login(owner, "planner") login(editor, "collaborator") owner_workspace = owner.get("/api/workspace").json() personal_session = next(row for row in owner_workspace["sessions"] if row["scope"] == "personal") assert owner.put( f"/api/sessions/{personal_session['id']}/messages", json={"messages": [{"role": "user", "text": "private"}]}, ).status_code == 200 assert editor.get(f"/api/sessions/{personal_session['id']}/messages").status_code == 404 created = owner.post("/api/projects", json={"id": "proj_shared", "name": "协作项目"}) assert created.status_code == 200 project_session = created.json()["session"] invited = owner.post( "/api/projects/proj_shared/members", json={"userId": 1002, "role": "editor"}, ) assert invited.status_code == 200 shared = editor.get("/api/workspace").json() assert [(row["id"], row["role"]) for row in shared["projects"]] == [("proj_shared", "editor")] assert any(row["id"] == project_session["id"] for row in shared["sessions"]) assert editor.put( f"/api/sessions/{project_session['id']}/messages", json={"messages": [{"role": "user", "text": "shared"}]}, ).status_code == 200 messages = owner.get(f"/api/sessions/{project_session['id']}/messages").json()["messages"] assert messages == [{"role": "user", "text": "shared"}] def test_replacing_same_session_messages_twice_is_idempotent(secure_app): client = TestClient(secure_app) login(client, "planner") workspace = client.get("/api/workspace").json() session_id = next(row["id"] for row in workspace["sessions"] if row["scope"] == "personal") first = [{"role": "user", "text": "你是谁?"}] second = [*first, {"role": "assistant", "text": "我是工业智核 APS 助手。"}] assert client.put( f"/api/sessions/{session_id}/messages", json={"messages": first}, ).status_code == 200 assert client.put( f"/api/sessions/{session_id}/messages", json={"messages": second}, ).status_code == 200 assert client.get(f"/api/sessions/{session_id}/messages").json()["messages"] == second def test_viewer_cannot_modify_project(secure_app): owner = TestClient(secure_app) viewer = TestClient(secure_app) login(owner, "planner") login(viewer, "viewer") owner.post("/api/projects", json={"id": "proj_readonly", "name": "只读项目"}) owner.post( "/api/projects/proj_readonly/members", json={"userId": 1003, "role": "viewer"}, ) assert viewer.get("/api/projects/proj_readonly/members").status_code == 200 assert viewer.patch("/api/projects/proj_readonly", json={"name": "越权修改"}).status_code == 403 assert viewer.post( "/api/sessions", json={"projectId": "proj_readonly", "title": "越权会话"}, ).status_code == 403 def test_cross_tenant_token_cannot_access_known_project_id(secure_app, monkeypatch): owner = TestClient(secure_app) login(owner, "planner") owner.post("/api/projects", json={"id": "proj_tenant_a", "name": "租户 A"}) import asyncio _, token = asyncio.run(TestAuthProvider( "tenant-b-000000000000000000000001", ).login({"username": "collaborator"})) tenant_b = TestClient(secure_app) tenant_b.cookies.set("aps_session", token) workspace = tenant_b.get("/api/workspace") assert workspace.status_code == 200 assert workspace.json()["projects"] == [] assert tenant_b.get("/api/projects/proj_tenant_a/members").status_code == 404 assert tenant_b.patch("/api/projects/proj_tenant_a", json={"name": "越权"}).status_code == 403 def test_chat_worker_preserves_authenticated_project_context(secure_app, monkeypatch): from server.auth.context import get_identity from server.contracts import AgentReply from server.state.projects import get_project_store import server.gateway.app as gateway_app async def assert_context(_store, _session_id, _intent, actor="planner"): identity = get_identity(required=True) snapshot = get_project_store().snapshot(include_messages=False) assert identity.user_id == 1001 assert identity.tenant_uuid == "tenant-a-000000000000000000000001" assert snapshot["activeProjectId"] == "proj_context_worker" return AgentReply(text="context-ok") monkeypatch.setattr(gateway_app, "handle_intent", assert_context) client = TestClient(secure_app) login(client, "planner") created = client.post("/api/projects", json={ "id": "proj_context_worker", "name": "线程上下文项目", }) assert created.status_code == 200 session_id = created.json()["session"]["id"] response = client.post("/api/chat", json={ "sessionId": session_id, "projectId": "proj_context_worker", "text": "分析项目", }) assert response.status_code == 200 assert "context-ok" in response.text