Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4102039447 | |||
| 5c9989f05e |
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} == {""}
|
||||
Reference in New Issue
Block a user