feat(astroklient): pule plików per konto i izolacja od produkcji (PRE-29)

Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto
na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby
dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy.

IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo
deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych —
demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.

PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli;
puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic
nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych
baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda.

PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta
trafiłby z cache do drugiego — cicha wymiana treści baz między klientami,
niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje.

PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany
per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła
z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user`
i `login` w polach formularza i sprawdza, że nie mają na nią wpływu.

Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba
by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach
nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią
zapomnieć i sięgnąć nie tam.

Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby
wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji.

Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty
wgrań — procedurę importu opisuje runbook w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:00:39 +02:00
parent e7b6d5013b
commit caf4fd80d1
12 changed files with 301 additions and 79 deletions
+67 -24
View File
@@ -17,8 +17,9 @@ import pytest
@pytest.fixture()
def env(monkeypatch):
monkeypatch.setenv("DEMO_USER", "demo")
monkeypatch.setenv("DEMO_PASSWORD", "tajne-demo")
monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
monkeypatch.delenv("DEMO_USER", raising=False)
monkeypatch.delenv("DEMO_PASSWORD", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
@@ -39,20 +40,25 @@ REPORT = {
}
def _client(monkeypatch, calls=None):
def _client(monkeypatch, calls=None, upload=None):
"""Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per
żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy
też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie."""
from starlette.testclient import TestClient
from app.main import app, logic
from app.clients.logic_client import LogicClient
from app.main import app
calls = calls if calls is not None else []
monkeypatch.setattr(logic, "report",
lambda **kw: (calls.append(("report", kw)), REPORT)[1])
monkeypatch.setattr(logic, "files_upload",
lambda *a, **kw: (calls.append(("upload", a, kw)),
{"path": "nowa.xlsx", "name": "nowa.xlsx",
"accepted": True})[1])
monkeypatch.setattr(logic, "files_status",
lambda *a, **kw: (calls.append(("status", a, kw)), {})[1])
domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True}
monkeypatch.setattr(LogicClient, "report",
lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1])
monkeypatch.setattr(LogicClient, "files_upload",
lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)),
upload or domyslny)[1])
monkeypatch.setattr(LogicClient, "files_status",
lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[1])
return TestClient(app), calls
@@ -109,7 +115,7 @@ def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
monkeypatch.setenv("DEMO_PASSWORD", "zażółć-gęślą")
monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą")
c, _ = _client(monkeypatch)
assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401
assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200
@@ -125,7 +131,7 @@ def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch):
assert r.status_code == 303
rodzaje = [k[0] for k in calls]
assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}"
assert calls[1][1][1] == "active", "plik nie został włączony do użytku"
assert calls[1][2][1] == "active", "plik nie został włączony do użytku"
def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch):
@@ -133,13 +139,8 @@ def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, mon
demo nie ma prawa ich zdradzić, nawet mimochodem."""
from urllib.parse import unquote_plus
from app.main import logic
c, calls = _client(monkeypatch)
monkeypatch.setattr(logic, "files_upload", lambda *a, **kw: (
calls.append(("upload", a, kw)),
{"path": "zla.xlsx", "name": "zla.xlsx", "accepted": False})[1])
c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx",
"accepted": False})
r = c.post("/plik", headers=_auth(), follow_redirects=False,
files={"upload": ("zla.xlsx", b"x", "text/plain")})
assert r.status_code == 303
@@ -171,7 +172,7 @@ def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch):
a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost."""
c, calls = _client(monkeypatch)
c.post("/", headers=_auth(), data=FORM)
assert calls[0][1]["when_utc_iso"].startswith("1984-04-30T09:20")
assert calls[0][2]["when_utc_iso"].startswith("1984-04-30T09:20")
def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
@@ -184,10 +185,10 @@ def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch):
import httpx
from app.main import logic
from app.clients.logic_client import LogicClient
c, _ = _client(monkeypatch)
monkeypatch.setattr(logic, "report", lambda **kw: (_ for _ in ()).throw(
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
httpx.ConnectError("brak połączenia")))
r = c.post("/", headers=_auth(), data=FORM)
assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text
@@ -201,3 +202,45 @@ def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch):
for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia",
"Konta", "PDF", "Excel", "prompt", "model"):
assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać
# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient
# zobaczyłby cudze bazy jako własne.
def test_the_pool_is_taken_from_the_login(env, monkeypatch):
c, calls = _client(monkeypatch)
c.post("/", headers=_auth("klientA", "hasloA"), data=FORM)
c.post("/", headers=_auth("klientB", "hasloB"), data=FORM)
assert [k[1] for k in calls] == ["klientA", "klientB"]
def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch):
c, calls = _client(monkeypatch)
c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False,
files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")})
assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli"
def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch):
"""Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login."""
c, calls = _client(monkeypatch)
c.post("/", headers=_auth("klientA", "hasloA"),
data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"})
assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza"
def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
"""Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz."""
c, calls = _client(monkeypatch)
for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")):
c.post("/", headers=_auth(login, haslo), data=FORM)
pule = [k[1] for k in calls]
assert len(set(pule)) == len(pule) == 3
def test_an_unknown_account_gets_nothing(env, monkeypatch):
c, calls = _client(monkeypatch)
assert c.post("/", headers=_auth("obcy", "cokolwiek"), data=FORM).status_code == 401
assert calls == [], "poszło zapytanie mimo odmowy logowania"