Files
astrololo/services/presentation/tests/test_ukrywanie_ai.py
T
gitea d094a145b1
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
astrololo: generowanie tekstu jako moduł odłączalny (2/5)
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.

DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.

CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).

JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.

TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:

1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
   wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
   deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
   `llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
   w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
   międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
   jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.

DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.

TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.

Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.

SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.

Testy: presentation 362, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:23:08 +02:00

155 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Generowanie tekstu przez model znika bez uprawnienia (PRE-27 × LOG-29/30/31).
Wymaganie jest MOCNIEJSZE niż „przycisk ma być schowany": po niedostępnej funkcji
nie może zostać ŚLAD w źródle strony. To rozróżnienie ma znaczenie praktyczne —
ukrycie stylem albo atrybutem `hidden` zostawia wszystko w HTML-u, więc podgląd
źródła nadal opowiada, co program potrafi.
Największy wyciek nie był przyciskiem: blok generowania wstrzykiwał w stronę CAŁY
katalog modeli jako JSON — nazwy dostawców, nazwy modeli i rozmiary okien
kontekstu — i robił to na każdej stronie Interpretacji i Kalendarza, niezależnie
od uprawnień konta.
"""
import pytest
from app import accounts as store
# Słowa, które nie mają prawa paść przy koncie bez uprawnienia. Mieszanka
# widocznego tekstu, nazw pól formularza, identyfikatorów dla JS i nazw plików —
# bo ślad zostawia każde z nich z osobna.
SLADY_AI = [
"Generuj prompt", "Napisz horoskop", "llmCatalog", "llmProvider", "llmModel",
"llm_provider", "llm_model", "prompt_budget", "Budżet promptu", "Dostawca",
"anthropic", "openai", "models.js", "progress.js",
'value="prompt"', 'value="horoscope"',
]
RAPORT = {"engine": "test", "objects": []}
KALENDARZ = {"engine": "test", "days": []}
@pytest.fixture()
def env(tmp_path, monkeypatch):
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1")
def _auth(user):
from app import security, session
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
def _client(monkeypatch):
"""Klient ze śledzeniem tego, co poszło do warstwy logicznej."""
from starlette.testclient import TestClient
from app.main import app, logic
wolania = []
monkeypatch.setattr(logic, "report", lambda **kw: (wolania.append("report"), RAPORT)[1])
monkeypatch.setattr(logic, "timeline", lambda **kw: (wolania.append("timeline"), KALENDARZ)[1])
# Generowanie mieszka w module `app/ai/` i ma własnego klienta — wspólny
# klient nie zna tych metod, bo jego plik jedzie do każdego obrazu.
from app.dodatki import klient
monkeypatch.setattr(klient, "prompt",
lambda *a, **kw: (wolania.append("prompt"), {"prompt": "TREŚĆ"})[1])
monkeypatch.setattr(klient, "horoscope",
lambda *a, **kw: (wolania.append("horoscope"), {"text": "TREŚĆ"})[1])
monkeypatch.setattr(klient, "modele", lambda *a: {"providers": {"openai": ["gpt-x"]}})
return TestClient(app, follow_redirects=False), wolania
NATAL = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"}
OKRES = {**NATAL, "from_date": "2026-01-01", "to_date": "2026-02-01"}
# ── brak śladu w źródle strony ──────────────────────────────────────────
@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"])
def test_no_trace_of_the_model_features_without_the_permission(env, monkeypatch, ekran):
store.create("ala", "x", ["interpret", "timeline"])
c, _ = _client(monkeypatch)
html = c.get(ekran, headers=_auth("ala")).text
for slad in SLADY_AI:
assert slad not in html, f"{ekran} zdradza „{slad}”"
@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"])
def test_the_same_screens_carry_them_with_the_permission(env, monkeypatch, ekran):
"""Kontrola pozytywna. Bez niej test wyżej przechodziłby także wtedy, gdyby
generowanie było zepsute dla wszystkich."""
store.create("ola", "x", ["interpret", "timeline", "ai"])
c, _ = _client(monkeypatch)
html = c.get(ekran, headers=_auth("ola")).text
for musi_byc in ("Generuj prompt", "llmCatalog", "llmProvider", "models.js"):
assert musi_byc in html, f"{ekran}: brakuje „{musi_byc}” mimo uprawnienia"
def test_the_model_catalogue_never_reaches_a_page_it_should_not(env, monkeypatch):
"""Katalog jedzie do strony blokiem JSON. Bez ograniczenia nazwy dostawców,
modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony."""
store.create("ala", "x", ["interpret"])
c, _ = _client(monkeypatch)
html = c.get("/interpret", headers=_auth("ala")).text
assert "gpt-x" not in html and "providers" not in html
# ── spreparowane żądanie nie kupuje funkcji ─────────────────────────────
@pytest.mark.parametrize("akcja", ["prompt", "horoscope"])
def test_a_crafted_request_cannot_generate_without_the_permission(env, monkeypatch, akcja):
"""Ukrycie przycisku chroni przed przypadkiem, nie przed kimś, kto zna nazwę
pola. Granicą jest handler."""
store.create("ala", "x", ["interpret"])
c, wolania = _client(monkeypatch)
r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": akcja})
assert r.status_code == 200
assert akcja not in wolania, f"wygenerowano „{akcja}” bez uprawnienia"
assert wolania == ["report"], f"oczekiwano powrotu do domyślnej akcji, było: {wolania}"
@pytest.mark.parametrize("akcja", ["prompt", "horoscope"])
def test_the_same_on_the_calendar_screen(env, monkeypatch, akcja):
store.create("ala", "x", ["timeline"])
c, wolania = _client(monkeypatch)
c.post("/timeline", headers=_auth("ala"), data={**OKRES, "action": akcja})
assert akcja not in wolania and wolania == ["timeline"]
def test_a_crafted_export_needs_the_export_permission_too(env, monkeypatch):
"""Ta sama dziura obejmowała eksport: szablon chował przycisk, handler nie
sprawdzał niczego, więc spreparowane żądanie pobierało arkusz."""
store.create("ala", "x", ["interpret"])
c, _ = _client(monkeypatch)
r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "export"})
assert r.status_code == 200
assert "spreadsheetml" not in r.headers.get("content-type", ""), "pobrano arkusz bez uprawnienia"
def test_the_streaming_route_stays_shut(env, monkeypatch):
"""Pisanie horoskopu idzie osobną trasą — ona też musi być zamknięta,
i to jako NIEISTNIEJĄCA, nie jako odmowa."""
store.create("ala", "x", ["interpret"])
c, wolania = _client(monkeypatch)
r = c.post("/horoscope/stream", headers=_auth("ala"), json={"profile": "natal"})
assert r.status_code == 404
assert wolania == []
def test_refusal_never_explains_itself(env, monkeypatch):
"""Komunikat „brak uprawnień do generowania" sam w sobie mówiłby, że taka
funkcja istnieje. Akcja bez uprawnienia ma zachowywać się jak literówka."""
store.create("ala", "x", ["interpret"])
c, _ = _client(monkeypatch)
html = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "prompt"}).text
for slowo in ("uprawnie", "brak dostępu", "nie wolno", "zabronione", "AI", "model"):
assert slowo not in html, f"odpowiedź tłumaczy odmowę słowem „{slowo}”"