astrodemo: zmiana nazwy, rebase na mastera i domknięcie wycieków
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

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>
This commit is contained in:
2026-08-26 12:05:40 +02:00
parent c73ba6a964
commit 2e9d3706ec
24 changed files with 327 additions and 99 deletions
+3 -3
View File
@@ -9,12 +9,12 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Login - name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
# astroklient-demo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze, # astrodemo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze,
# więc jego obraz ma powstawać z tego samego commita co reszta produktu. # więc jego obraz ma powstawać z tego samego commita co reszta produktu.
- name: Build & push (data, logic, presentation, astroklient-demo) - name: Build & push (data, logic, presentation, astrodemo)
run: | run: |
TAG=${GITHUB_SHA::8} TAG=${GITHUB_SHA::8}
for SVC in data logic presentation astroklient-demo; do for SVC in data logic presentation astrodemo; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done done
+5 -5
View File
@@ -91,8 +91,8 @@ jobs:
PYTHONPATH: . PYTHONPATH: .
run: pytest tests -q -rs run: pytest tests -q -rs
astroklient-demo-tests: astrodemo-tests:
name: Testy astroklienta-demo name: Testy astrodemo
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -100,14 +100,14 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
cache: pip cache: pip
cache-dependency-path: services/astroklient-demo/requirements-dev.txt cache-dependency-path: services/astrodemo/requirements-dev.txt
- name: Instalacja zależności - name: Instalacja zależności
run: pip install -r services/astroklient-demo/requirements-dev.txt run: pip install -r services/astrodemo/requirements-dev.txt
# Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być # Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być
# pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła # pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła
# żadna trasa poza dwiema funkcjami. # żadna trasa poza dwiema funkcjami.
- name: Testy (pytest) - name: Testy (pytest)
working-directory: services/astroklient-demo working-directory: services/astrodemo
env: env:
PYTHONPATH: . PYTHONPATH: .
run: pytest tests -q -rs run: pytest tests -q -rs
@@ -1,4 +1,4 @@
# astroklient-demo — wersja demonstracyjna (PRE-28) # astrodemo — wersja demonstracyjna (PRE-28)
Osobna warstwa prezentacji o **dwóch funkcjach**: Osobna warstwa prezentacji o **dwóch funkcjach**:
@@ -18,7 +18,7 @@ okazji kodu reszty programu.
## Izolacja: własne dane, pula na konto ## Izolacja: własne dane, pula na konto
``` ```
astroklient-demo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie) astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
└── klientA/ ← pula konta └── klientA/ ← pula konta
└── klientB/ ← pula konta └── klientB/ ← pula konta
``` ```
@@ -42,15 +42,15 @@ w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza **Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`, utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
`astrololo/README-astroklient-demo.md`). `astrololo/README-astrodemo.md`).
## Konfiguracja ## Konfiguracja
| zmienna | znaczenie | | zmienna | znaczenie |
|---|---| |---|---|
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) | | `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
| `DEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików | | `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
| `DEMO_USER` / `DEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `DEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie | | `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług | | `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja | | `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza | | `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
@@ -66,16 +66,16 @@ cd services/presentation && python scripts/make_user.py demo
## Uruchomienie lokalne ## Uruchomienie lokalne
```bash ```bash
cd services/astroklient-demo cd services/astrodemo
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
DEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \ ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
uvicorn app.main:app --port 8005 uvicorn app.main:app --port 8005
``` ```
Testy: Testy:
```bash ```bash
cd services/astroklient-demo && PYTHONPATH=. pytest tests -q cd services/astrodemo && PYTHONPATH=. pytest tests -q
``` ```
## Co pilnują testy ## Co pilnują testy
@@ -1,8 +1,8 @@
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28). """Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
Astroklient nie dziedziczy klienta z pełnej prezentacji i nie importuje go: ma tu Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma,
być wyłącznie to, czego naprawdę używa. Metoda, której nie ma, nie zostanie nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest
przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest coś więcej. coś więcej.
Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym
tokenem międzywarstwowym (LOG-32) demo nie jest furtką omijającą ochronę. tokenem międzywarstwowym (LOG-32) demo nie jest furtką omijającą ochronę.
@@ -45,8 +45,8 @@ class LogicClient:
def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]: def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]:
"""Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach.
Jedyne pytanie, jakie astroklient-demo zadaje o treść bez grupowania, Jedyne pytanie, jakie astrodemo zadaje o treść bez grupowania,
bez promptów, bez modelu językowego.""" bez grupowania i bez żadnej obróbki treści."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"limit": settings.report_limit, "group": False, "limit": settings.report_limit, "group": False,
"tenant": self.tenant} "tenant": self.tenant}
@@ -1,7 +1,6 @@
"""Konfiguracja astroklienta-demo — wersji demonstracyjnej (PRE-28). """Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28).
Zna WYŁĄCZNIE adres warstwy logicznej, tak samo jak pełna prezentacja. Nie wie Zna WYŁĄCZNIE adres warstwy logicznej. Nie wie nic o bazach, Excelu ani SQL-u.
nic o bazach, Excelu ani SQL-u.
""" """
from __future__ import annotations from __future__ import annotations
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int, def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes: frame: bytes) -> bytes:
if not frame.startswith(MAGIC): if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo") raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):] body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES: if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka") raise LinkError("ramka za krótka")
@@ -1,42 +1,86 @@
"""astroklient-demo — wersja DEMONSTRACYJNA (PRE-28). """astrodemo — wersja DEMONSTRACYJNA (PRE-28).
DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ: DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ:
1. dodanie pliku bazy (wgranie + włączenie do użytku), 1. dodanie pliku bazy (wgranie + włączenie do użytku),
2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych. 2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych.
DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27 DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Ukrycie a nieobecność to
umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy. dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie
Tutaj pozostałych funkcji NIE MA W OBRAZIE: nie ma tras, nie ma szablonów, nie ma ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać
przy okazji kodu reszty programu. na ekranie łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem.
UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient-demo pracuje na TEJ SAMEJ warstwie PULE ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików,
danych co produkcja. Kto ma do niego dostęp, czyta oryginalne bazy interpretacyjne, niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej
a pliki przez niego wgrane trafiają do produkcyjnego zbioru. Dlatego konto jest osoby, nigdy z pola formularza. Konta osobne (ASTRODEMO_USERS) żeby dało się
osobne (DEMO_USER/DEMO_PASSWORD) żeby dało się odciąć demo jedną zmienną, bez odciąć tę usługę jedną zmienną, bez
ruszania kont głównej aplikacji. ruszania kont głównej aplikacji.
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import logging
import re
from functools import lru_cache
from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import httpx import httpx
from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app import link_crypto, security, session from app import link_crypto, security, session
from app.clients.logic_client import LogicClient from app.clients.logic_client import LogicClient
# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs # Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa
# wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. # ma nie zdradzać nawet własnej powierzchni.
app = FastAPI(title="astroklient-demo", docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None)
security.install(app) security.install(app)
app.mount("/static", StaticFiles(directory="app/static"), name="static") # ── zasoby (PRE-27) ────────────────────────────────────────────────────────
# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu
# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje
# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest
# publiczny, bo
# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje.
ZASOBY_PUBLICZNE = frozenset({"styles.css"})
_KATALOG_ZASOBOW = Path("app/static")
def _bez_komentarzy(tresc: str) -> str:
"""Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt.
W repozytorium zostaje do odpowiedzi nie ma po co trafiać."""
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
@lru_cache(maxsize=None)
def _zasob(nazwa: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
if nazwa not in ZASOBY_PUBLICZNE:
return None
sciezka = (_KATALOG_ZASOBOW / nazwa).resolve()
# Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz.
if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file():
return None
if sciezka.suffix in (".css", ".js"):
return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8")
return sciezka.read_bytes()
@app.get("/static/{nazwa:path}")
def zasob(nazwa: str):
tresc = _zasob(nazwa)
if tresc is None:
raise HTTPException(status_code=404)
typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream"
return Response(tresc, media_type=typ,
headers={"Cache-Control": "public, max-age=3600"})
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad).
log = logging.getLogger("astrodemo")
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or "" templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
templates.env.globals["auth_on"] = security.auth_enabled templates.env.globals["auth_on"] = security.auth_enabled
@@ -50,7 +94,7 @@ def _logic(request: Request) -> LogicClient:
return LogicClient(tenant=getattr(request.state, "user", "") or "") return LogicClient(tenant=getattr(request.state, "user", "") or "")
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana # Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo astroklient-demo nie importuje tamtej warstwy; literówka objawiłaby # tutaj wprost, bo astrodemo nie importuje tamtej warstwy; literówka objawiłaby
# się dopiero na żywo, więc pilnuje jej test. # się dopiero na żywo, więc pilnuje jej test.
ACTIVE = "active" ACTIVE = "active"
@@ -68,15 +112,16 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M") return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M")
# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku
# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym
# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze.
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
def _blad(e: Exception) -> str: def _blad(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError): """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran."""
try: log.warning("operacja nieudana: %s: %s", type(e).__name__, e)
return str(e.response.json().get("detail") or e) return _AWARIA
except Exception: # noqa: BLE001
return str(e)
if isinstance(e, link_crypto.LinkError):
return f"Łącze do warstwy logicznej nie działa: {e}"
return f"Warstwa logiczna niedostępna: {e}"
def _ile_rekordow(report: dict) -> int: def _ile_rekordow(report: dict) -> int:
@@ -113,13 +158,12 @@ def interpretacja(request: Request, date: str = Form(...), time: str = Form(...)
async def dodaj_plik(request: Request, upload: UploadFile = File(...)): async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
"""Dodanie pliku bazy: wgranie ORAZ włączenie do użytku. """Dodanie pliku bazy: wgranie ORAZ włączenie do użytku.
W pełnej aplikacji to dwie osobne decyzje (DAN-27), bo tam ktoś nad tym panuje. Jedna czynność, nie dwie: dodać plik" ma tu znaczyć, że od razu bierze udział
Tutaj dodać do bazy" ma znaczyć, że plik od razu bierze udział w wyszukiwaniu — w wyszukiwaniu inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to
inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute. na zepsute.
Walidacja zostaje: plik o złym układzie NIE wchodzi do użytku, ale też NIE JEST Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony zostaje
tracony leży dalej na udziale i decyzję o nim podejmuje administrator zapisany i czeka."""
w pełnej aplikacji."""
raw = await upload.read() raw = await upload.read()
if not raw: if not raw:
return RedirectResponse("/?error=Pusty+plik.", status_code=303) return RedirectResponse("/?error=Pusty+plik.", status_code=303)
@@ -153,10 +197,9 @@ def _wroc(error: str = "", done: str = "") -> RedirectResponse:
# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- # ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Ta sama konstrukcja co w pełnej aplikacji. Różnica jest jedna i wynika z tego, # Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc
# że demo nie ma własnego wolumenu: nie ma licznika pokolenia sesji, więc # „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS.
# „wyrzuć zewsząd" robi się zmianą hasła w DEMO_USERS. Wylogowanie i tak działa # Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka.
# natychmiast, bo polega na skasowaniu ciasteczka.
def _bezpieczne_dokad(dokad: str) -> str: def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl` """Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
@@ -207,4 +250,6 @@ def wyloguj(request: Request):
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok", "layer": "astroklient-demo"} # Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa;
# kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
@@ -1,4 +1,4 @@
"""Logowanie do astroklienta-demo (PRE-28). """Logowanie do astrodemo (PRE-28).
OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej
warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy
@@ -6,7 +6,7 @@ interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną
środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek. środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont
i bez uprawnień. Astroklient ma dwie funkcje i obie dla tego samego człowieka i bez uprawnień. Ta usługa ma dwie funkcje i obie dla tego samego człowieka
model uprawnień z PRE-27 nie miałby tu czego dzielić. model uprawnień z PRE-27 nie miałby tu czego dzielić.
Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem
@@ -26,23 +26,27 @@ from collections import deque
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astroklient-demo.security") log = logging.getLogger("astrodemo.security")
audit_log = logging.getLogger("astroklient-demo.audit") audit_log = logging.getLogger("astrodemo.audit")
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 _SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$" _HASH_PREFIX = "scrypt$"
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być # /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji. # zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"}) PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
PUBLIC_PREFIXES = ("/static/",) # Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
# z nazwy, bo potrzebuje go ekran logowania.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {} _hits: dict[str, deque[float]] = {}
MAX_TRACKED_CLIENTS = 4096 MAX_TRACKED_CLIENTS = 4096
def accounts() -> dict[str, str]: def accounts() -> dict[str, str]:
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej DEMO_USERS. """Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS.
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29). WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
@@ -51,10 +55,10 @@ def accounts() -> dict[str, str]:
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) ten sam format i ten Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) ten sam format i ten
sam skrypt make_user.py, co w głównej aplikacji. sam skrypt make_user.py, co w głównej aplikacji.
Zgodność wstecz: pojedyncze DEMO_USER/DEMO_PASSWORD działa, gdy DEMO_USERS Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
jest puste.""" jest puste."""
out: dict[str, str] = {} out: dict[str, str] = {}
for entry in os.getenv("DEMO_USERS", "").split(","): for entry in os.getenv("ASTRODEMO_USERS", "").split(","):
entry = entry.strip() entry = entry.strip()
if not entry: if not entry:
continue continue
@@ -63,8 +67,8 @@ def accounts() -> dict[str, str]:
out[user.strip()] = secret.strip() out[user.strip()] = secret.strip()
if out: if out:
return out return out
single = os.getenv("DEMO_PASSWORD", "") single = os.getenv("ASTRODEMO_PASSWORD", "")
return {os.getenv("DEMO_USER", "demo"): single} if single else {} return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {}
def rate_limit_per_min() -> int: def rate_limit_per_min() -> int:
@@ -109,7 +113,7 @@ def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta. """Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
konta z DEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu, sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
bo nie ma własnego wolumenu a stan sesji na udziale z bazami klientów bo nie ma własnego wolumenu a stan sesji na udziale z bazami klientów
byłby pomieszaniem dwóch zupełnie różnych rzeczy.""" byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
@@ -217,7 +221,7 @@ def install(app) -> None:
if not auth_enabled(): if not auth_enabled():
log.warning( log.warning(
"UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient-demo stoi OTWARTY. " "UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. "
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym " "Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
"zbiorze baz, widząc nawzajem swoje wgrania." "zbiorze baz, widząc nawzajem swoje wgrania."
) )
@@ -40,10 +40,10 @@ import json
import os import os
import time import time
# Własna nazwa, nie ta z pełnej aplikacji: gdyby obie stały kiedyś pod jedną # Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
# domeną, ciasteczka o tej samej nazwie nadpisywałyby się nawzajem i człowiek # ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
# wypadałby z jednej aplikacji, logując się do drugiej. # z jednej, logując się do drugiej.
COOKIE = "astroklient_sesja" COOKIE = "astrodemo_sesja"
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji. # Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
@@ -1,6 +1,5 @@
/* astroklient-demo arkusz CELOWO minimalny. Nie kopiujemy stylów pełnej aplikacji: /* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to
demo ma dwa formularze i tabelę wyników, a każda przeniesiona klasa niosłaby niosłaby nazwę funkcji, której tu nie ma. */
nazwy funkcji, których tu nie ma. */
:root { :root {
--bg: #11121b; --panel: #191b26; --line: #2b2e3d; --bg: #11121b; --panel: #191b26; --line: #2b2e3d;
--fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff; --fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff;
@@ -3,13 +3,13 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astroklient-demo · {% block title %}{% endblock %}</title> <title>astrodemo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<main> <main>
<header class="topbar"> <header class="topbar">
<h1>astroklient-demo</h1> <h1>astrodemo</h1>
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach, {# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #} więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
{% if auth_on() %} {% if auth_on() %}
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astroklient-demo · logowanie</title> <title>astrodemo · logowanie</title>
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<main class="login-main"> <main class="login-main">
<header class="topbar"><h1>astroklient-demo</h1></header> <header class="topbar"><h1>astrodemo</h1></header>
{% if request.query_params.get('wylogowano') %} {% if request.query_params.get('wylogowano') %}
<p class="ok">Wylogowano.</p> <p class="ok">Wylogowano.</p>
@@ -1,4 +1,4 @@
"""astroklient-demo — wersja demonstracyjna (PRE-28). """astrodemo — wersja demonstracyjna (PRE-28).
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
@@ -19,9 +19,9 @@ from app import security
@pytest.fixture() @pytest.fixture()
def env(monkeypatch): def env(monkeypatch):
monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB") monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
monkeypatch.delenv("DEMO_USER", raising=False) monkeypatch.delenv("ASTRODEMO_USER", raising=False)
monkeypatch.delenv("DEMO_PASSWORD", raising=False) monkeypatch.delenv("ASTRODEMO_PASSWORD", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34). # Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
monkeypatch.setenv("SESSION_SECRET", "t" * 64) monkeypatch.setenv("SESSION_SECRET", "t" * 64)
@@ -118,7 +118,7 @@ def test_without_credentials_nothing_is_served(env, monkeypatch):
def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
"""Konto astroklienta-demo MUSI być własne: demo pracuje na tej samej warstwie """Konto astrodemo MUSI być własne: demo pracuje na tej samej warstwie
danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł
w głównej aplikacji.""" w głównej aplikacji."""
monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_USER", "astrololo")
@@ -129,7 +129,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): def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą") monkeypatch.setenv("ASTRODEMO_USERS", "demo:zażółć-gęślą")
c, _ = _client(monkeypatch) c, _ = _client(monkeypatch)
@@ -204,7 +204,11 @@ def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch):
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw( monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
httpx.ConnectError("brak połączenia"))) httpx.ConnectError("brak połączenia")))
r = c.post("/", headers=_auth(), data=FORM) r = c.post("/", headers=_auth(), data=FORM)
assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text # Komunikat jest CELOWO nieinformacyjny: treść wyjątku niosła nazwę usługi,
# jej port i nazwy tras, czyli mapę tego, co stoi za tym ekranem.
assert r.status_code == 200
assert "Nie udało się teraz wykonać tej operacji" in r.text
assert "Warstwa logiczna" not in r.text and "8001" not in r.text
def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch): def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch):
@@ -280,12 +284,12 @@ def test_logging_in_and_out_of_the_demo(env, monkeypatch):
def test_removing_an_account_kills_its_open_session(env, monkeypatch): def test_removing_an_account_kills_its_open_session(env, monkeypatch):
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi """Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
się przez DEMO_USERS usunięcie albo zmiana hasła ubija sesję odciskiem.""" się przez ASTRODEMO_USERS usunięcie albo zmiana hasła ubija sesję odciskiem."""
c, _ = _client(monkeypatch) c, _ = _client(monkeypatch)
ciasteczko = _auth("klientA") ciasteczko = _auth("klientA")
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200 assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303 assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
@@ -0,0 +1,177 @@
"""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"
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int, def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes: frame: bytes) -> bytes:
if not frame.startswith(MAGIC): if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo") raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):] body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES: if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka") raise LinkError("ramka za krótka")
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int, def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes: frame: bytes) -> bytes:
if not frame.startswith(MAGIC): if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo") raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):] body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES: if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka") raise LinkError("ramka za krótka")
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int, def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes: frame: bytes) -> bytes:
if not frame.startswith(MAGIC): if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo") raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):] body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES: if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka") raise LinkError("ramka za krótka")
+1 -1
View File
@@ -153,7 +153,7 @@ class Link:
def open(self, direction: bytes, path: str, stamp: str, seq: int, def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes: frame: bytes) -> bytes:
if not frame.startswith(MAGIC): if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo") raise LinkError("ramka bez znacznika protokołu")
body = frame[len(MAGIC):] body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES: if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka") raise LinkError("ramka za krótka")