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
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+5
-5
@@ -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
|
||||||
|
|
||||||
+1
-1
@@ -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 SĄ 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 są 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 są dla tego samego człowieka —
|
i bez uprawnień. Ta usługa ma dwie funkcje i obie są 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ć
|
||||||
+2
-3
@@ -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;
|
||||||
+2
-2
@@ -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() %}
|
||||||
+2
-2
@@ -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>
|
||||||
+13
-9
@@ -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"
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user