astroklient: warstwa pośrednia — pełne astro, bez generowania i administracji (4/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
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 / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
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 / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo. Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią: własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu. CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem. CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt. KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się po to, żeby każde miało własną pulę plików. PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji. Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje klienta danych na żądanie. Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza, że w dół poszedł login zalogowanego. WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma. Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę. Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany plików), bo produkt bez tego ekranu wlókł ich zależności. ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk. Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić. usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie, albo błąd dopiero przy uruchomieniu. CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia użytkownika. Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #83.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
|
||||
|
||||
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
|
||||
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
|
||||
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
|
||||
inaczej sprawdzałyby garść plików, a nie produkt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
USLUGA = pathlib.Path(__file__).resolve().parents[1]
|
||||
RDZEN = USLUGA.parents[0] / "presentation" / "app"
|
||||
|
||||
|
||||
def zloz(cel: pathlib.Path) -> pathlib.Path:
|
||||
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
|
||||
app = cel / "app"
|
||||
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
|
||||
sciezka = linia.strip()
|
||||
if not sciezka:
|
||||
continue
|
||||
do_usuniecia = app / sciezka
|
||||
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
|
||||
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
|
||||
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
return cel
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def drzewo(tmp_path_factory) -> pathlib.Path:
|
||||
return zloz(tmp_path_factory.mktemp("astroklient"))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
|
||||
|
||||
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
|
||||
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
|
||||
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
|
||||
trafia do obrazu, który komuś się oddaje.
|
||||
|
||||
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
|
||||
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
|
||||
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
|
||||
wyliczające nieobecne funkcje.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
|
||||
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
|
||||
"anthropic", "openai", "gpt", "claude", "horoscope",
|
||||
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
|
||||
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
|
||||
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
|
||||
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
|
||||
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
|
||||
|
||||
|
||||
def _pliki(drzewo: pathlib.Path):
|
||||
for f in sorted((drzewo / "app").rglob("*")):
|
||||
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
|
||||
yield f
|
||||
|
||||
|
||||
def test_the_shipped_tree_carries_no_trace(drzewo):
|
||||
grzechy = []
|
||||
for f in _pliki(drzewo):
|
||||
try:
|
||||
tresc = f.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
gdzie = f.relative_to(drzewo)
|
||||
for slowo in SLOWA:
|
||||
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
|
||||
grzechy.append(f"{gdzie}: „{slowo}”")
|
||||
for wzor, powod in WZORCE:
|
||||
m = re.search(wzor, tresc)
|
||||
if m:
|
||||
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||
assert not grzechy, (
|
||||
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
|
||||
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
|
||||
|
||||
|
||||
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
|
||||
"""Kontrola negatywna: zapora ma coś łapać.
|
||||
|
||||
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
|
||||
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
|
||||
podrzucony = drzewo / "app" / "podrzucony.py"
|
||||
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
|
||||
try:
|
||||
with pytest.raises(AssertionError):
|
||||
test_the_shipped_tree_carries_no_trace(drzewo)
|
||||
finally:
|
||||
podrzucony.unlink()
|
||||
|
||||
|
||||
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
|
||||
"""usun.txt i main.py muszą mówić to samo.
|
||||
|
||||
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
|
||||
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
|
||||
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
|
||||
"""
|
||||
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
|
||||
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
|
||||
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
|
||||
if f.stem != "__init__"}
|
||||
assert zaimportowane == obecne, (
|
||||
f"ekrany w drzewie: {sorted(obecne)}\n"
|
||||
f"ekrany importowane: {sorted(zaimportowane)}")
|
||||
|
||||
|
||||
def test_no_optional_module_survived(drzewo):
|
||||
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
|
||||
assert not (drzewo / "app" / "moduly").exists()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
|
||||
|
||||
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
|
||||
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SONDA = '''
|
||||
import json, os, hashlib, secrets, re
|
||||
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 starlette.testclient import TestClient
|
||||
from app.main import app
|
||||
from app import security, session, features
|
||||
from app.clients import logic_client
|
||||
c = TestClient(app, follow_redirects=False)
|
||||
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
|
||||
strona = c.get("/", headers=H).text
|
||||
zebrane = []
|
||||
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
|
||||
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
|
||||
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
|
||||
print("WYNIK:" + json.dumps({
|
||||
"ekrany": [f.label for f in features.SCREENS],
|
||||
"uprawnienia": sorted(security._uprawnienia()),
|
||||
"dziala": {a: c.get(a, headers=H).status_code
|
||||
for a in ("/", "/interpret", "/timeline", "/synastry",
|
||||
"/significators", "/pliki")},
|
||||
"nie_ma": {a: c.get(a, headers=H).status_code
|
||||
for a in ("/compile", "/settings", "/accounts", "/files",
|
||||
"/horoscope/stream", "/compile/pdf")},
|
||||
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
|
||||
"pula": (zebrane[0] or {}).get("tenant"),
|
||||
"logowanie_dziala": bool(security.verify("klient", "tajne")),
|
||||
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
|
||||
}))
|
||||
'''
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def wynik(drzewo):
|
||||
"""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,
|
||||
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:"):])
|
||||
|
||||
|
||||
def test_the_build_starts_at_all(wynik):
|
||||
assert wynik["dziala"]["/"] == 200
|
||||
|
||||
|
||||
def test_every_astrological_screen_works(wynik):
|
||||
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
|
||||
|
||||
|
||||
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
|
||||
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
|
||||
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
|
||||
|
||||
|
||||
def test_the_catalogue_describes_this_product(wynik):
|
||||
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
|
||||
"Synastria", "Sygnifikatory", "Pliki"}
|
||||
|
||||
|
||||
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
|
||||
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
|
||||
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
|
||||
assert "ai" not in wynik["uprawnienia"]
|
||||
assert "export" not in wynik["uprawnienia"]
|
||||
assert "admin" not in wynik["uprawnienia"]
|
||||
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
|
||||
|
||||
|
||||
def test_navigation_has_no_dead_links(wynik):
|
||||
assert wynik["nawigacja"] == len(wynik["ekrany"])
|
||||
|
||||
|
||||
def test_login_still_tells_right_password_from_wrong(wynik):
|
||||
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
|
||||
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user