f24616d342
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 34s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Ostatnia z osmiu wskazowek partnerow. Nowa usluga services/render sklada raport:
generuje plik posredni .tex i kompiluje go XeLaTeX-em do PDF.
OSOBNY komponent, nie czesc prezentacji — ta sama zasada co przy izolacji
swissepha (LOG-27). TeX Live wazy setki megabajtow; w obrazie produktu
spowalnialby kazdy build, a tak aktualizuje sie niezaleznie i jego awaria nie
kladzie aplikacji, tylko przycisk „Pobierz PDF".
SZYFROWANIE, o ktore prosiles: lacze prezentacja↔render idzie tak samo jak
pozostale, ale z WLASNYM, TRZECIM kluczem (LINK_KEY_PRESENTATION_RENDER). Osobny,
bo tym laczem plynie CALY raport — dane urodzeniowe i opisy z baz — wiec przejecie
go nie moze otwierac lacza do logiki ani do danych. Fail-closed: bez klucza pod
nie wstaje. Test kopii link_crypto obejmuje teraz cztery uslugi.
Uklad PDF wg prosby: imie i nazwisko -> wprowadzone dane -> RYSUNEK kosmogramu
-> interpretacja natalna -> predykcje okresowe. Test pilnuje kolejnosci.
Dwie rzeczy, ktore wyszly dopiero przy skladaniu tego kawalka:
1. KOSMOGRAM NIE NADAWAL SIE DO PDF. Uzywa zmiennych CSS (var(--line)) i klasy
.glyph, ktorej font podaje styles.css — a samodzielny konwerter SVG→PDF nie zna
naszego arkusza. Wyszlyby czarne kreski BEZ SYMBOLI. Stad wariant „print":
konkretne kolory na bialym tle i font glifow wpisany wprost w rysunek. Przy
okazji zalatwia to etap 4 planu kola (wariant do druku).
2. UCIECZKA ZNAKOW LATEXA byla zepsuta — i zlapal to moj wlasny test. Zamiana
„\” na \textbackslash{} szla w tej samej petli co nawiasy, wiec kolejne
podmiany ucieklyby nawiasy dopiero co wstawione: wychodzilo
\textbackslash\{\}. Poprawka: ukosnik chowany pod znacznik zastepczy i
rozwijany na koncu. To nie kosmetyka — niezauwazony „%” komentuje RESZTE LINII,
wiec zdanie od modelu urywaloby sie w polowie, a PDF powstawalby normalnie,
tylko krotszy. Test na wrogim tekscie sprawdza tez, ze \end{document} ani
\input{} nie wyrwa sie z dokumentu.
Usluga nie zapisuje nic poza katalogiem tymczasowym, ktory sprzata po sobie;
w manifescie readOnlyRootFilesystem + emptyDir na /tmp. /health raportuje
obecnosc xelatex i rsvg-convert, zeby zepsuty obraz bylo widac od razu.
Zweryfikowane na zywo (TestClient uslugi render): zadanie bez szyfrowania
z POPRAWNYM tokenem -> 400; obcy klucz -> 400 i tajny opis nie wraca; wlasciwy
klucz -> zadanie dochodzi do aplikacji, tresci baz NIE MA na kablu, odpowiedz
zaszyfrowana. Manifesty przechodza kubectl apply --dry-run=server na zywym
klastrze.
CZEGO NIE SPRAWDZILEM: samej kompilacji PDF. W tym srodowisku nie ma ani TeX
Live, ani dzialajacego runtime'u kontenerow (docker CLI jest, daemon nie) —
probowalem zbudowac obraz i sie nie dalo. Sprawdzone jest wszystko dookola:
generowanie .tex, ucieczka znakow, szyfrowanie, kontrakt API, samowystarczalnosc
SVG. Pierwsze uruchomienie na klastrze trzeba obejrzec — dlatego PRE-24 zostaje
jako „W trakcie", nie „Zrobione".
Instrukcja wdrozenia: docs/wdrozenie-render-pdf.md (klucz, obraz, merge,
weryfikacja, znane ograniczenia).
Testy: 15 nowych (render) + 14 (lacze i wariant druku w prezentacji).
Calosc: prezentacja 131, logika 265/1 skip, render 15.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
17 KiB
Python
402 lines
17 KiB
Python
"""Szyfrowanie łącza między warstwami (PRE-16).
|
|
|
|
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
|
|
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało" —
|
|
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
|
|
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
|
|
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
|
|
"""
|
|
import json
|
|
import pathlib
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from starlette.testclient import TestClient
|
|
|
|
from app import link_crypto
|
|
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
|
|
|
|
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
|
|
KEY_A = "11" * 32 # hex, 32 bajty
|
|
KEY_B = "22" * 32
|
|
|
|
|
|
class Payload(BaseModel):
|
|
question: str
|
|
|
|
|
|
def _app(link: Link | None) -> FastAPI:
|
|
app = FastAPI()
|
|
if link is not None:
|
|
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
@app.post("/search")
|
|
def search(payload: Payload):
|
|
return {"echo": payload.question, "interpretation": SECRET}
|
|
|
|
@app.get("/catalog")
|
|
def catalog():
|
|
return {"models": ["a", "b"]}
|
|
|
|
@app.post("/stream")
|
|
def stream():
|
|
def lines():
|
|
for i in range(4):
|
|
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
|
|
|
|
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def link():
|
|
return Link(link_crypto.parse_key(KEY_A))
|
|
|
|
|
|
# ------------------------------------------------------------------- klucze
|
|
|
|
def test_key_accepts_hex_and_base64():
|
|
import base64
|
|
|
|
raw = bytes(range(32))
|
|
assert link_crypto.parse_key(raw.hex()) == raw
|
|
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
|
|
|
|
|
|
def test_key_of_wrong_length_is_rejected_loudly():
|
|
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
|
|
with pytest.raises(LinkError, match="32"):
|
|
link_crypto.parse_key("aabb")
|
|
|
|
|
|
def test_key_that_is_neither_hex_nor_base64_is_rejected():
|
|
with pytest.raises(LinkError, match="hex"):
|
|
link_crypto.parse_key("!!! to nie jest klucz !!!")
|
|
|
|
|
|
def test_key_from_env_is_lazy(monkeypatch):
|
|
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
|
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
|
|
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
|
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
|
|
|
|
|
|
def test_directions_use_different_subkeys(link):
|
|
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
|
|
stronę nie osłabia drugiej."""
|
|
stamp = link_crypto.stamp_now()
|
|
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
|
|
with pytest.raises(LinkError):
|
|
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
|
|
|
|
|
|
# --------------------------------------------------------- podstawowy obieg
|
|
|
|
def test_round_trip_delivers_plaintext_to_the_app(link):
|
|
with TestClient(_app(link)) as client:
|
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
|
payload={"question": "Saturn"}, link=link)
|
|
assert got["echo"] == "Saturn"
|
|
assert got["interpretation"] == SECRET
|
|
|
|
|
|
def test_get_without_body_also_works(link):
|
|
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
|
|
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
|
|
with TestClient(_app(link)) as client:
|
|
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
|
|
assert got == {"models": ["a", "b"]}
|
|
|
|
|
|
def test_without_key_traffic_stays_plaintext(link):
|
|
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
|
|
with TestClient(_app(None)) as client:
|
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
|
payload={"question": "Saturn"}, link=None)
|
|
assert got["interpretation"] == SECRET
|
|
|
|
|
|
def test_health_stays_open_for_kubernetes_probes(link):
|
|
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
|
|
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
|
|
with TestClient(_app(link)) as client:
|
|
assert client.get("http://testserver/health").json() == {"status": "ok"}
|
|
|
|
|
|
# ------------------------------------------- czy na kablu naprawdę nic nie widać
|
|
|
|
def _raw_exchange(client, link, payload):
|
|
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
|
|
stamp = link_crypto.stamp_now()
|
|
body = link_crypto.frame_out(
|
|
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
|
|
response = client.request(
|
|
"POST", "http://testserver/search", content=body,
|
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
|
link_crypto.HEADER_TS: stamp,
|
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
|
return body, response
|
|
|
|
|
|
def test_request_bytes_do_not_contain_the_question(link):
|
|
with TestClient(_app(link)) as client:
|
|
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
|
|
assert b"Saturn" not in body
|
|
assert b"question" not in body
|
|
|
|
|
|
def test_response_bytes_do_not_contain_the_interpretation(link):
|
|
"""To jest właściwy powód istnienia całego modułu."""
|
|
with TestClient(_app(link)) as client:
|
|
_, response = _raw_exchange(client, link, {"question": "Saturn"})
|
|
assert response.status_code == 200
|
|
assert SECRET.encode() not in response.content
|
|
assert b"interpretation" not in response.content
|
|
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
|
|
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
|
|
|
|
|
|
# ------------------------------------------------------- drogi na skróty i ataki
|
|
|
|
def test_plaintext_request_is_refused_when_key_is_set(link):
|
|
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
|
|
powrotu do jawnego ruchu."""
|
|
with TestClient(_app(link)) as client:
|
|
response = client.post("http://testserver/search", json={"question": "Saturn"})
|
|
assert response.status_code == 400
|
|
assert SECRET.encode() not in response.content
|
|
|
|
|
|
def test_foreign_key_cannot_read_the_link(link):
|
|
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
|
|
intruder = Link(link_crypto.parse_key(KEY_B))
|
|
with TestClient(_app(link)) as client:
|
|
stamp = link_crypto.stamp_now()
|
|
body = link_crypto.frame_out(
|
|
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
|
|
response = client.request(
|
|
"POST", "http://testserver/search", content=body,
|
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
|
link_crypto.HEADER_TS: stamp,
|
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
|
assert response.status_code == 400
|
|
assert SECRET.encode() not in response.content
|
|
|
|
|
|
def test_single_flipped_bit_is_rejected(link):
|
|
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
|
|
with TestClient(_app(link)) as client:
|
|
stamp = link_crypto.stamp_now()
|
|
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
|
|
b'{"question":"x"}'))
|
|
sealed[-1] ^= 0x01
|
|
response = client.request(
|
|
"POST", "http://testserver/search",
|
|
content=link_crypto.frame_out(bytes(sealed)),
|
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
|
link_crypto.HEADER_TS: stamp,
|
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_frame_cannot_be_replayed_against_another_endpoint(link):
|
|
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
|
|
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
|
|
stamp = link_crypto.stamp_now()
|
|
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
|
|
with pytest.raises(LinkError):
|
|
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
|
|
|
|
|
|
def test_frames_cannot_be_reordered(link):
|
|
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
|
|
to błąd, a nie po cichu pomieszany horoskop."""
|
|
stamp = link_crypto.stamp_now()
|
|
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
|
|
with pytest.raises(LinkError):
|
|
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
|
|
|
|
|
|
def test_stale_frame_is_refused(link, monkeypatch):
|
|
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
|
|
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
|
|
with TestClient(_app(link)) as client:
|
|
body = link_crypto.frame_out(
|
|
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
|
|
response = client.request(
|
|
"POST", "http://testserver/search", content=body,
|
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
|
link_crypto.HEADER_TS: old,
|
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_truncated_stream_is_an_error_not_silent_loss(link):
|
|
stamp = link_crypto.stamp_now()
|
|
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
|
|
with pytest.raises(LinkError, match="urwana"):
|
|
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
|
|
|
|
|
|
def test_absurd_frame_length_does_not_allocate(link):
|
|
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
|
|
import struct
|
|
|
|
with pytest.raises(LinkError, match="rozmiar"):
|
|
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
|
|
|
|
|
|
# ---------------------------------------------------------- odpowiedź strumieniowa
|
|
|
|
def test_streaming_response_survives_encryption(link):
|
|
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
|
|
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
|
|
with TestClient(_app(link)) as client:
|
|
stamp = link_crypto.stamp_now()
|
|
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
|
|
with client.stream("POST", "http://testserver/stream", content=body,
|
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
|
link_crypto.HEADER_TS: stamp,
|
|
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
|
|
chunks = list(link_crypto.open_response_stream(response, link))
|
|
|
|
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
|
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
|
assert all(s["note"] == SECRET for s in steps)
|
|
|
|
|
|
def test_stream_lines_helper_round_trips_ndjson(link):
|
|
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
|
|
|
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
|
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
|
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
|
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
|
tajny opis nie przecieka po drodze."""
|
|
with TestClient(_app(link)) as client:
|
|
lines = list(link_crypto.stream_lines(
|
|
client, "http://testserver/stream", payload=None, link=link))
|
|
steps = [json.loads(line) for line in lines]
|
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
|
assert all(s["note"] == SECRET for s in steps)
|
|
|
|
|
|
def test_stream_lines_plaintext_path_still_works(link):
|
|
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
|
with TestClient(_app(None)) as client:
|
|
lines = list(link_crypto.stream_lines(
|
|
client, "http://testserver/stream", payload=None, link=None))
|
|
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_stream_request_without_key_is_refused_when_required(link):
|
|
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
|
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
|
with TestClient(_app(link)) as client:
|
|
response = client.post("http://testserver/stream", json={})
|
|
assert response.status_code == 400
|
|
assert SECRET.encode() not in response.content
|
|
|
|
|
|
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
|
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
|
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
|
sent = []
|
|
|
|
class Tripwire:
|
|
def stream(self, *args, **kwargs):
|
|
sent.append(args)
|
|
raise AssertionError("strumień NIE powinien opuścić procesu")
|
|
|
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
|
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
|
payload={"lat": 50.0}, link=None))
|
|
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
|
|
|
|
|
def test_incremental_unframing_handles_split_frames(link):
|
|
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
|
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
|
stamp = link_crypto.stamp_now()
|
|
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
|
|
[b"raz", b"dwa", b"trzy"]))
|
|
buffer = bytearray()
|
|
opened, seq = [], 0
|
|
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
|
|
buffer += stream[i:i + 5]
|
|
for frame in link_crypto.unframe_incremental(buffer):
|
|
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
|
|
seq += 1
|
|
assert opened == [b"raz", b"dwa", b"trzy"]
|
|
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
|
|
|
|
|
|
# ----------------------------------------------- trzy kopie muszą być identyczne
|
|
|
|
def test_all_services_share_the_same_module():
|
|
"""Moduł jest skopiowany do KAŻDEJ usługi, która szyfruje łącze (nie mają wspólnej
|
|
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
|
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
|
root = pathlib.Path(__file__).resolve().parents[3]
|
|
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
|
for svc in ("presentation", "logic", "data", "render")}
|
|
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
|
assert not missing, f"brak modułu w warstwach: {missing}"
|
|
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
|
assert len(set(contents.values())) == 1, (
|
|
"kopie link_crypto.py rozjechały się między warstwami: "
|
|
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
|
|
)
|
|
|
|
|
|
def test_env_names_are_two_distinct_keys():
|
|
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
|
|
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
|
|
|
|
|
|
# ------------------------------------------------- klient też musi być fail-closed
|
|
#
|
|
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
|
|
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
|
|
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
|
|
|
|
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
|
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
|
sent = []
|
|
|
|
class Tripwire:
|
|
def request(self, *args, **kwargs):
|
|
sent.append(args)
|
|
raise AssertionError("żądanie NIE powinno opuścić procesu")
|
|
|
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
|
link_crypto.call(Tripwire(), "POST", "http://logic/search",
|
|
payload={"value": "[Sat"}, link=None)
|
|
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
|
|
|
|
|
|
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
|
|
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
|
|
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
|
|
with TestClient(_app(None)) as client:
|
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
|
payload={"question": "Saturn"}, link=None)
|
|
assert got["interpretation"] == SECRET
|
|
|
|
|
|
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
|
|
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
|
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
|
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
|
|
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
|
|
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
|