diff --git a/services/astroklient/app/clients/logic_client.py b/services/astroklient/app/clients/logic_client.py index e968acb..04f3f68 100644 --- a/services/astroklient/app/clients/logic_client.py +++ b/services/astroklient/app/clients/logic_client.py @@ -74,7 +74,12 @@ class LogicClient: """Strumień NDJSON w dół — przekazywany dalej ramka po ramce. Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy - włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie.""" + włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie. + + Nie przechodzi przez `_post`, więc pulę konta dokłada sam — tak samo, + z kontekstu i z pierwszeństwem przed polem przyniesionym w payloadzie. + Bez tego horoskop strumieniowany szukałby w całym udziale.""" + payload = {**payload, "tenant": KONTO.get("")} with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client: yield from link_crypto.stream_lines( client, f"{self.base_url}{sciezka}", diff --git a/services/astroklient/tests/conftest.py b/services/astroklient/tests/conftest.py index b72c519..fe2f343 100644 --- a/services/astroklient/tests/conftest.py +++ b/services/astroklient/tests/conftest.py @@ -80,15 +80,66 @@ print("WYNIK:" + json.dumps({ ''' -@pytest.fixture(scope="module") -def wynik(drzewo): +# Generowania tekstu w tym obrazie nie ma, ale most modułów zostaje — więc jego +# transport musi nieść pulę z sesji tak samo jak wywołania ekranów. Osobna sonda, +# bo dokłada trasę, której w produkcie nie ma: w sondzie produktu zafałszowałaby +# każde pytanie o to, co produkt zawiera. +SONDA_MODULU = ''' +import json, os, hashlib, secrets +sol = secrets.token_bytes(16) +dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32) +os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()), + SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0") +from fastapi.responses import StreamingResponse +from starlette.testclient import TestClient +from app.main import app, logic +from app import security, session, features +from app.clients import logic_client +wyslane = {} +def call_json(client, method, url, payload=None, **kw): + wyslane[url.split("/chart/", 1)[1]] = (payload or {}).get("tenant") + return {} +def stream_lines(client, url, payload=None, **kw): + wyslane[url.split("/chart/", 1)[1]] = (payload or {}).get("tenant") + yield '{"type": "result", "result": {}}' +logic_client.link_crypto.call_json = call_json +logic_client.link_crypto.stream_lines = stream_lines +# Trasa w miejscu modułu: woła ten sam transport co moduł generowania, a strumień +# konsumuje równie leniwie — dopiero przy wysyłaniu odpowiedzi, nie w obsłudze trasy. +features.ROUTES[("POST", "/sonda/modul")] = None +@app.post("/sonda/modul") +def sonda_modul(): + podrobione = {"profile": "natal", "tenant": "ktos-inny"} + logic.wywolaj("/chart/prompt", podrobione, 5) + logic.wywolaj("/chart/horoscope", podrobione, 5) + def relay(): + yield from logic.strumien("/chart/horoscope/stream", podrobione) + return StreamingResponse(relay(), media_type="application/x-ndjson") +c = TestClient(app, follow_redirects=False) +H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"} +r = c.post("/sonda/modul", headers=H) +print("WYNIK:" + json.dumps({"status": r.status_code, "pule": wyslane})) +''' + + +def _uruchom(drzewo: pathlib.Path, sonda: str) -> dict: """Aplikacja uruchomiona w OSOBNYM PROCESIE. Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie pozytywny.""" - r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True, + r = subprocess.run([sys.executable, "-c", sonda], cwd=drzewo, capture_output=True, text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"}) linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None) assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}" return json.loads(linia[len("WYNIK:"):]) + + +@pytest.fixture(scope="module") +def wynik(drzewo): + return _uruchom(drzewo, SONDA) + + +@pytest.fixture(scope="module") +def wynik_modulu(drzewo): + return _uruchom(drzewo, SONDA_MODULU) diff --git a/services/astroklient/tests/test_zlozenie.py b/services/astroklient/tests/test_zlozenie.py index eb7cd81..4988a38 100644 --- a/services/astroklient/tests/test_zlozenie.py +++ b/services/astroklient/tests/test_zlozenie.py @@ -44,3 +44,12 @@ def test_the_pool_comes_from_the_session_not_from_the_form(wynik): """Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje.""" assert wynik["pula"] == "klient" + + +def test_module_transport_carries_the_session_pool_too(wynik_modulu): + """Prompt i horoskop idą mostem modułów, a strumień horoskopu obok `_post` — + i właśnie tam pula ginęła, więc horoskop szukałby w całym udziale. Sonda + podsuwa cudzy login w payloadzie; ma wygrać konto z sesji.""" + assert wynik_modulu["status"] == 200 + assert wynik_modulu["pule"] == {"prompt": "klient", "horoscope": "klient", + "horoscope/stream": "klient"} diff --git a/services/logic/app/main.py b/services/logic/app/main.py index c5a3963..5b13e68 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -294,6 +294,10 @@ class PromptRequest(BaseModel): from_date: str | None = None to_date: str | None = None techniques: list[str] | None = None + # Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie + # rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa + # prezentacji, bo to ona zna konta. HoroscopeRequest dziedziczy to pole. + tenant: str = "" @app.post("/chart/prompt") @@ -336,7 +340,7 @@ def chart_prompt(req: PromptRequest) -> dict: report: dict = {"objects": []} try: report = build_report( - chart["positions"], DataClient(), + chart["positions"], DataClient(tenant=req.tenant), aspects=chart.get("aspects"), parallels=chart.get("parallels"), per_object_limit=req.limit, ) @@ -358,7 +362,7 @@ def chart_prompt(req: PromptRequest) -> dict: events = build_timeline(engine, moment, points, req.from_date, req.to_date, req.techniques) try: - interpret_events(events, DataClient()) + interpret_events(events, DataClient(tenant=req.tenant)) except httpx.HTTPError as e: data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje out = build_period_prompt(chart, events, req.from_date, req.to_date, @@ -627,7 +631,7 @@ def chart_timeline(req: TimelineRequest) -> dict: if req.interpret: from app.significators import interpret_events try: - interpret_events(events, DataClient()) + interpret_events(events, DataClient(tenant=req.tenant)) except httpx.HTTPError as e: out["data_error"] = f"Warstwa danych niedostępna: {e}" out.update(count=len(events), events=events) diff --git a/services/logic/tests/test_tenant_routes.py b/services/logic/tests/test_tenant_routes.py new file mode 100644 index 0000000..dbc96d3 --- /dev/null +++ b/services/logic/tests/test_tenant_routes.py @@ -0,0 +1,86 @@ +"""Pula konta (PRE-29) dociera do warstwy danych z KAŻDEJ trasy, która w niej szuka. + +Trasy są wołane przez HTTP, a DataClient podmieniony na szpiega, który zapisuje, +z jaką pulą go zbudowano i czy naprawdę o coś zapytał. Celowo nie czytamy źródła: +napis `tenant=` w kodzie nie mówi, czy pula przeżyła drogę od żądania do bazy. +A pominięta pula nie daje błędu — daje cały udział, czyli cudze pliki. +""" +from __future__ import annotations + +import json + +import pytest +from starlette.testclient import TestClient + +from app import link_crypto, main + +POOL = "konto-a" +PERIOD = {"from_date": "2024-01-01", "to_date": "2024-06-30"} +# Nieznany dostawca kończy horoskop na LLMError zaraz po zbudowaniu promptu — +# bez sieci i bez klucza API, a warstwa danych jest już wtedy przepytana. +NO_PROVIDER = {"provider": "brak-dostawcy"} + +ROUTES = [ + pytest.param("/chart/report", {}, id="report"), + pytest.param("/chart/timeline", {**PERIOD, "interpret": True}, id="timeline"), + pytest.param("/chart/prompt", {"profile": "natal"}, id="prompt-natal"), + pytest.param("/chart/prompt", {"profile": "period", **PERIOD}, id="prompt-period"), + pytest.param("/chart/horoscope", {"profile": "natal", **NO_PROVIDER}, id="horoscope-natal"), + pytest.param("/chart/horoscope", {"profile": "period", **PERIOD, **NO_PROVIDER}, + id="horoscope-period"), + pytest.param("/chart/horoscope/stream", {"profile": "natal", **NO_PROVIDER}, + id="stream-natal"), + pytest.param("/chart/horoscope/stream", {"profile": "period", **PERIOD, **NO_PROVIDER}, + id="stream-period"), +] + + +@pytest.fixture +def clients(monkeypatch, own_engine) -> list: + """Każdy DataClient zbudowany w trakcie żądania, w kolejności tworzenia.""" + built: list = [] + + class SpyDataClient: + def __init__(self, base_url: str | None = None, tenant: str = "") -> None: + self.tenant = tenant + self.searches = 0 + built.append(self) + + def search(self, key, value, exact, limit, fields=None): + self.searches += 1 + return {"provider": "szpieg", "total": 0, "rows": []} + + monkeypatch.setattr(main, "DataClient", SpyDataClient) + monkeypatch.setattr(main, "get_engine", lambda: own_engine) + for env in ("INTERNAL_TOKEN", link_crypto.ENV_REQUIRED, link_crypto.ENV_PRESENTATION_LOGIC): + monkeypatch.delenv(env, raising=False) + return built + + +def _call(path: str, body: dict, reference_moment) -> None: + payload = {"when_utc": reference_moment.when_utc.isoformat(), + "lat": reference_moment.lat, "lon": reference_moment.lon, **body} + r = TestClient(main.app).post(path, json=payload) + assert r.status_code == 200, r.text + if path.endswith("/stream"): + # strumień zawsze odpowiada 200 — o porażce mówi dopiero ostatnia ramka + last = json.loads(r.text.strip().splitlines()[-1]) + assert last["type"] == "result", last + + +@pytest.mark.parametrize("path, body", ROUTES) +def test_account_pool_reaches_the_data_layer(clients, reference_moment, path, body): + _call(path, {**body, "tenant": POOL}, reference_moment) + + # Bez choćby jednego wyszukiwania test przechodziłby na pusto. + assert any(c.searches for c in clients), "trasa nie zapytała warstwy danych" + assert {c.tenant for c in clients} == {POOL} + + +@pytest.mark.parametrize("path, body", ROUTES) +def test_without_a_pool_the_route_searches_the_whole_share(clients, reference_moment, path, body): + """Pełna aplikacja nie zna kont i nie wysyła puli — dla niej nic się nie zmienia.""" + _call(path, body, reference_moment) + + assert any(c.searches for c in clients) + assert {c.tenant for c in clients} == {""}