3ce3911f55
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m50s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m50s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 35s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 17s
Warstwy rozmawialy ze soba jawnym tekstem wewnatrz klastra. Token miedzywarstwowy (LOG-32) mowil KTO pyta, ale nie ukrywal CZEGO dotyczy odpowiedz — a plyna nia surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzen produktu. Kto podsluchal ruch wewnatrz sieci (drugi pod, mirror portu na switchu, zrzut z wezla), mial je w calosci. Nowy modul link_crypto (kopia w kazdej z trzech uslug — nie maja wspolnej biblioteki; test pilnuje, ze kopie sa identyczne): - AES-256-GCM na ciele kazdego zadania i odpowiedzi. GCM daje poufnosc I uwierzytelnienie naraz, wiec nie ma wariantu „zaszyfrowane, ale podatne na modyfikacje". - DWA niezalezne klucze, po jednym na pare rozmowcow (prezentacja-logika, logika-dane). Przejecie klucza prezentacji nie otwiera warstwy danych, gdzie leza cale bazy. Z kazdego klucza lacza HKDF wyprowadza osobne podklucze na kierunek, wiec zadanie i odpowiedz nigdy nie szyfruja sie tym samym kluczem. - Do materialu uwierzytelnianego (AAD) wchodza kierunek, sciezka, znacznik czasu i numer ramki — wiec ramki nie da sie przekleic na inny endpoint, odtworzyc po czasie (okno MAX_SKEW) ani przestawic w strumieniu. - Strona serwerowa to czyste ASGI: podmienia cialo zanim zobaczy je FastAPI i przepuszcza odpowiedz strumieniowa kawalek po kawalku (okno postepu dziala dalej). Fail-closed: przy ustawionym kluczu jawne zadanie dostaje odmowe. Najgrozniejszy blad wyszedl dopiero z PODSLUCHU prawdziwego gniazda, nie z testow: klient BEZ klucza wysylal pytanie jawnym tekstem, ZANIM serwer zdazyl odmowic. Odpowiedz byla chroniona, zapytanie juz nie — a to wlasnie ono niesie sygnifikatory. Stad LINK_ENCRYPTION_REQUIRED: klient nie wysyla niczego, a usluga nie wstaje, jesli klucza brak. Ta sama zasada co przy sekrecie logowania — wolimy pod w CrashLoop niz usluge, ktora wstala i po cichu nie chroni niczego. Klient prezentacji przepuszczony przez jeden punkt `_post()`: dopoki kazda metoda skladala zadanie sama, dolozenie nowej znaczylo, ze latwo zapomniec o tokenie albo kluczu (401 wyszedl juz raz dopiero na produkcji). Test strukturalny rozszerzony: kazde wyjscie w dol musi miec i token, i klucz lacza, a surowe httpx wolno tylko na sciezkach wyjetych spod szyfrowania (/health). Weryfikacja: - 23 testy link_crypto, w tym dowod, ze tajny opis NIE wystepuje w bajtach lecacych po sieci, oraz odrzucenie: obcego klucza, przestawionego bitu, przekleconej sciezki, przestawionej ramki, przeterminowanej koperty, urwanego strumienia i absurdalnej dlugosci ramki. - E2E na prawdziwym uvicornie z proxy zrzucajacym gniazdo do pliku: przy wlaczonym wymaganiu tresci baz NIE MA na kablu (grep = 0) w obie strony; bez klucza / ze zlym kluczem — odmowa; klucz jednej pary nie otwiera drugiej. - Calosc: logika 200 passed/1 skipped, prezentacja 25 passed. docs/wdrozenie-pre16.md: instrukcja krok po kroku (klucze -> cert-manager -> DNS -> merge aplikacji -> merge manifestow -> zaufanie CA -> weryfikacja), z uzasadnieniem kolejnosci i tabela diagnostyki. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
6.3 KiB
Python
156 lines
6.3 KiB
Python
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32)
|
|
oraz klucz szyfrujący łącze (PRE-16).
|
|
|
|
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
|
|
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
|
|
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
|
|
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
|
|
strukturalnie.
|
|
|
|
Po dołożeniu szyfrowania ta sama klasa błędu ma gorszy objaw: wywołanie bez `link=`
|
|
nie wywala się widocznie, tylko po cichu wysyła treść JAWNYM tekstem. Dlatego dla
|
|
wywołań przez `link_crypto` wymagamy obu argumentów naraz.
|
|
"""
|
|
import ast
|
|
import pathlib
|
|
|
|
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
|
|
|
|
HTTP_VERBS = ("post", "get", "put", "patch", "delete")
|
|
LINK_CALLS = ("call", "call_json")
|
|
|
|
|
|
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool, bool]]:
|
|
"""(opis, linia, czy_ma_headers, czy_wymaga_i_ma_link) dla każdego wyjścia w dół."""
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
out = []
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
continue
|
|
target = node.func.value
|
|
has_headers = any(kw.arg == "headers" for kw in node.keywords)
|
|
has_link = any(kw.arg == "link" for kw in node.keywords)
|
|
|
|
if (node.func.attr in HTTP_VERBS
|
|
and isinstance(target, ast.Name) and target.id == "client"):
|
|
# Surowe wywołanie httpx — omija szyfrowanie, więc dopuszczalne tylko
|
|
# dla ścieżek wyjętych spod niego (patrz link_crypto.PUBLIC_PATHS).
|
|
out.append((f"client.{node.func.attr}()", node.lineno, has_headers, True))
|
|
elif (node.func.attr in LINK_CALLS
|
|
and isinstance(target, ast.Name) and target.id == "link_crypto"):
|
|
out.append((f"link_crypto.{node.func.attr}()", node.lineno, has_headers, has_link))
|
|
return out
|
|
|
|
|
|
def test_client_module_exists():
|
|
assert CLIENT.is_file()
|
|
|
|
|
|
def test_every_outbound_call_sends_auth_header():
|
|
calls = _http_calls(CLIENT)
|
|
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
|
|
missing = [f"{CLIENT.name}:{line} {what}" for what, line, ok, _ in calls if not ok]
|
|
assert not missing, (
|
|
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
|
|
"ochronie): " + ", ".join(missing)
|
|
)
|
|
|
|
|
|
def test_every_outbound_call_passes_link_key():
|
|
"""Brak `link=` nie boli od razu — po prostu treść leci jawnym tekstem."""
|
|
calls = _http_calls(CLIENT)
|
|
missing = [f"{CLIENT.name}:{line} {what}" for what, line, _, ok in calls if not ok]
|
|
assert not missing, (
|
|
"Wywołania w dół bez klucza łącza — poszłyby NIEZASZYFROWANE: " + ", ".join(missing)
|
|
)
|
|
|
|
|
|
def test_raw_http_calls_only_on_paths_exempt_from_encryption():
|
|
"""Surowe `client.get/post` wolno wołać wyłącznie tam, gdzie szyfrowania nie ma
|
|
z założenia (`/health` dla sond k8s). Każde inne to obejście PRE-16."""
|
|
import re
|
|
|
|
from app import link_crypto
|
|
|
|
source = CLIENT.read_text(encoding="utf-8").splitlines()
|
|
offenders = []
|
|
for what, line, _, _ in _http_calls(CLIENT):
|
|
if not what.startswith("client."):
|
|
continue
|
|
url = re.search(r'f"\{self\.base_url\}([^"]*)"', source[line - 1])
|
|
if url is None or url.group(1) not in link_crypto.PUBLIC_PATHS:
|
|
offenders.append(f"{CLIENT.name}:{line} {what}")
|
|
assert not offenders, (
|
|
"Surowe wywołania HTTP poza ścieżkami wyjętymi spod szyfrowania: "
|
|
+ ", ".join(offenders)
|
|
)
|
|
|
|
|
|
def test_auth_headers_helper_is_lazy():
|
|
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
|
|
z pustym tokenem, gdyby zmienna pojawiła się później."""
|
|
import os
|
|
|
|
from app.clients.logic_client import _auth_headers
|
|
|
|
old = os.environ.get("INTERNAL_TOKEN")
|
|
try:
|
|
os.environ["INTERNAL_TOKEN"] = "abc"
|
|
assert _auth_headers() == {"X-Astrololo-Token": "abc"}
|
|
os.environ.pop("INTERNAL_TOKEN")
|
|
assert _auth_headers() == {} # ochrona wyłączona = brak nagłówka
|
|
finally:
|
|
if old is not None:
|
|
os.environ["INTERNAL_TOKEN"] = old
|
|
else:
|
|
os.environ.pop("INTERNAL_TOKEN", None)
|
|
|
|
|
|
# --------------------------------------------- wybor modelu musi dojsc do logiki
|
|
# Ta sama klasa bledu co przy tokenie: dokladajac nowa sciezke latwo zapomniec
|
|
# przekazac parametr, a objaw (cichy powrot do modelu domyslnego) jest niewidoczny.
|
|
|
|
def _calls_to(path_fragment: str) -> list[int]:
|
|
"""Linie wywolan logic.<metoda>(...) w handlerach prezentacji."""
|
|
main = CLIENT.parent.parent / "main.py"
|
|
tree = ast.parse(main.read_text(encoding="utf-8"))
|
|
out = []
|
|
for node in ast.walk(tree):
|
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == path_fragment
|
|
and isinstance(node.func.value, ast.Name) and node.func.value.id == "logic"):
|
|
out.append(node.lineno)
|
|
return out
|
|
|
|
|
|
def _has_kwarg(main_src: str, lineno: int, name: str) -> bool:
|
|
tree = ast.parse(main_src)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Call) and node.lineno == lineno:
|
|
return any(kw.arg == name for kw in node.keywords)
|
|
return False
|
|
|
|
|
|
def test_every_llm_call_passes_selected_model():
|
|
main = CLIENT.parent.parent / "main.py"
|
|
src = main.read_text(encoding="utf-8")
|
|
missing = []
|
|
for method in ("prompt", "horoscope"):
|
|
for line in _calls_to(method):
|
|
if not _has_kwarg(src, line, "model"):
|
|
missing.append(f"main.py:{line} logic.{method}()")
|
|
assert not missing, (
|
|
"Wywołania bez wybranego modelu — po cichu użyją domyślnego: " + ", ".join(missing)
|
|
)
|
|
|
|
|
|
def test_every_llm_call_passes_provider():
|
|
main = CLIENT.parent.parent / "main.py"
|
|
src = main.read_text(encoding="utf-8")
|
|
missing = []
|
|
for method in ("prompt", "horoscope"):
|
|
for line in _calls_to(method):
|
|
if not _has_kwarg(src, line, "provider"):
|
|
missing.append(f"main.py:{line} logic.{method}()")
|
|
assert not missing, "Wywołania bez dostawcy: " + ", ".join(missing)
|