Ctrl K
ComunidadGuías…

tests/test_xkb_memory_service_http.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

import importlib.util
import json
import os
import tempfile
import threading
import unittest
from unittest import mock
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen

SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "xkb_memory_service.py"
spec = importlib.util.spec_from_file_location("xkb_memory_service_http", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
import sys
sys.modules[spec.name] = module
spec.loader.exec_module(module)

_wiki_patch = None


def setUpModule() -> None:
    """同 test_xkb_memory_service:不要讓測試載入真實知識庫或打 embedding API。"""
    global _wiki_patch
    from unittest import mock
    _wiki_patch = mock.patch.object(module.KnowledgeCatalog, "_wiki_search", return_value=[])
    _wiki_patch.start()


def tearDownModule() -> None:
    if _wiki_patch is not None:
        _wiki_patch.stop()


class XKBMemoryServiceHTTPTests(unittest.TestCase):
    def setUp(self) -> None:
        # 這是 HTTP 契約測試,不是語意後端測試。機器上沒有 gbrain / bun 時,
        # 每個請求會卡滿子行程的 timeout 才靜靜回空陣列——原本那是 30 秒,
        # 於是整個檔案看起來像「HTTP 服務壞了」。
        self._timeout_patch = mock.patch.dict(os.environ, {"XKB_EMBEDDING_TIMEOUT": "1", "XKB_XBRAIN_TIMEOUT": "1"})
        self._timeout_patch.start()
        self.addCleanup(self._timeout_patch.stop)
        self.tmp = tempfile.TemporaryDirectory()
        self.store = module.Store(Path(self.tmp.name) / "memory.sqlite")
        self.server = module.ThreadingHTTPServer(("127.0.0.1", 0), module.Handler)
        self.server.store = self.store
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
        self.thread.start()
        self.base = f"http://127.0.0.1:{self.server.server_address[1]}"

        session = self.store.open_session({"source": "http-fixture", "session_key": "http-session"})
        self.store.start_turn({"session_id": session["session_id"], "turn_id": "http-turn", "query": "http query"})
        self.completed = self.store.complete_turn(
            "http-turn",
            {"query": "http query", "answer": "user evidence", "content": {"answer": "user evidence"}},
        )

    def tearDown(self) -> None:
        self.server.shutdown()
        self.server.server_close()
        # join 不設上限。原本 timeout=2,時間到就往下刪暫存目錄——如果那條
        # 執行緒還在處理請求,它用的 sqlite 檔會在腳下被移走,同一個類別的
        # 下一個測試就對著一個還在關閉的伺服器跑。這就是它在 discover 底下
        # 偶爾出錯、單獨跑卻永遠通過的原因。shutdown() 已經呼叫過,join 會回來。
        self.thread.join()
        self.tmp.cleanup()

    def post(self, path: str, payload: dict) -> tuple[int, dict]:
        request = Request(
            self.base + path,
            data=json.dumps(payload).encode(),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urlopen(request, timeout=3) as response:
                return response.status, json.loads(response.read())
        except HTTPError as exc:
            return exc.code, json.loads(exc.read())

    def test_http_rejects_malformed_json_and_invalid_numeric_types(self) -> None:
        request = Request(self.base + "/v1/recall", data=b"{not-json", headers={"Content-Type": "application/json"}, method="POST")
        with self.assertRaises(HTTPError) as raised:
            urlopen(request, timeout=3)
        self.assertEqual(raised.exception.code, 400)
        self.assertIn("malformed JSON", json.loads(raised.exception.read())["error"])
        status, body = self.post("/v1/recall", {"query": "http query", "limit": "10"})
        self.assertEqual(status, 400)
        self.assertIn("limit must be an integer", body["error"])

    def test_http_turn_start_returns_400_for_identity_conflicts(self) -> None:
        session = self.store.open_session({"source": "http-fixture", "session_key": "identity-http", "namespace": "private"})
        payload = {"session_id": session["session_id"], "turn_id": "identity-http-turn", "query": "same query"}
        status, body = self.post("/v1/turns/start", payload)
        self.assertEqual(status, 200)
        self.assertFalse(body["resumed"])
        status, body = self.post("/v1/turns/start", {**payload, "query": "different query"})
        self.assertEqual(status, 400)
        self.assertIn("query", body["error"])
        status, body = self.post("/v1/turns/start", {**payload, "namespace": "shared"})
        self.assertEqual(status, 400)
        self.assertIn("namespace", body["error"])

    def test_http_namespace_must_match_session_and_completion(self) -> None:
        session = self.store.open_session({"source": "http-fixture", "session_key": "namespace-http", "namespace": "private"})
        status, body = self.post("/v1/turns/start", {"session_id": session["session_id"], "turn_id": "namespace-http-turn", "query": "q", "namespace": "shared"})
        self.assertEqual(status, 400)
        self.assertIn("namespace", body["error"])
        status, body = self.post("/v1/turns/start", {"session_id": session["session_id"], "turn_id": "namespace-http-turn", "query": "q"})
        self.assertEqual(status, 200)
        status, body = self.post("/v1/turns/namespace-http-turn/complete", {"query": "q", "answer": "a", "namespace": "shared"})
        self.assertEqual(status, 400)
        self.assertIn("namespace", body["error"])

    def test_http_recall_exposes_context_governance_packet(self) -> None:
        status, body = self.post("/v1/context", {"query": "http query", "namespace": "private"})
        self.assertEqual(status, 200)
        self.assertEqual(body["request_namespace"], "private")
        self.assertIn("acl_policy", body)
        self.assertIn("filtered_counts", body)
        self.assertIn("semantic_backend", body)
        self.assertIn("warnings", body)


class XKBMemoryServiceAuthTests(unittest.TestCase):
    """Once a token exists, identity must come from it and not from the body."""

    TOKEN = "test-token-0123456789abcdef"

    def setUp(self) -> None:
        # 這是 HTTP 契約測試,不是語意後端測試。機器上沒有 gbrain / bun 時,
        # 每個請求會卡滿子行程的 timeout 才靜靜回空陣列——原本那是 30 秒,
        # 於是整個檔案看起來像「HTTP 服務壞了」。
        self._timeout_patch = mock.patch.dict(os.environ, {"XKB_EMBEDDING_TIMEOUT": "1", "XKB_XBRAIN_TIMEOUT": "1"})
        self._timeout_patch.start()
        self.addCleanup(self._timeout_patch.stop)
        self.tmp = tempfile.TemporaryDirectory()
        self.store = module.Store(Path(self.tmp.name) / "memory.sqlite")
        self.server = module.ThreadingHTTPServer(("127.0.0.1", 0), module.Handler)
        self.server.store = self.store
        self.server.auth = module.AuthPolicy({
            "tokens": {
                self.TOKEN: {"namespace": "team-a", "scopes": ["memory:read", "memory:write"]},
                "readonly-token-0123456789ab": {"namespace": "team-a", "scopes": ["memory:read"]},
            }
        })
        self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
        self.thread.start()
        self.base = f"http://127.0.0.1:{self.server.server_address[1]}"

    def tearDown(self) -> None:
        self.server.shutdown()
        self.server.server_close()
        # join 不設上限。原本 timeout=2,時間到就往下刪暫存目錄——如果那條
        # 執行緒還在處理請求,它用的 sqlite 檔會在腳下被移走,同一個類別的
        # 下一個測試就對著一個還在關閉的伺服器跑。這就是它在 discover 底下
        # 偶爾出錯、單獨跑卻永遠通過的原因。shutdown() 已經呼叫過,join 會回來。
        self.thread.join()
        self.tmp.cleanup()

    def post(self, path: str, payload: dict, token: str | None = None) -> tuple[int, dict]:
        headers = {"Content-Type": "application/json"}
        if token:
            headers["Authorization"] = f"Bearer {token}"
        request = Request(self.base + path, data=json.dumps(payload).encode(), headers=headers, method="POST")
        try:
            with urlopen(request, timeout=3) as response:
                return response.status, json.loads(response.read())
        except HTTPError as exc:
            return exc.code, json.loads(exc.read())

    def test_health_stays_open_and_advertises_that_auth_is_on(self) -> None:
        with urlopen(self.base + "/v1/health", timeout=3) as response:
            body = json.loads(response.read())
        self.assertTrue(body["ok"])
        self.assertTrue(body["auth_required"])

    def test_anonymous_is_refused_once_a_token_is_configured(self) -> None:
        status, error = self.post("/v1/recall", {"query": "anything"})
        self.assertEqual(status, 401)
        self.assertIn("token", error["error"])

    def test_invalid_token_is_refused(self) -> None:
        status, _ = self.post("/v1/recall", {"query": "anything"}, token="wrong-token-0123456789abcd")
        self.assertEqual(status, 401)

    def test_token_namespace_cannot_be_overridden_by_the_body(self) -> None:
        """The whole point: claiming another namespace must fail, not silently work."""
        status, error = self.post("/v1/recall", {"query": "anything", "namespace": "team-b"}, token=self.TOKEN)
        self.assertEqual(status, 403)
        status, body = self.post("/v1/recall", {"query": "anything"}, token=self.TOKEN)
        self.assertEqual(status, 200)
        self.assertEqual(body["request_namespace"], "team-a")

    def test_scopes_separate_read_from_write(self) -> None:
        status, _ = self.post("/v1/sessions/open", {"source": "x", "session_key": "k"}, token="readonly-token-0123456789ab")
        self.assertEqual(status, 403)
        status, _ = self.post("/v1/recall", {"query": "anything"}, token="readonly-token-0123456789ab")
        self.assertEqual(status, 200)

    def test_written_session_is_pinned_to_the_token_namespace(self) -> None:
        status, session = self.post("/v1/sessions/open", {"source": "x", "session_key": "k", "namespace": "team-b"}, token=self.TOKEN)
        self.assertEqual(status, 403)
        status, session = self.post("/v1/sessions/open", {"source": "x", "session_key": "k"}, token=self.TOKEN)
        self.assertEqual(status, 200)
        self.assertEqual(session["namespace"], "team-a")


class AuthPolicyTests(unittest.TestCase):
    def test_anonymous_only_while_no_token_exists(self) -> None:
        self.assertTrue(module.AuthPolicy({}).allow_anonymous)
        policy = module.AuthPolicy({"tokens": {"a-long-enough-token-value": {"namespace": "n"}}})
        self.assertFalse(policy.allow_anonymous)
        self.assertTrue(policy.enabled)

    def test_config_errors_fail_closed(self) -> None:
        with self.assertRaises(ValueError):
            module.AuthPolicy({"tokens": {"short": {"namespace": "n"}}})
        with self.assertRaises(ValueError):
            module.AuthPolicy({"tokens": {"a-long-enough-token-value": {}}})

    def test_unreadable_policy_file_is_an_error_not_open_access(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            bad = Path(tmp) / "auth.json"
            bad.write_text("{not json", encoding="utf-8")
            with self.assertRaises(ValueError):
                module.AuthPolicy.load(bad)
            # A missing file is the documented "no auth configured" default.
            self.assertFalse(module.AuthPolicy.load(Path(tmp) / "absent.json").enabled)


if __name__ == "__main__":
    unittest.main()