Files
astrololo/services/astrodemo/tests/test_slownik_zakazany.py
T
gitea 2e9d3706ec
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m50s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 8s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 6s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
astrodemo: zmiana nazwy, rebase na mastera i domknięcie wycieków
Pierwszy z pięciu kroków budowy trzech produktów: astrodemo (dwie funkcje) →
astroklient (pełne astro bez AI) → astrololo (wszystko). Nazwa „astroklient"
zostaje zwolniona dla warstwy pośredniej, więc dotychczasowe astroklient-demo
nazywa się teraz astrodemo.

REBASE. Cztery commity demo przeniesione na aktualnego mastera. Konflikt był
jeden — rejestr wymagań (xlsx, binarny, git go nie scali). Master dodał LOG-34,
gałąź demo PRE-28 i PRE-29; żaden wspólny wiersz się nie różnił, więc scalone
ręcznie: 148 pozycji, wszystkie trzy obecne.

ZMIANA NAZWY. Katalog, ciasteczko sesji (astrodemo_sesja), zmienne
ASTRODEMO_USERS/USER/PASSWORD, CI, README, docstringi. Ponieważ PR z demo nigdy
nie został zmergowany, usługa nie jest nigdzie wdrożona — zmiana nazw niczego
nie migruje i nikogo nie wylogowuje.

WYCIEKI. astrodemo powstało przed audytem z PRE-27, więc miało komplet tych
samych dziur:

- /static omijało bramkę (PUBLIC_PREFIXES), a pierwszy komentarz w styles.css
  brzmiał „Nie kopiujemy stylów pełnej aplikacji" — czyli anonimowy curl
  dowiadywał się, że istnieje pełna aplikacja. Zasoby idą teraz trasą z jawną
  listą, komentarze są zdejmowane przy serwowaniu.
- Komunikat awarii wypisywał na ekran treść wyjątku httpx, z nazwą usługi
  i portem. Teraz jedno neutralne zdanie, szczegóły do dziennika.
- /health oddawał nazwę warstwy. Teraz samo „ok".
- Dziesięć komentarzy i docstringów tłumaczyło decyzje przez porównanie
  z „pełną aplikacją". Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie,
  przeczyta też komentarze. Przepisane tak, żeby opisywały tę usługę samą
  w sobie.
- Nagłówek main.py twierdził, że demo dzieli pulę plików z produkcją. To
  nieprawda od PRE-29 (pule per konto) — opis poprawiony.

ZAPORA SŁOWNIKOWA, dwupoziomowa. Poziom „wszędzie" (także w kodzie serwera, bo
obraz się oddaje) obejmuje wzmianki o większym rodzeństwie, o modelu językowym
i o funkcjach, których tu nie ma. Poziom „do przeglądarki" dokłada słownictwo
mechanizmów. Test sprawdza odpowiedzi ORAZ drzewo plików.

Jeden wyjątek jest jawny i opisany: stałe protokołu łącza (X-Astrololo-Token,
X-Astrololo-Enc, typ treści, etykieta HKDF) niosą nazwę rodziny produktów. Są
wspólne z warstwą logiczną, więc zmiana wymaga jednoczesnej podmiany we
wszystkich usługach i rotacji — osobna decyzja. Osobny test pilnuje warunku, pod
jakim to zostaje: że nie docierają do przeglądarki. Wcześniej przechodziły tylko
dlatego, że regex nie dopasowywał po myślniku — przypadek, nie decyzja.

Przy okazji: komunikat „ramka bez znacznika astrololo" zmieniony na neutralny we
WSZYSTKICH PIĘCIU kopiach link_crypto.py (presentation, astrodemo, logic, data,
render), żeby nie rozjechały się przed scaleniem w rdzeń. Te kopie to 2625 linii
tego samego kodu.

Testy: astrodemo 27, presentation 358, logic 342, data 42, render 41.

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

178 lines
8.3 KiB
Python

"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć.
astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient →
astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to
było wszystko, co program potrafi.
Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się
pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW
usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie
znaleźć, a nie tylko że odpowiedź jest czysta.
Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił
„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim.
"""
import pathlib
import re
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
# (a) że istnieje wersja obszerniejsza i jak się nazywa
WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji",
"astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"]
# (b) że tekst pisze model językowy
# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy,
# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne.
MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt",
"generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"]
# (c) funkcje, których astrodemo nie ma
INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj",
"skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"]
# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie
MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap",
"w budowie", "widok testowy", "warstwa logiczna", "layer"]
# Dwa poziomy, bo dwie różne drogi wycieku.
#
# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy.
# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też
# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu
# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie
# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo.
WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE
# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej
# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego
# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć.
DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY
ZAKAZANE = DO_PRZEGLADARKI
WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"),
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
(r"(?i)\bAI\b", "model językowy")]
def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]:
out = []
for igla in (slownik if slownik is not None else ZAKAZANE):
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
out.append(f"{gdzie}: „{igla}”")
for wzor, powod in (WZORCE if slownik is None else WZORCE[-1:]):
m = re.search(wzor, tresc)
if m:
out.append(f"{gdzie}: „{m.group(0)}” — {powod}")
return out
@pytest.fixture()
def klient(monkeypatch):
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1")
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
from starlette.testclient import TestClient
from app.main import app
return TestClient(app, follow_redirects=False)
def test_nothing_forbidden_reaches_an_anonymous_visitor(klient):
grzechy, obejrzane = [], 0
for adres in ("/logowanie", "/health", "/static/styles.css"):
r = klient.get(adres)
assert r.status_code == 200, f"{adres}: {r.status_code}"
obejrzane += 1
grzechy += _grzechy(r.text, adres)
assert obejrzane == 3
assert not grzechy, "\n" + "\n".join(grzechy)
def test_nothing_forbidden_reaches_a_logged_in_visitor(klient, monkeypatch):
from app import security, session
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
# Warstwa logiczna niedostępna CELOWO: ekran idzie wtedy ścieżką błędu,
# a to komunikaty o awarii wypisywały nazwy warstw i tras.
import httpx
from app.main import LogicClient
monkeypatch.setattr(LogicClient, "report",
lambda *a, **k: (_ for _ in ()).throw(httpx.ConnectError("nie da rady")))
grzechy = []
for adres in ("/", "/static/styles.css"):
r = klient.get(adres, headers=naglowki)
assert r.status_code == 200, f"{adres}: {r.status_code}"
grzechy += _grzechy(r.text, adres)
r = klient.post("/", headers=naglowki,
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"})
grzechy += _grzechy(r.text, "POST /")
assert not grzechy, "\n" + "\n".join(grzechy)
# Stałe protokołu łącza (nagłówki, typ treści, etykieta HKDF) niosą nazwę rodziny
# produktów, ale są WSPÓLNE z warstwą logiczną: zmiana wymaga jednoczesnej zmiany
# we wszystkich usługach i rotacji, więc to osobna decyzja. Nie idą do przeglądarki.
BEZ_KONTROLI = {"app/link_crypto.py"}
def test_the_source_tree_itself_carries_no_trace():
"""„Bez śladu w kodzie" znaczy: grep po obrazie ma nic nie znaleźć.
Odpowiedź może być czysta, a plik i tak nieść komentarz — dokładnie tak
wyciekał arkusz stylów, zanim komentarze zaczęły być zdejmowane."""
# Identyfikatory wymagań (PRE-28, LOG-34) zostają: to notatki inżynierskie
# o tej usłudze, nie informacja o istnieniu czegoś większego.
grzechy = []
for f in APP.rglob("*"):
wzgledna = str(f.relative_to(APP.parent))
if (not f.is_file() or "__pycache__" in str(f) or f.suffix == ".pyc"
or wzgledna in BEZ_KONTROLI):
continue
try:
grzechy += _grzechy(f.read_text(encoding="utf-8"),
str(f.relative_to(APP.parent)), WSZEDZIE)
except UnicodeDecodeError:
continue
assert not grzechy, "\n" + "\n".join(grzechy)
def test_an_unknown_asset_is_indistinguishable_from_one_that_never_existed(klient):
from app import security, session
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
nieistniejacy = klient.get("/static/nie-ma-takiego.js", headers=naglowki)
for adres in ("/static/main.py", "/static/../app/config.py", "/static/vendor/cokolwiek.js"):
r = klient.get(adres, headers=naglowki)
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
assert r.text == nieistniejacy.text
def test_the_protocol_constants_are_the_one_known_exception():
"""Świadomie zostawiona dziura — opisana, żeby nie była przypadkiem.
Stałe łącza międzywarstwowego (`X-Astrololo-Token`, `X-Astrololo-Enc`, typ
treści, etykieta HKDF) niosą nazwę rodziny produktów. Są WSPÓLNE z warstwą
logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach
i rotacji sekretów — to osobna decyzja, nie efekt uboczny tej zmiany.
Warunek, pod jakim to zostaje: te stałe nie mają prawa dotrzeć do
przeglądarki. Idą wyłącznie w nagłówkach żądań DO warstwy logicznej.
Ten test pilnuje właśnie tego warunku, a nie samego istnienia stałych.
"""
rdzen = "astrololo"
protokol = [f for f in APP.rglob("*.py")
if f.is_file() and rdzen in f.read_text(encoding="utf-8").lower()]
assert protokol, "stałe zniknęły — jeśli je przemianowano, ten wyjątek jest zbędny"
for f in protokol:
tresc = f.read_text(encoding="utf-8").lower()
for linia in tresc.splitlines():
if rdzen not in linia:
continue
# Każde wystąpienie musi być stałą protokołu albo nazwą kanału logów —
# nigdy tekstem, który mógłby trafić do szablonu.
assert any(z in linia for z in ("x-astrololo", "vnd.astrololo",
"astrololo/link/", 'getlogger("astrololo')), \
f"{f.name}: „{linia.strip()[:70]}” to nie jest stała protokołu"