diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 64479d7..caeb9aa 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,10 +9,12 @@ jobs: - uses: actions/checkout@v4 - name: Login run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin - - name: Build & push (data, logic, presentation) + # astroklient 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. + - name: Build & push (data, logic, presentation, astroklient) run: | TAG=${GITHUB_SHA::8} - for SVC in data logic presentation; do + for SVC in data logic presentation astroklient; do docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG done diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index e8bf142..ec025a4 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -91,6 +91,27 @@ jobs: PYTHONPATH: . run: pytest tests -q -rs + astroklient-tests: + name: Testy astroklienta (wersja demo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: services/astroklient/requirements-dev.txt + - name: Instalacja zależności + run: pip install -r services/astroklient/requirements-dev.txt + # 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 + # żadna trasa poza dwiema funkcjami. + - name: Testy (pytest) + working-directory: services/astroklient + env: + PYTHONPATH: . + run: pytest tests -q -rs + swisseph-image: name: Build obrazu silnika B (swisseph) runs-on: ubuntu-latest diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 656a3cf..63e679c 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/astroklient/Dockerfile b/services/astroklient/Dockerfile new file mode 100644 index 0000000..c596363 --- /dev/null +++ b/services/astroklient/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8005 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"] diff --git a/services/astroklient/README.md b/services/astroklient/README.md new file mode 100644 index 0000000..2d31742 --- /dev/null +++ b/services/astroklient/README.md @@ -0,0 +1,78 @@ +# astroklient — wersja demonstracyjna (PRE-28) + +Osobna warstwa prezentacji o **dwóch funkcjach**: + +1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością, +2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych. + +I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu, +eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**. + +## Dlaczego osobna usługa, a nie konto z ograniczeniami + +Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji. Ale **ukrycie +a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nie ma +nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy +okazji kodu reszty programu. + +## ⚠️ Demo pracuje na produkcyjnej warstwie danych + +Świadoma decyzja właściciela. Konsekwencje, żeby nie było niespodzianek: + +* **kto ma dostęp do demo, czyta oryginalne bazy interpretacyjne** — czyli rdzeń + produktu, którego pilnują LOG-32, DAN-25 i PRE-27, +* **pliki wgrane przez demo trafiają do produkcyjnego zbioru baz** i od razu biorą + udział w wyszukiwaniu, także w pełnej aplikacji. + +Dlatego konto astroklienta jest **osobne** (`DEMO_USER` / `DEMO_PASSWORD`): demo +odcina się jedną zmienną środowiskową, bez ruszania kont głównej aplikacji i bez +zmiany hasła komukolwiek. + +Jeśli demo ma trafić do kogoś spoza kręgu zaufania, właściwą odpowiedzią jest +osobna warstwa danych z pustym udziałem — nie jest to dziś zrobione. + +## Konfiguracja + +| zmienna | znaczenie | +|---|---| +| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) | +| `DEMO_USER` | login (domyślnie `demo`) | +| `DEMO_PASSWORD` | hasło — jawne albo hash `scrypt$sól$hash`. **Puste = 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 | +| `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 | +| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 | +| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra | + +Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji: + +```bash +cd services/presentation && python scripts/make_user.py demo +``` + +## Uruchomienie lokalne + +```bash +cd services/astroklient +pip install -r requirements-dev.txt +DEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \ + uvicorn app.main:app --port 8005 +``` + +Testy: + +```bash +cd services/astroklient && PYTHONPATH=. pytest tests -q +``` + +## Co pilnują testy + +Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia: + +* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje + wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa, + o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia + z produkcyjną warstwą danych. +* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu. + Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma + prawa ich zdradzić nawet mimochodem. diff --git a/services/astroklient/app/__init__.py b/services/astroklient/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/astroklient/app/clients/__init__.py b/services/astroklient/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/astroklient/app/clients/logic_client.py b/services/astroklient/app/clients/logic_client.py new file mode 100644 index 0000000..c09da2f --- /dev/null +++ b/services/astroklient/app/clients/logic_client.py @@ -0,0 +1,70 @@ +"""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 +być wyłącznie to, czego naprawdę używa. Metoda, której nie ma, nie zostanie +przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest coś więcej. + +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ę. +""" +from __future__ import annotations + +import os +from typing import Any + +import httpx + +from app import link_crypto +from app.config import settings + + +def _auth_headers() -> dict[str, str]: + token = os.getenv("INTERNAL_TOKEN", "") + return {"X-Astrololo-Token": token} if token else {} + + +def _link() -> link_crypto.Link | None: + """Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu + bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie + ZANIM cokolwiek wyjdzie jawnym tekstem.""" + key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC) + return link_crypto.Link(key) if key else None + + +class LogicClient: + def __init__(self, base_url: str | None = None) -> None: + self.base_url = (base_url or settings.logic_url).rstrip("/") + + def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]: + """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. + + Jedyne pytanie, jakie astroklient zadaje o treść — bez grupowania, + bez promptów, bez modelu językowego.""" + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, + "limit": settings.report_limit, "group": False} + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report", + payload=payload, headers=_auth_headers(), link=_link()) + + def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]: + with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/upload", + payload={"filename": filename, "content_b64": content_b64, "by": by}, + headers=_auth_headers(), link=_link()) + + def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]: + """Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA + czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział + w wyszukiwaniu — inaczej demo wyglądałoby na zepsute.""" + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/status", + payload={"path": path, "status": status, "by": by}, + headers=_auth_headers(), link=_link()) + + def health(self) -> dict[str, Any]: + with httpx.Client(timeout=settings.http_timeout) as client: + r = client.get(f"{self.base_url}/health", headers=_auth_headers()) + r.raise_for_status() + return r.json() diff --git a/services/astroklient/app/config.py b/services/astroklient/app/config.py new file mode 100644 index 0000000..0e3769b --- /dev/null +++ b/services/astroklient/app/config.py @@ -0,0 +1,23 @@ +"""Konfiguracja astroklienta — wersji demonstracyjnej (PRE-28). + +Zna WYŁĄCZNIE adres warstwy logicznej, tak samo jak pełna prezentacja. Nie wie +nic o bazach, Excelu ani SQL-u. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass +class Settings: + logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001")) + # Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit + # czasu jest wyższy niż domyślny dla zwykłego żądania. + http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30"))) + # Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko + # ograniczenie, ile treści baz może wyjść jednym zapytaniem. + report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000"))) + + +settings = Settings() diff --git a/services/astroklient/app/link_crypto.py b/services/astroklient/app/link_crypto.py new file mode 100644 index 0000000..89ed3d8 --- /dev/null +++ b/services/astroklient/app/link_crypto.py @@ -0,0 +1,525 @@ +"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33). + +Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token +międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź +— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń +produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu, +zrzut z węzła), miał je w całości. + +Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi. +GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt +nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale +podatne na modyfikację". + +**Dwa niezależne klucze**, po jednym na parę rozmówców: + * ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika, + * ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane. +Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych, +gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony. + +Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF). +Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie +losowej jednorazówki w jedną stronę nie osłabia drugiej. + +Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu): + + [4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik] + +Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu +i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć +po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu. + +Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność +wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa +działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa +regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu. +""" +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import struct +import time +from typing import Iterable, Iterator + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +log = logging.getLogger("astrololo.link") + +MAGIC = b"AL1" +VERSION = "v1" +NONCE_BYTES = 12 +KEY_BYTES = 32 # AES-256 +LENGTH_PREFIX = 4 +MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości +MAX_SKEW_SECONDS = 300.0 + +HEADER_ENC = "X-Astrololo-Enc" +HEADER_TS = "X-Astrololo-Enc-Ts" +CONTENT_TYPE = "application/vnd.astrololo.enc" + +ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" +ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" +ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" + +REQUEST, RESPONSE = b"req", b"res" + +# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka +# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji. +PUBLIC_PATHS = frozenset({"/health"}) + + +class LinkError(Exception): + """Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz.""" + + +# --------------------------------------------------------------------- klucze + +def parse_key(raw: str) -> bytes: + """Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty.""" + text = raw.strip() + if not text: + raise LinkError("pusty klucz łącza") + try: + key = bytes.fromhex(text) + except ValueError: + try: + key = base64.b64decode(text, validate=True) + except (binascii.Error, ValueError) as exc: + raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc + if len(key) != KEY_BYTES: + raise LinkError( + f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez " + f"`openssl rand -hex 32`" + ) + return key + + +def key_from_env(env_name: str) -> bytes | None: + """Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu.""" + raw = os.getenv(env_name, "") + return parse_key(raw) if raw.strip() else None + + +def encryption_required() -> bool: + """Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu. + + Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza + wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść + zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim + cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu. + """ + return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _subkey(link_key: bytes, direction: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, + info=b"astrololo/link/" + direction, + ).derive(link_key) + + +class Link: + """Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze.""" + + def __init__(self, link_key: bytes) -> None: + self._by_direction = { + REQUEST: AESGCM(_subkey(link_key, REQUEST)), + RESPONSE: AESGCM(_subkey(link_key, RESPONSE)), + } + + # ---------------------------------------------------------- pojedyncza ramka + + def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes: + return b"|".join([MAGIC, direction, path.encode("utf-8"), + stamp.encode("ascii"), str(seq).encode("ascii")]) + + def seal(self, direction: bytes, path: str, stamp: str, seq: int, + plaintext: bytes) -> bytes: + nonce = os.urandom(NONCE_BYTES) + sealed = self._by_direction[direction].encrypt( + nonce, plaintext, self._aad(direction, path, stamp, seq)) + return MAGIC + nonce + sealed + + def open(self, direction: bytes, path: str, stamp: str, seq: int, + frame: bytes) -> bytes: + if not frame.startswith(MAGIC): + raise LinkError("ramka bez znacznika astrololo") + body = frame[len(MAGIC):] + if len(body) <= NONCE_BYTES: + raise LinkError("ramka za krótka") + nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:] + try: + return self._by_direction[direction].decrypt( + nonce, sealed, self._aad(direction, path, stamp, seq)) + except InvalidTag as exc: + # Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt, + # przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich + # na zewnątrz podpowiadałoby atakującemu, w co trafił. + raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc + + # ------------------------------------------------------------ strumień ramek + + def seal_stream(self, direction: bytes, path: str, stamp: str, + chunks: Iterable[bytes]) -> Iterator[bytes]: + for seq, chunk in enumerate(chunks): + yield frame_out(self.seal(direction, path, stamp, seq, chunk)) + + def open_stream(self, direction: bytes, path: str, stamp: str, + raw: bytes) -> Iterator[bytes]: + for seq, frame in enumerate(frames_in(raw)): + yield self.open(direction, path, stamp, seq, frame) + + def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes: + return b"".join(self.open_stream(direction, path, stamp, raw)) + + +# ---------------------------------------------------------------- ramkowanie + +def frame_out(payload: bytes) -> bytes: + return struct.pack(">I", len(payload)) + payload + + +def frames_in(raw: bytes) -> Iterator[bytes]: + """Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych.""" + offset = 0 + while offset < len(raw): + if offset + LENGTH_PREFIX > len(raw): + raise LinkError("urwana ramka (brak nagłówka długości)") + (size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + offset += LENGTH_PREFIX + if offset + size > len(raw): + raise LinkError("urwana ramka (za mało danych)") + yield raw[offset:offset + size] + offset += size + + +def unframe_incremental(buffer: bytearray) -> Iterator[bytes]: + """Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później. + + Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi + rozjechać się między dwa odczyty. + """ + while True: + if len(buffer) < LENGTH_PREFIX: + return + (size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + if len(buffer) < LENGTH_PREFIX + size: + return + frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size]) + del buffer[:LENGTH_PREFIX + size] + yield frame + + +# ------------------------------------------------------------- świeżość ruchu + +def stamp_now() -> str: + return f"{time.time():.3f}" + + +def check_stamp(stamp: str) -> None: + """Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się + odtworzyć w dowolnym momencie w przyszłości.""" + try: + sent = float(stamp) + except (TypeError, ValueError) as exc: + raise LinkError("brak albo błędny znacznik czasu") from exc + if abs(time.time() - sent) > MAX_SKEW_SECONDS: + raise LinkError("znacznik czasu poza dopuszczalnym oknem") + + +# =========================================================== strona serwerowa + +class LinkCryptoMiddleware: + """Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi. + + Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba + podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić + odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci. + """ + + def __init__(self, app, link: Link | None, layer: str) -> None: + self.app = app + self.link = link + self.layer = layer + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS: + return await self.app(scope, receive, send) + + path = scope["path"] + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + + if headers.get(HEADER_ENC.lower()) != VERSION: + # Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo + # pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie + # chcemy po cichu wrócić do jawnego ruchu. + log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)", + self.layer, path) + return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.") + + stamp = headers.get(HEADER_TS.lower(), "") + try: + check_stamp(stamp) + plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive)) + except LinkError as exc: + log.warning("warstwa %s: %s (%s)", self.layer, exc, path) + return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.") + + scope = dict(scope) + scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext)) + await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path)) + + def _sealing_send(self, send, path: str): + state: dict = {"stamp": "", "seq": 0} + + async def sealing(message): + if message["type"] == "http.response.start": + state["stamp"] = stamp_now() + keep = [(k, v) for k, v in message.get("headers", []) + if k.lower() not in (b"content-length", b"content-type")] + message = dict(message) + message["headers"] = keep + [ + (b"content-type", CONTENT_TYPE.encode()), + (HEADER_ENC.lower().encode(), VERSION.encode()), + (HEADER_TS.lower().encode(), state["stamp"].encode()), + ] + return await send(message) + + if message["type"] == "http.response.body": + chunk = message.get("body", b"") + sealed = b"" + if chunk: + sealed = frame_out(self.link.seal( + RESPONSE, path, state["stamp"], state["seq"], chunk)) + state["seq"] += 1 + return await send({"type": "http.response.body", "body": sealed, + "more_body": message.get("more_body", False)}) + + return await send(message) + + return sealing + + +def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int): + """Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI + próbowałby sparsować JSON o cudzej deklarowanej wielkości.""" + kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")] + kept.append((b"content-length", str(length).encode())) + if length: + kept.append((b"content-type", b"application/json")) + return kept + + +async def _read_body(receive) -> bytes: + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + raise LinkError("rozłączenie w trakcie odbioru żądania") + body += message.get("body", b"") + if not message.get("more_body", False): + return bytes(body) + + +def _replay(body: bytes, original): + """Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji. + + Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu + zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia + równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji + okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały. + """ + delivered = False + + async def receive(): + nonlocal delivered + if delivered: + return await original() + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +async def _refuse(send, detail: str) -> None: + """Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować, + więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny.""" + payload = f'{{"detail":"{detail}"}}'.encode("utf-8") + await send({"type": "http.response.start", "status": 400, "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode()), + ]}) + await send({"type": "http.response.body", "body": payload}) + + +def install(app, env_name: str, layer: str): + """Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa + tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał.""" + link_key = key_from_env(env_name) + if link_key is None and encryption_required(): + # Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania: + # wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni + # niczego. Pod w CrashLoop widać od razu, jawny ruch — nie. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — " + f"warstwa {layer} nie wystartuje bez klucza łącza" + ) + if link_key is None: + log.warning( + "UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, " + "więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha " + "ruch wewnątrz sieci.", env_name, layer, + ) + return None + link = Link(link_key) + app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer) + log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name) + return link + + +# ============================================================ strona kliencka + +def call(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None) -> bytes: + """Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi. + + Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego + argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby + się niejasnym błędem odszyfrowania. + """ + import json as _json + + import httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero + # zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie + # sygnifikatory, o które pytamy bazę. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie " + f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem" + ) + response = client.request(method, url, json=payload, headers=request_headers) + response.raise_for_status() + return response.content + + path = httpx.URL(url).path + stamp = stamp_now() + plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8") + body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext)) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, + "Content-Type": CONTENT_TYPE}) + + response = client.request(method, url, content=body, headers=request_headers) + if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION: + log.error("łącze %s odmówiło: %s", path, response.text[:200]) + response.raise_for_status() + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony") + reply_stamp = response.headers.get(HEADER_TS, "") + check_stamp(reply_stamp) + return link.open_all(RESPONSE, path, reply_stamp, response.content) + + +def call_json(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None): + import json as _json + + return _json.loads(call(client, method, url, payload=payload, + headers=headers, link=link)) + + +def open_response_stream(response, link: Link | None) -> Iterator[bytes]: + """Odbiór odpowiedzi płynącej kawałkami (okno postępu). + + Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją + w buforze zamiast zakładać, że każdy kawałek to komplet. + """ + if link is None: + yield from response.iter_bytes() + return + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony") + stamp = response.headers.get(HEADER_TS, "") + check_stamp(stamp) + path = response.request.url.path + buffer = bytearray() + seq = 0 + for chunk in response.iter_bytes(): + buffer += chunk + for frame in unframe_incremental(buffer): + yield link.open(RESPONSE, path, stamp, seq, frame) + seq += 1 + if buffer: + raise LinkError("strumień urwał się w połowie ramki") + + +def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None, + link: Link | None) -> Iterator[str]: + """Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo. + + Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc + czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest + pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE + pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je + dopiero na znakach nowej linii. + + Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów + działał bez zmian. + """ + import json as _json + + import httpx as _httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy + # szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale + # ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień " + f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem" + ) + with client.stream("POST", url, json=payload, headers=request_headers) as response: + response.raise_for_status() + for text_line in response.iter_lines(): + if text_line: + yield text_line + return + + path = _httpx.URL(url).path + stamp = stamp_now() + body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8"))) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE}) + with client.stream("POST", url, content=body, headers=request_headers) as response: + response.raise_for_status() + buffer = bytearray() + for plain in open_response_stream(response, link): + buffer += plain + while True: + nl = buffer.find(b"\n") + if nl < 0: + break + text_line = bytes(buffer[:nl]) + del buffer[:nl + 1] + if text_line: + yield text_line.decode("utf-8") + if buffer: + yield bytes(buffer).decode("utf-8") diff --git a/services/astroklient/app/main.py b/services/astroklient/app/main.py new file mode 100644 index 0000000..fb15aad --- /dev/null +++ b/services/astroklient/app/main.py @@ -0,0 +1,148 @@ +"""astroklient — wersja DEMONSTRACYJNA (PRE-28). + +DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ: + 1. dodanie pliku bazy (wgranie + włączenie do użytku), + 2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych. + +DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27 +umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy. +Tutaj pozostałych funkcji NIE MA W OBRAZIE: nie ma tras, nie ma szablonów, nie ma +nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając +przy okazji kodu reszty programu. + +UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient pracuje na TEJ SAMEJ warstwie +danych co produkcja. Kto ma do niego dostęp, czyta oryginalne bazy interpretacyjne, +a pliki przez niego wgrane trafiają do produkcyjnego zbioru. Dlatego konto jest +osobne (DEMO_USER/DEMO_PASSWORD) — żeby dało się odciąć demo jedną zmienną, bez +ruszania kont głównej aplikacji. +""" +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import FastAPI, File, Form, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app import link_crypto, security +from app.clients.logic_client import LogicClient + +# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs +# wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. +app = FastAPI(title="astroklient · demo", docs_url=None, redoc_url=None, openapi_url=None) +security.install(app) + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + +logic = LogicClient() + +# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana +# tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby +# się dopiero na żywo, więc pilnuje jej test. +ACTIVE = "active" + + +def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: + """Data + godzina lokalna + offset → chwila w UTC. + + Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma + dwie funkcje, a strefy to trzecia.""" + try: + local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M") + except ValueError as e: + raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e + moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset))) + return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M") + + +def _blad(e: Exception) -> str: + if isinstance(e, httpx.HTTPStatusError): + try: + return str(e.response.json().get("detail") or e) + 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: + return sum(len(s.get("samples") or []) for p in (report.get("positions") or []) + for s in (p.get("significators") or [])) + + +@app.get("/", response_class=HTMLResponse) +def formularz(request: Request, error: str = "", done: str = ""): + return templates.TemplateResponse(request, "index.html", + {"result": None, "form": {}, "error": error, "done": done}) + + +@app.post("/", response_class=HTMLResponse) +def interpretacja(request: Request, date: str = Form(...), time: str = Form(...), + tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)): + """Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść.""" + form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon} + ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None} + try: + iso_utc, label = _build_utc(date, time, tz_offset) + ctx["moment"] = label + ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon) + # Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17). + security.audit_records(request, _ile_rekordow(ctx["result"])) + except ValueError as e: + ctx["error"] = str(e) + except (httpx.HTTPError, link_crypto.LinkError) as e: + ctx["error"] = _blad(e) + return templates.TemplateResponse(request, "index.html", ctx) + + +@app.post("/plik") +async def dodaj_plik(request: Request, upload: UploadFile = File(...)): + """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. + Tutaj „dodać do bazy" ma znaczyć, że plik od razu bierze udział w wyszukiwaniu — + inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute. + + Walidacja zostaje: plik o złym układzie NIE wchodzi do użytku, ale też NIE JEST + tracony — leży dalej na udziale i decyzję o nim podejmuje administrator + w pełnej aplikacji.""" + raw = await upload.read() + if not raw: + return RedirectResponse("/?error=Pusty+plik.", status_code=303) + + who = getattr(request.state, "user", "") or "demo" + try: + out = logic.files_upload(upload.filename or "baza.xlsx", + base64.b64encode(raw).decode("ascii"), by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=_blad(e)) + + nazwa = out.get("name") or "plik" + if not out.get("accepted"): + # Bez słowa o regułach — te zna wyłącznie administrator (DAN-27). + return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się " + f"do użycia w tej postaci.") + try: + logic.files_status(out["path"], ACTIVE, by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}") + return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.") + + +def _wroc(error: str = "", done: str = "") -> RedirectResponse: + """Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może + wgrać pliku drugi raz.""" + from urllib.parse import urlencode + + q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v}) + return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "layer": "astroklient"} diff --git a/services/astroklient/app/security.py b/services/astroklient/app/security.py new file mode 100644 index 0000000..6058179 --- /dev/null +++ b/services/astroklient/app/security.py @@ -0,0 +1,176 @@ +"""Logowanie do astroklienta (PRE-28). + +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 +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. + +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 — +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 +co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import logging +import os +import time +from collections import deque + +from fastapi import Request +from fastapi.responses import HTMLResponse, JSONResponse + +log = logging.getLogger("astroklient.security") +audit_log = logging.getLogger("astroklient.audit") + +_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 +_HASH_PREFIX = "scrypt$" + +PUBLIC_PATHS = frozenset({"/health"}) +PUBLIC_PREFIXES = ("/static/",) + +_hits: dict[str, deque[float]] = {} +MAX_TRACKED_CLIENTS = 4096 + + +def demo_user() -> str: + return os.getenv("DEMO_USER", "demo") + + +def demo_password() -> str: + return os.getenv("DEMO_PASSWORD", "") + + +def rate_limit_per_min() -> int: + return int(os.getenv("RATE_LIMIT_PER_MIN", "60")) + + +def trust_proxy() -> bool: + return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} + + +def auth_enabled() -> bool: + return bool(demo_password()) + + +def _secret_matches(spec: str, password: str) -> bool: + if spec.startswith(_HASH_PREFIX): + try: + _, salt_hex, hash_hex = spec.split("$", 2) + salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex) + except ValueError: + return False + actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, + n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN) + return hmac.compare_digest(actual, expected) + # BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach + # spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy. + return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) + + +def authenticate(header: str | None) -> str | None: + if not header or not header.lower().startswith("basic "): + return None + try: + raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") + user, _, password = raw.partition(":") + except (binascii.Error, UnicodeDecodeError, IndexError): + return None + if user != demo_user(): + return None + return user if _secret_matches(demo_password(), password) else None + + +def client_ip(request: Request) -> str: + """Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy — + inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit.""" + peer = request.client.host if request.client else "?" + if not trust_proxy(): + return peer + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + last = forwarded.rsplit(",", 1)[-1].strip() + if last: + return last + return request.headers.get("x-real-ip", "").strip() or peer + + +def _rate_limited(client: str) -> bool: + cap = rate_limit_per_min() + if cap <= 0: + return False + now = time.monotonic() + window = _hits.get(client) + if window is None: + if len(_hits) >= MAX_TRACKED_CLIENTS: + _hits.clear() + window = _hits[client] = deque() + while window and now - window[0] > 60.0: + window.popleft() + if len(window) >= cap: + return True + window.append(now) + return False + + +def _setup_audit_logging() -> None: + """Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje + naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był + pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17).""" + audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper()) + if not audit_log.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + audit_log.addHandler(handler) + audit_log.propagate = False + + +def audit_records(request: Request, count: int) -> None: + try: + request.state.audit_records = int(count) + except (TypeError, ValueError): + pass + + +def install(app) -> None: + _setup_audit_logging() + if not auth_enabled(): + log.warning( + "UWAGA: DEMO_PASSWORD nie ustawione — astroklient stoi OTWARTY, a pracuje " + "na tej samej warstwie danych co produkcja, więc wystawia treść " + "oryginalnych baz interpretacyjnych każdemu, kto zna adres." + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES): + return await call_next(request) + + started = time.monotonic() + if _rate_limited(client_ip(request)): + return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, + status_code=429, headers={"Retry-After": "60"}) + + user = "" + if auth_enabled(): + user = authenticate(request.headers.get("authorization")) or "" + if not user: + return HTMLResponse( + "
{{ error }}
{% endif %} +{% if done %}{{ done }}
{% endif %} + ++ Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji. +
+ +| {{ s.expanded }} | +{{ s.effect }} | +
+ Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę + zawierającą pasujące opisy. +
+ {% endif %} +{% endif %} +{% endblock %} diff --git a/services/astroklient/requirements-dev.txt b/services/astroklient/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/services/astroklient/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/services/astroklient/requirements.txt b/services/astroklient/requirements.txt new file mode 100644 index 0000000..1297467 --- /dev/null +++ b/services/astroklient/requirements.txt @@ -0,0 +1,11 @@ +# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie +# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset +# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie +# demo to kolejna rzecz do pilnowania i kolejna droga do środka. +fastapi>=0.115 +uvicorn[standard]>=0.34 +httpx>=0.28 +jinja2>=3.1 +python-multipart>=0.0.20 +# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF +cryptography>=44.0 diff --git a/services/astroklient/tests/test_astroklient.py b/services/astroklient/tests/test_astroklient.py new file mode 100644 index 0000000..d4d41c0 --- /dev/null +++ b/services/astroklient/tests/test_astroklient.py @@ -0,0 +1,203 @@ +"""astroklient — wersja demonstracyjna (PRE-28). + +DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK + +1. Demo ma DWIE funkcje i ani jednej więcej. Sprawdzamy to na trasach aplikacji, + nie w szablonie: trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem, + bo ta usługa rozmawia z warstwą danych produkcji. + +2. „Dodać plik do bazy" znaczy, że plik ZACZYNA BYĆ UŻYWANY. W pełnej aplikacji + wgranie i włączenie to dwie decyzje (DAN-27); tutaj muszą być jedną, inaczej + po wgraniu nic się nie zmienia i demo wygląda na zepsute. +""" +import base64 + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + monkeypatch.setenv("DEMO_USER", "demo") + monkeypatch.setenv("DEMO_PASSWORD", "tajne-demo") + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + + +def _auth(user="demo", password="tajne-demo"): + return {"Authorization": "Basic " + base64.b64encode( + f"{user}:{password}".encode()).decode()} + + +REPORT = { + "engine": "test", + "objects": [{ + "object": "Sun", "sign": "Taurus", "house": 11, "direction": "D", + "planet_token": "Su", "planet_total": 3, + "facets": [{"label": "w znaku Taurus", "token": "Su Tau", "count": 1, "score": 5.0, + "samples": [{"significator": "Su Tau", "expanded": "Sun in Taurus", + "effect": "wytrwały, praktyczny"}]}], + }], +} + + +def _client(monkeypatch, calls=None): + from starlette.testclient import TestClient + + from app.main import app, logic + + calls = calls if calls is not None else [] + monkeypatch.setattr(logic, "report", + lambda **kw: (calls.append(("report", kw)), REPORT)[1]) + monkeypatch.setattr(logic, "files_upload", + lambda *a, **kw: (calls.append(("upload", a, kw)), + {"path": "nowa.xlsx", "name": "nowa.xlsx", + "accepted": True})[1]) + monkeypatch.setattr(logic, "files_status", + lambda *a, **kw: (calls.append(("status", a, kw)), {})[1]) + return TestClient(app), calls + + +FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2", + "lat": "50.06", "lon": "19.94"} + + +# ── powierzchnia: dokładnie dwie funkcje ──────────────────────────────── + +def test_the_service_exposes_nothing_beyond_its_two_functions(): + """Trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem — ta usługa + rozmawia z warstwą danych PRODUKCJI.""" + from app.main import app + + trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set()) + if not r.path.startswith("/static") and m in {"GET", "POST"}} + assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health")} + + +def test_the_client_can_only_ask_for_what_the_demo_needs(): + """Klient warstwy logicznej jest okrojony CELOWO: metody, której nie ma, nikt + przypadkiem nie zawoła i nie podpowie ona, że po drugiej stronie jest więcej.""" + from app.clients.logic_client import LogicClient + + publiczne = {n for n in dir(LogicClient) if not n.startswith("_")} + assert publiczne == {"report", "files_upload", "files_status", "health"} + + +def test_api_docs_are_off(): + """/docs wypisałoby komplet tras — demo ma nie zdradzać własnej powierzchni.""" + from app.main import app + + assert app.docs_url is None and app.openapi_url is None and app.redoc_url is None + + +# ── logowanie ──────────────────────────────────────────────────────────── + +def test_without_credentials_nothing_is_served(env, monkeypatch): + c, _ = _client(monkeypatch) + for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")): + assert getattr(c, method)(path).status_code == 401, path + assert c.get("/health").status_code == 200, "sonda musi działać bez hasła" + + +def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): + """Konto astroklienta MUSI być własne: demo pracuje na tej samej warstwie + danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł + w głównej aplikacji.""" + monkeypatch.setenv("APP_USER", "astrololo") + monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji") + c, _ = _client(monkeypatch) + assert c.get("/", headers=_auth("astrololo", "haslo-glownej-aplikacji")).status_code == 401 + assert c.get("/", headers=_auth()).status_code == 200 + + +def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch): + monkeypatch.setenv("DEMO_PASSWORD", "zażółć-gęślą") + c, _ = _client(monkeypatch) + assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401 + assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200 + + +# ── funkcja 1: dodanie pliku ───────────────────────────────────────────── + +def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch): + """Sedno wymagania: jedna czynność, nie dwie.""" + c, calls = _client(monkeypatch) + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("baza.xlsx", b"zawartosc", "application/vnd.ms-excel")}) + assert r.status_code == 303 + rodzaje = [k[0] for k in calls] + assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}" + assert calls[1][1][1] == "active", "plik nie został włączony do użytku" + + +def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch): + """Walidacja zostaje, ale jej REGUŁY zna wyłącznie administrator (DAN-27) — + demo nie ma prawa ich zdradzić, nawet mimochodem.""" + from urllib.parse import unquote_plus + + from app.main import logic + + c, calls = _client(monkeypatch) + monkeypatch.setattr(logic, "files_upload", lambda *a, **kw: ( + calls.append(("upload", a, kw)), + {"path": "zla.xlsx", "name": "zla.xlsx", "accepted": False})[1]) + + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("zla.xlsx", b"x", "text/plain")}) + assert r.status_code == 303 + assert [k[0] for k in calls] == ["upload"], "odrzucony plik został włączony do użytku" + komunikat = unquote_plus(r.headers["location"]) + for slowo in ("walidacj", "reguł", "kolumn", "rozszerzeni", "rozmiar"): + assert slowo not in komunikat, f"komunikat zdradza mechanizm: „{slowo}”" + + +def test_an_empty_file_is_refused_before_anything_is_sent(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("pusty.xlsx", b"", "application/vnd.ms-excel")}) + assert r.status_code == 303 and calls == [] + + +# ── funkcja 2: interpretacja urodzeniowa ──────────────────────────────── + +def test_asking_for_an_interpretation_returns_the_matches(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/", headers=_auth(), data=FORM) + assert r.status_code == 200 + assert "Sun in Taurus" in r.text and "wytrwały, praktyczny" in r.text + assert [k[0] for k in calls] == ["report"] + + +def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch): + """11:20 przy offsecie +2 to 09:20 UTC. Pomyłka tutaj przesuwa CAŁY horoskop, + a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost.""" + c, calls = _client(monkeypatch) + c.post("/", headers=_auth(), data=FORM) + assert calls[0][1]["when_utc_iso"].startswith("1984-04-30T09:20") + + +def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/", headers=_auth(), data={**FORM, "date": "30 kwietnia"}) + assert r.status_code == 200 and "RRRR-MM-DD" in r.text + assert calls == [], "poszło zapytanie mimo błędnych danych" + + +def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch): + import httpx + + from app.main import logic + + c, _ = _client(monkeypatch) + monkeypatch.setattr(logic, "report", lambda **kw: (_ for _ in ()).throw( + httpx.ConnectError("brak połączenia"))) + r = c.post("/", headers=_auth(), data=FORM) + assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text + + +def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch): + """Demo ma wyglądać na KOMPLETNE w swojej postaci — żadnych śladów funkcji, + których nie ma (ta sama zasada, co przy uprawnieniach w PRE-27).""" + c, _ = _client(monkeypatch) + html = c.get("/", headers=_auth()).text + for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia", + "Konta", "PDF", "Excel", "prompt", "model"): + assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"