From 73af7205d14b3988b418b72011ec35bf65d76d3b Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 6 Aug 2026 21:45:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(bezpiecze=C5=84stwo):=20konta=20z=20uprawn?= =?UTF-8?q?ieniami=20do=20zak=C5=82adek=20i=20funkcji=20(PRE-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ekran „Konta" dla administratora: zakładanie, kasowanie i nadawanie uprawnień. Zestaw funkcji zależy od konta, a konto ograniczone widzi program KOMPLETNY — tylko mniejszy. PODZIAŁ NA GRUPY. Ekrany to zakładki (7), bo zakładka jest naturalną jednostką — to ją widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI wewnątrz ekranów: porównanie systemów domów, wykresy dodatkowe, obliczenia zaawansowane, generowanie tekstu przez model (kosztuje pieniądze) i eksport plików. Konto bez porównania domów dostaje horoskop w Whole Sign i nie wie, że systemów jest trzynaście. NIC NIE ZDRADZA, ŻE JEST WIĘCEJ: - brak pozycji w menu zamiast pozycji wyszarzonej, - 404 zamiast 403 — odmowa z powodem sama mówi, że coś tam jest, - rysunki bez uprawnienia w OGÓLE NIE POWSTAJĄ, więc nie ma ich nawet w źródle, - automatyczna dokumentacja API wyłączona. /docs, /redoc i /openapi.json wypisują komplet tras, czyli spis wszystkich funkcji programu — ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog. Znalezione TESTEM przechodzącym po trasach aplikacji, nie przeglądem kodu. KONTO ADMINISTRACYJNE zostaje w APP_USER/APP_PASSWORD, jak było. Nie leży w pliku kont, więc nie da się go skasować ani ograniczyć z ekranu. Konto założone w pliku o tym samym loginie NIE przesłoni administracyjnego — kolejność sprawdzania jest odwrotna, inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać. Uprawnienia administracyjnego nie da się też nadać z formularza: odsiewamy je w normalise(), a nie w handlerze, więc żadne spreparowane żądanie tam nie sięgnie. GRANICA JEST W HANDLERZE, NIE W SZABLONIE. Ukrycie pola chroni przed przypadkiem, nie przed kimś, kto zna nazwy pól — _limit_options() ścina opcje po stronie serwera i test wysyła spreparowane żądanie, żeby to potwierdzić. MAPA TRASA→UPRAWNIENIE JEST JEDNA (features.ROUTES). Rozproszenie jej po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Trasa bez wpisu wymaga administratora: przeoczenie ma ZAMYKAĆ, nie otwierać. Test idzie po trasach APLIKACJI, nie po wpisach mapy — inaczej potwierdzałby tylko sam siebie. Konta w pliku JSON na własnym podkatalogu NFS (nie tam, gdzie bazy — zamontowanie całego udziału obeszłoby bokiem DAN-25). Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co APP_USERS. Zapis atomowy, bo przerwanie zapisu na NFS obcięłoby plik, czyli skasowało wszystkie konta naraz. Przy okazji przepisane trzy testy, które greppowały nawigację i main.py: menu powstaje teraz z katalogu funkcji, więc szukanie sztywnych linków w base.html niczego już nie sprawdzało. Wymaga wolumenu na konta — osobny PR w repo deploy. Co-Authored-By: Claude Opus 5 --- docs/astrololo_wymagania.xlsx | Bin 39046 -> 39659 bytes docs/konta-i-uprawnienia.md | 82 +++++ services/presentation/app/accounts.py | 142 ++++++++ services/presentation/app/features.py | 123 +++++++ services/presentation/app/main.py | 159 ++++++++- services/presentation/app/security.py | 77 ++++- services/presentation/app/static/styles.css | 19 ++ .../app/templates/_form_options.html | 8 + .../presentation/app/templates/accounts.html | 90 +++++ services/presentation/app/templates/base.html | 16 +- .../presentation/app/templates/chart.html | 6 +- .../presentation/app/templates/compile.html | 10 +- .../presentation/app/templates/interpret.html | 4 +- .../presentation/app/templates/settings.html | 2 +- .../app/templates/significators.html | 2 +- .../presentation/app/templates/synastry.html | 2 +- .../presentation/app/templates/timeline.html | 2 +- services/presentation/tests/test_compile.py | 35 +- .../presentation/tests/test_house_compare.py | 30 +- .../tests/test_kontrola_dostepu.py | 311 ++++++++++++++++++ .../presentation/tests/test_settings_bases.py | 11 +- services/presentation/tests/test_synastry.py | 11 +- 22 files changed, 1086 insertions(+), 56 deletions(-) create mode 100644 docs/konta-i-uprawnienia.md create mode 100644 services/presentation/app/accounts.py create mode 100644 services/presentation/app/features.py create mode 100644 services/presentation/app/templates/accounts.html create mode 100644 services/presentation/tests/test_kontrola_dostepu.py diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 17f2259feef943e14e4bcc93b6fc1c44d1a66324..2f226b1c59eb64537bd4371ca2e7339987451e8d 100644 GIT binary patch delta 10892 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 delta 10267 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 diff --git a/docs/konta-i-uprawnienia.md b/docs/konta-i-uprawnienia.md new file mode 100644 index 0000000..d693e6a --- /dev/null +++ b/docs/konta-i-uprawnienia.md @@ -0,0 +1,82 @@ +# Konta i uprawnienia (PRE-27) + +Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się +**z aplikacji**, a każde dostaje własny zestaw funkcji. + +## Dwie zasady, z których wynika reszta + +**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.** +Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek, +żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**, +że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo +jak adres, którego nie ma. + +Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`. +Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich +funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog. +(Znalezione testem, nie przeglądem kodu.) + +**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.** +`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie +uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc +nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet +spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie +przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna). + +## Podział funkcji + +**Ekrany** — zakładki widoczne w nawigacji: + +| klucz | zakładka | +|---|---| +| `chart` | Horoskop | +| `interpret` | Interpretacje | +| `timeline` | Kalendarz | +| `synastry` | Synastria | +| `significators` | Sygnifikatory | +| `compile` | Skompiluj | +| `settings` | Ustawienia | + +**Rozszerzenia** — poziomy złożoności wewnątrz ekranów: + +| klucz | co daje | +|---|---| +| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła | +| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji | +| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne | +| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) | +| `export` | pobieranie PDF i Excela | + +Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że +systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani +nie wywoła go z pominięciem interfejsu. + +## Gdzie leży granica + +W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem, +ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje +**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają +(nie ma ich nawet w źródle strony). + +Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej +po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura +jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga +uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test +przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana. + +## Gdzie leżą konta + +Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`), +na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie +tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25. + +Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna +implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik +tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie +pliku, czyli nie skasuje wszystkich kont naraz. + +## Czego ten mechanizm NIE robi + +Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do +udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji +ograniczają to, co widać **przez aplikację**, i tyle. diff --git a/services/presentation/app/accounts.py b/services/presentation/app/accounts.py new file mode 100644 index 0000000..ee67d39 --- /dev/null +++ b/services/presentation/app/accounts.py @@ -0,0 +1,142 @@ +"""Magazyn kont zarządzanych z aplikacji (PRE-27). + +KONTO ADMINISTRACYJNE NIE LEŻY TUTAJ. Pochodzi z konfiguracji środowiska +(APP_USER/APP_PASSWORD albo APP_USERS) i dlatego NIE DA SIĘ go skasować ani +ograniczyć z poziomu ekranu — nawet przez pomyłkę, nawet spreparowanym żądaniem. +Ekran zarządzania kontami operuje wyłącznie na tym pliku. + +Format: JSON `{"users": {"login": {"secret": "scrypt$...", "features": [...], +"note": "...", "created": "ISO"}}}`. Hasła WYŁĄCZNIE jako hash scrypt — ten sam +mechanizm, co dla kont z APP_USERS (security.hash_password), więc nie ma tu +drugiej implementacji do rozjechania. + +Zapis jest ATOMOWY (plik tymczasowy + os.replace) i pod blokadą procesu. Plik +leży na NFS, dzieli go kilka replik, a zwykły zapis „w miejscu" po przerwaniu +zostawiłby plik obcięty — czyli utratę WSZYSTKICH kont naraz. os.replace jest +w obrębie jednego katalogu operacją niepodzielną. +""" +from __future__ import annotations + +import json +import os +import tempfile +import threading +from datetime import datetime, timezone + +from app import features + +_lock = threading.Lock() + + +def store_path() -> str: + """Ścieżka pliku kont. + + Domyślnie WŁASNY podkatalog prezentacji na NFS, nie katalog z bazami: + prezentacja nie ma powodu widzieć baz interpretacyjnych, a zamontowanie ich + tutaj obeszłoby bokiem zamknięcie dostępu z DAN-25.""" + return os.getenv("ACCOUNTS_FILE", "/app/state/accounts.json") + + +def _read() -> dict: + try: + with open(store_path(), encoding="utf-8") as fh: + data = json.load(fh) + except (FileNotFoundError, json.JSONDecodeError): + return {"users": {}} + users = data.get("users") + return {"users": users if isinstance(users, dict) else {}} + + +def _write(data: dict) -> None: + path = store_path() + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + # Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest + # niepodzielne tylko w obrębie jednego systemu plików. + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def all_users() -> dict[str, dict]: + """Konta zarządzane: login → {features, note, created}. BEZ sekretów. + + Sekret nie wychodzi z tego modułu — ekran nigdy go nie potrzebuje, a to, czego + nie ma w kontekście szablonu, nie wycieknie do HTML-a przez pomyłkę.""" + out = {} + for login, row in _read()["users"].items(): + out[login] = { + "features": sorted(features.normalise(row.get("features"))), + "note": str(row.get("note") or ""), + "created": str(row.get("created") or ""), + } + return out + + +def secret_of(login: str) -> str | None: + return (_read()["users"].get(login) or {}).get("secret") + + +def permissions_of(login: str) -> frozenset[str]: + return features.normalise((_read()["users"].get(login) or {}).get("features")) + + +def exists(login: str) -> bool: + return login in _read()["users"] + + +def create(login: str, password: str, granted, note: str = "") -> None: + """Zakłada konto. Podnosi ValueError, gdy login zajęty albo dane puste.""" + from app.security import hash_password + + login = (login or "").strip() + if not login: + raise ValueError("Login nie może być pusty.") + if not (password or "").strip(): + raise ValueError("Hasło nie może być puste.") + with _lock: + data = _read() + if login in data["users"]: + raise ValueError(f"Konto „{login}” już istnieje.") + data["users"][login] = { + "secret": hash_password(password), + "features": sorted(features.normalise(granted)), + "note": (note or "").strip(), + "created": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + _write(data) + + +def update(login: str, granted=None, password: str = "", note: str | None = None) -> None: + """Zmiana uprawnień, hasła albo opisu. Puste hasło = bez zmiany hasła.""" + from app.security import hash_password + + with _lock: + data = _read() + row = data["users"].get(login) + if row is None: + raise ValueError(f"Nie ma konta „{login}”.") + if granted is not None: + row["features"] = sorted(features.normalise(granted)) + if (password or "").strip(): + row["secret"] = hash_password(password) + if note is not None: + row["note"] = note.strip() + _write(data) + + +def delete(login: str) -> None: + with _lock: + data = _read() + if data["users"].pop(login, None) is None: + raise ValueError(f"Nie ma konta „{login}”.") + _write(data) diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py new file mode 100644 index 0000000..8ac8250 --- /dev/null +++ b/services/presentation/app/features.py @@ -0,0 +1,123 @@ +"""Katalog funkcji i uprawnień (PRE-27). + +DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM + +1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może + sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego + „brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403 + jest samo w sobie informacją, że coś tam jest. + +2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. Rozproszenie jej po + dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka + dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Test przechodzi po + wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona. + +PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo +zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI +wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania +systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze). +""" +from __future__ import annotations + +from dataclasses import dataclass + +ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI + + +@dataclass(frozen=True) +class Feature: + key: str + label: str + hint: str + group: str + href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki + + +# ── ekrany (zakładki) ──────────────────────────────────────────────────── +SCREENS: tuple[Feature, ...] = ( + Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"), + Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"), + Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"), + Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"), + Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"), + Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"), + Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"), +) + +# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ─────────────────── +EXTRAS: tuple[Feature, ...] = ( + Feature("houses_compare", "Porównanie systemów domów", + "Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.", + "Rozszerzenia"), + Feature("extra_charts", "Wykresy dodatkowe", + "Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"), + Feature("advanced_calc", "Obliczenia zaawansowane", + "Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.", + "Rozszerzenia"), + Feature("ai", "Generowanie tekstu przez model", + "Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.", + "Rozszerzenia"), + Feature("export", "Eksport plików", + "Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"), +) + +ALL: tuple[Feature, ...] = SCREENS + EXTRAS +BY_KEY: dict[str, Feature] = {f.key: f for f in ALL} +GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem + + +# ── mapa trasa → wymagane uprawnienie ──────────────────────────────────── +# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa +# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu. +ROUTES: dict[tuple[str, str], str | None] = { + ("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran + ("POST", "/"): "chart", + ("GET", "/interpret"): "interpret", + ("POST", "/interpret"): "interpret", + ("GET", "/timeline"): "timeline", + ("POST", "/timeline"): "timeline", + ("GET", "/synastry"): "synastry", + ("POST", "/synastry"): "synastry", + ("GET", "/significators"): "significators", + ("POST", "/significators"): "significators", + ("GET", "/compile"): "compile", + ("POST", "/compile"): "compile", + ("POST", "/compile/pdf"): "export", + ("GET", "/settings"): "settings", + ("POST", "/horoscope/stream"): "ai", + ("GET", "/accounts"): ADMIN, + ("POST", "/accounts/create"): ADMIN, + ("POST", "/accounts/update"): ADMIN, + ("POST", "/accounts/delete"): ADMIN, + ("GET", "/geocode"): None, + ("GET", "/reverse"): None, + ("GET", "/timezone"): None, + ("GET", "/health"): None, +} + + +def required(method: str, path: str) -> str | None: + """Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina. + + Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie + trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny + wybór oznaczałby, że każde przeoczenie tworzy dziurę.""" + return ROUTES.get((method.upper(), path), ADMIN) + + +def can(perms: frozenset[str], feature: str | None) -> bool: + return feature is None or feature in perms + + +def screens_for(perms: frozenset[str]) -> list[Feature]: + """Zakładki do pokazania w nawigacji — w stałej kolejności katalogu.""" + return [f for f in SCREENS if f.key in perms] + + +def normalise(keys) -> frozenset[str]: + """Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a. + + ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi + wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet + spreparowane — nie może go nadać.""" + return frozenset(k for k in (keys or ()) if k in GRANTABLE) diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 95a8280..3da0e14 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -18,25 +18,32 @@ from pathlib import Path import httpx from fastapi import FastAPI, Form, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from app import accounts as accounts_store from app import chartwheel as chartwheel_mod +from app import features from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label from app import geocode, security from app.clients.logic_client import LogicClient from app.config import DEFAULT_LOCATION_LABEL, default_form -app = FastAPI(title="astrololo · warstwa prezentacji") +# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują +# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których +# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała, +# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji. +app = FastAPI(title="astrololo · warstwa prezentacji", + docs_url=None, redoc_url=None, openapi_url=None) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") logic = LogicClient() security.install(app) # logowanie + limit żądań (LOG-32) -# ── cache-busting statyki (PRE-26) ───────────────────────────────────────── +# ── cache-busting statyki (PRE-27) ───────────────────────────────────────── # Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache). # Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL → # przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej). @@ -64,6 +71,45 @@ templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS +def _perms(request: Request) -> frozenset[str]: + """Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to.""" + who = getattr(request.state, "principal", None) + return who.permissions if who is not None else frozenset() + + +# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost. +# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest +# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym +# nikt już nie pamięta. +templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request)) +templates.env.globals["can"] = lambda request, key: key in _perms(request) + + +def _limit_options(request: Request, opts: dict) -> dict: + """Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA. + + Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól, + wyśle je wprost — a wtedy policzyłby funkcję, której mu nie przyznano, i to + jeszcze zobaczyłby ją w wyniku. Granicą jest ta funkcja, nie szablon. + + Wartości zastępcze są DOMYŚLNE, nie puste: konto ograniczone ma dostać + działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś. + """ + perms = _perms(request) + if "advanced_calc" not in perms: + opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical") + if "houses_compare" not in perms: + opts.update(house_system="whole_sign", house_systems=[], + wheel_orientation=chartwheel_mod.ASC_LEFT) + return opts + + +def _landing(request: Request) -> str | None: + """Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia.""" + allowed = features.screens_for(_perms(request)) + return allowed[0].href if allowed else None + + def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: """Z lokalnej daty/godziny + przesunięcia strefy → moment UTC. @@ -108,6 +154,16 @@ def _logic_error(e: Exception) -> str: # ---------------- Horoskop: pozycje (strona główna) ---------------- @app.get("/", response_class=HTMLResponse) def chart_form(request: Request): + # Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie. + # Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem + # głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw. + if "chart" not in _perms(request): + target = _landing(request) + if target: + return RedirectResponse(target, status_code=303) + return HTMLResponse("

Brak przypisanych funkcji

" + "

Skontaktuj się z osobą, która zakładała konto.

", + status_code=200) return templates.TemplateResponse( request, "chart.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, @@ -140,22 +196,27 @@ def chart_compute( "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "stations": stations, "zodiac": zodiac, "tables": tables, "wheel_orientation": wheel_orientation} + form = _limit_options(request, form) ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label ctx["result"] = logic.positions( when_utc_iso=iso_utc, lat=lat, lon=lon, - house_system=house_system, house_systems=house_systems, + house_system=form["house_system"], house_systems=form["house_systems"], aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, - aspect_minor=aspect_minor, - stations=stations, zodiac=zodiac, tables=tables, + aspect_minor=form["aspect_minor"], + stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"], ) from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera - ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation) - ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 - ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) - ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) + ctx["wheel_svg"] = chartwheel.render(ctx["result"], + orientation=form["wheel_orientation"]) + # Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie + # ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony. + if "extra_charts" in _perms(request): + ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 + ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 + ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: @@ -206,22 +267,25 @@ def compile_build( "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "stations": stations, "zodiac": zodiac, "tables": tables, "wheel_orientation": wheel_orientation} + form = _limit_options(request, form) ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label ctx["result"] = logic.positions( when_utc_iso=iso_utc, lat=lat, lon=lon, - house_system=house_system, house_systems=house_systems, + house_system=form["house_system"], house_systems=form["house_systems"], aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, - aspect_minor=aspect_minor, - stations=stations, zodiac=zodiac, tables=tables, + aspect_minor=form["aspect_minor"], + stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"], ) from app import chartwheel - ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation) - ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 - ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) - ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) + ctx["wheel_svg"] = chartwheel.render(ctx["result"], + orientation=form["wheel_orientation"]) + if "extra_charts" in _perms(request): + ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 + ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 + ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: @@ -608,6 +672,67 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00") return res +# ---------------- Konta i uprawnienia (PRE-27) ---------------- +# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa +# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach. + +def _accounts_context(request: Request, error: str = "", done: str = "") -> dict: + return { + "users": accounts_store.all_users(), + "catalog": features.ALL, + "screens": features.SCREENS, + "extras": features.EXTRAS, + "admin_login": security.app_user(), + "store_path": accounts_store.store_path(), + "error": error, "done": done, + } + + +@app.get("/accounts", response_class=HTMLResponse) +def accounts_view(request: Request, error: str = "", done: str = ""): + return templates.TemplateResponse(request, "accounts.html", + _accounts_context(request, error, done)) + + +def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse: + """Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może + powtórzyć zakładania ani kasowania konta.""" + from urllib.parse import urlencode + + q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v}) + return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303) + + +@app.post("/accounts/create") +def accounts_create(request: Request, login: str = Form(""), password: str = Form(""), + note: str = Form(""), granted: list[str] = Form([])): + try: + accounts_store.create(login, password, granted, note) + except ValueError as e: + return _accounts_redirect(error=str(e)) + return _accounts_redirect(done=f"Założono konto „{login.strip()}”.") + + +@app.post("/accounts/update") +def accounts_update(request: Request, login: str = Form(...), password: str = Form(""), + note: str = Form(""), granted: list[str] = Form([])): + try: + accounts_store.update(login, granted=granted, password=password, note=note) + except ValueError as e: + return _accounts_redirect(error=str(e)) + changed = "uprawnienia i hasło" if password.strip() else "uprawnienia" + return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.") + + +@app.post("/accounts/delete") +def accounts_delete(request: Request, login: str = Form(...)): + try: + accounts_store.delete(login) + except ValueError as e: + return _accounts_redirect(error=str(e)) + return _accounts_redirect(done=f"Skasowano konto „{login}”.") + + @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "presentation"} diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py index 59bff8f..3f58b80 100644 --- a/services/presentation/app/security.py +++ b/services/presentation/app/security.py @@ -42,6 +42,7 @@ import os import secrets import time from collections import deque +from dataclasses import dataclass from fastapi import Request from fastapi.responses import HTMLResponse, JSONResponse @@ -131,12 +132,36 @@ def _is_public(path: str) -> bool: return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES) -def authenticate(header: str | None) -> str | None: - """Nagłówek Basic → LOGIN zalogowanej osoby albo None. +@dataclass(frozen=True) +class Principal: + """Kto się zalogował i co wolno mu zobaczyć.""" - Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno - PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania, - które konto istnieje).""" + login: str + permissions: frozenset[str] + is_admin: bool + + +def _admin_permissions() -> frozenset[str]: + """Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie. + + Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania + o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja + byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać.""" + from app import features + + return frozenset(features.GRANTABLE) | {features.ADMIN} + + +def principal(header: str | None) -> Principal | None: + """Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują. + + Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne + uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej + założenie w pliku konta o loginie administratora przesłoniłoby konto + administracyjne i odebrało mu uprawnienia. + + Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto + istnieje.""" if not header or not header.lower().startswith("basic "): return None try: @@ -144,10 +169,26 @@ def authenticate(header: str | None) -> str | None: user, _, password = raw.partition(":") except (binascii.Error, UnicodeDecodeError, IndexError): return None + spec = accounts().get(user) - if spec is None: + if spec is not None: + return (Principal(user, _admin_permissions(), True) + if _secret_matches(spec, password) else None) + + from app import accounts as store + + managed = store.secret_of(user) + if managed is None: return None - return user if _secret_matches(spec, password) else None + if not _secret_matches(managed, password): + return None + return Principal(user, store.permissions_of(user), False) + + +def authenticate(header: str | None) -> str | None: + """Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz.""" + who = principal(header) + return who.login if who else None def client_ip(request: Request) -> str: @@ -264,16 +305,32 @@ def install(app) -> None: status_code=429, headers={"Retry-After": "60"}, ) - user = "" + from app import features + + user, who = "", None if auth_enabled(): - user = authenticate(request.headers.get("authorization")) or "" - if not user: + who = principal(request.headers.get("authorization")) + if who is None: _audit(request, "-", 401, (time.monotonic() - started) * 1000) return HTMLResponse( "

401 — wymagane logowanie

", status_code=401, headers={"WWW-Authenticate": 'Basic realm="astrololo"'}, ) + user = who.login + else: + # Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej + # deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma. + who = Principal("", _admin_permissions(), True) + request.state.user = user + request.state.principal = who + + # 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest — + # a konto ograniczone ma widzieć program kompletny, tylko mniejszy. + need = features.required(request.method, request.url.path) + if not features.can(who.permissions, need): + _audit(request, user, 404, (time.monotonic() - started) * 1000) + return HTMLResponse("

404 — nie znaleziono

", status_code=404) response = await call_next(request) _audit(request, user, response.status_code, (time.monotonic() - started) * 1000) return response diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 7da19f8..1e37860 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -214,3 +214,22 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; } bez przepisywania ich na HTML. */ .report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; } + + +/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */ +.account-card { + margin-top: 1rem; padding: 1rem 1.25rem; + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; +} +.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; } +.account-note input { width: 100%; } +.account-card .actions { margin-top: .9rem; } +button.danger { background: #8b2f2f; } + +/* Potwierdzenie operacji. Odróżnia się od .house-warning, bo tamto ostrzega, + a to tylko potwierdza — pomylenie ich uczy ignorować oba. */ +.done-note { + margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem; + border: 1px solid var(--line); border-left: 4px solid var(--accent); + border-radius: 4px; background: rgba(255, 255, 255, .03); +} diff --git a/services/presentation/app/templates/_form_options.html b/services/presentation/app/templates/_form_options.html index 91816ba..e359241 100644 --- a/services/presentation/app/templates/_form_options.html +++ b/services/presentation/app/templates/_form_options.html @@ -1,12 +1,15 @@ {# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik, bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #} +{% if can(request, 'advanced_calc') %}
+{% endif %} +{% if can(request, 'houses_compare') %}
{% set chosen = form.house_systems or [] %} Porównaj systemy domów @@ -16,6 +19,8 @@ {% endfor %}
+{% endif %} +{% if can(request, 'houses_compare') %}
{% set wo = form.wheel_orientation or 'asc' %} Kosmogram — po lewej stronie: @@ -23,8 +28,11 @@ {% endfor %}
+{% endif %} +{% if can(request, 'advanced_calc') %}
+{% endif %} diff --git a/services/presentation/app/templates/accounts.html b/services/presentation/app/templates/accounts.html new file mode 100644 index 0000000..afba6b8 --- /dev/null +++ b/services/presentation/app/templates/accounts.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% set nav_active = "accounts" %} +{% block title %}Konta{% endblock %} + +{% block content %} +

+ Konta i ich dostęp do funkcji programu. Konto {{ admin_login }} + pochodzi z konfiguracji środowiska, ma wszystkie uprawnienia i jest jedynym, + które może zarządzać pozostałymi — dlatego nie ma go na tej liście i nie da się + go stąd skasować. +

+

+ Osoba na koncie ograniczonym widzi wyłącznie przyznane jej zakładki i opcje. + Nic w programie nie pokazuje jej, że istnieje coś więcej — niedostępne adresy + odpowiadają tak samo jak nieistniejące. +

+ +{% if error %}

{{ error }}

{% endif %} +{% if done %}

{{ done }}

{% endif %} + +
Konta ({{ users | length }})
+{% if not users %} +

Nie ma jeszcze żadnego konta poza administracyjnym.

+{% endif %} + +{% for login, row in users | dictsort %} + +{% endfor %} + +
Nowe konto
+ + +
+ Gdzie to jest zapisane +

+ Konta trzymamy w pliku {{ store_path }} — hasła wyłącznie jako + hash scrypt, nigdy jawnie. Zapis jest atomowy, więc przerwanie w trakcie nie + obetnie pliku i nie skasuje wszystkich kont naraz. +

+
+{% endblock %} diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index c771609..7e6035f 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -18,14 +18,16 @@

astrololo

+ {# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych — + ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że + program umie więcej, niż pokazuje (PRE-26). #}
{% block content %}{% endblock %} diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 677d49a..0c87e80 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Horoskop{% endblock %} -{% block nav_chart %}active{% endblock %} +{% set nav_active = "chart" %} {% block content %}

Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).

@@ -31,6 +31,7 @@ + {% if can(request, 'houses_compare') %} + {% endif %} + {% if can(request, 'advanced_calc') %} + {% endif %} {% include "_location_picker.html" %} {% include "_form_options.html" %} diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html index 0cf4592..d514896 100644 --- a/services/presentation/app/templates/compile.html +++ b/services/presentation/app/templates/compile.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Skompiluj{% endblock %} -{% block nav_compile %}active{% endblock %} +{% set nav_active = "compile" %} {% block content %}

Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie @@ -36,6 +36,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t + {% if can(request, 'houses_compare') %} + {% endif %} + {% if can(request, 'advanced_calc') %} + {% endif %} {# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #} {% include "_form_options.html" %}

- - + {% endif %}
diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index 29c7691..b639102 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Interpretacje{% endblock %} -{% block nav_interp %}active{% endblock %} +{% set nav_active = "interpret" %} {% block content %}

Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).

@@ -40,7 +40,7 @@
- + {% if can(request, 'export') %}{% endif %} diff --git a/services/presentation/app/templates/settings.html b/services/presentation/app/templates/settings.html index 5d3c10d..c78292e 100644 --- a/services/presentation/app/templates/settings.html +++ b/services/presentation/app/templates/settings.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Ustawienia{% endblock %} -{% block nav_settings %}active{% endblock %} +{% set nav_active = "settings" %} {% block content %}

Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.

diff --git a/services/presentation/app/templates/significators.html b/services/presentation/app/templates/significators.html index 89c9e44..b5454d1 100644 --- a/services/presentation/app/templates/significators.html +++ b/services/presentation/app/templates/significators.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Sygnifikatory{% endblock %} -{% block nav_sig %}active{% endblock %} +{% set nav_active = "significators" %} {% block content %}

Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.

diff --git a/services/presentation/app/templates/synastry.html b/services/presentation/app/templates/synastry.html index 9562f97..0215063 100644 --- a/services/presentation/app/templates/synastry.html +++ b/services/presentation/app/templates/synastry.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Synastria{% endblock %} -{% block nav_synastry %}active{% endblock %} +{% set nav_active = "synastry" %} {% block content %}

Dwie osoby → aspekty między ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.

diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html index 90cf9e2..dc17d89 100644 --- a/services/presentation/app/templates/timeline.html +++ b/services/presentation/app/templates/timeline.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %}Kalendarz{% endblock %} -{% block nav_timeline %}active{% endblock %} +{% set nav_active = "timeline" %} {% block content %}

Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.

diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py index 2e7dee2..689d46f 100644 --- a/services/presentation/tests/test_compile.py +++ b/services/presentation/tests/test_compile.py @@ -31,7 +31,16 @@ def _client(): # ───────────────────────────────── nawigacja i trasa ───────────────────── def test_tab_is_in_the_menu(): - assert 'href="/compile"' in BASE and "Skompiluj" in BASE + """Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja. + + Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta + (PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło. + Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu.""" + from app import features + + tab = features.BY_KEY["compile"] + assert tab in features.SCREENS + assert tab.href and tab.label def test_get_renders_empty_form(): @@ -203,11 +212,25 @@ def test_compile_form_carries_the_options(): assert f in opts, f"brak opcji we wspólnym pliku: {f}" -def test_compile_handler_recomputes_with_all_options(): - assert "house_systems: list[str] = Form([])" in MAIN - assert "stations: bool = Form(False)" in MAIN - assert "house_systems=house_systems" in MAIN # przekazane do logiki - assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN +def test_compile_handler_recomputes_with_all_options(monkeypatch): + """„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza. + + To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym + zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy + przez wywołanie trasy, nie przez szukanie napisów w main.py.""" + seen = {} + from app.main import app, logic + + monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1]) + r = _client().post("/compile", data={ + "date": "1984-04-30", "time": "11:20", "tz_offset": "2", + "lat": "50.06", "lon": "19.94", "house_system": "koch", + "house_systems": ["koch", "equal"], "stations": "true", "tables": "true", + "zodiac": "sidereal_lahiri", "aspect_minor": "true"}) + assert r.status_code == 200, r.text[:300] + assert seen["house_systems"] == ["koch", "equal"] + assert seen["stations"] is True and seen["tables"] is True + assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True def test_compile_pdf_recomputes_with_all_options(): diff --git a/services/presentation/tests/test_house_compare.py b/services/presentation/tests/test_house_compare.py index 69ecc2a..c225e57 100644 --- a/services/presentation/tests/test_house_compare.py +++ b/services/presentation/tests/test_house_compare.py @@ -34,7 +34,12 @@ def _render(name: str, **ctx) -> str: env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True) env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED, - house_label=house_label, static=lambda p: p) + house_label=house_label, static=lambda p: p, + # Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami — + # inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia + # mają własne testy w test_kontrola_dostepu.py. + can=lambda request, key: ctx.pop("_denied", None) != key, + nav_screens=lambda request: []) return env.get_template(name).render(**ctx) @@ -57,9 +62,26 @@ def test_form_marks_systems_with_a_domain_limit(): assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}" -def test_handler_accepts_and_passes_house_systems(): - assert "house_systems: list[str] = Form([])" in MAIN - assert "house_systems=house_systems" in MAIN # przekazane do logiki +def test_handler_accepts_and_passes_house_systems(monkeypatch): + """Zaznaczone systemy DOCHODZĄ do warstwy logicznej. + + Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py + i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że + zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół.""" + seen = {} + + def spy(**kw): + seen.update(kw) + return _minimal_chart() + + c = _client_with_stub_logic(monkeypatch, _minimal_chart()) + from app.main import logic + + monkeypatch.setattr(logic, "positions", spy) + r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]}) + assert r.status_code == 200, r.text[:300] + assert seen["house_systems"] == ["koch", "campanus"] + assert seen["house_system"] == "placidus" def test_client_forwards_house_systems_to_logic(): diff --git a/services/presentation/tests/test_kontrola_dostepu.py b/services/presentation/tests/test_kontrola_dostepu.py new file mode 100644 index 0000000..3c528d4 --- /dev/null +++ b/services/presentation/tests/test_kontrola_dostepu.py @@ -0,0 +1,311 @@ +"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27). + +DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK + +1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane + przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu + nie chroni przed kimś, kto zna nazwy pól. + +2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność + negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat + o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak + pozycji w menu, brak rysunków w źródle strony. +""" +import json +import pathlib + +import pytest + +from app import accounts as store +from app import features, security + + +@pytest.fixture() +def env(tmp_path, monkeypatch): + """Świeży plik kont + konto administracyjne ze środowiska.""" + monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json")) + monkeypatch.setenv("APP_USER", "szef") + monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") + monkeypatch.delenv("APP_USERS", raising=False) + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania + return tmp_path + + +def _client(monkeypatch, chart=None): + from starlette.testclient import TestClient + + from app.main import app, logic + + monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart()) + return TestClient(app) + + +def _auth(user, password): + import base64 + + raw = base64.b64encode(f"{user}:{password}".encode()).decode() + return {"Authorization": f"Basic {raw}"} + + +def _chart(): + cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30), + "sign_glyph": "♈"} for i in range(12)] + ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0, + "sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")} + return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang, + "sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "whole_sign", + "aspects": [], "house_systems": []} + + +FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2", + "lat": "50.06", "lon": "19.94"} + + +# ── katalog i mapa tras ────────────────────────────────────────────────── + +def test_every_route_declares_the_permission_it_needs(): + """Trasa bez wpisu w ROUTES to dziura, której nie widać. + + Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test + potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy.""" + from app.main import app + + missing = [] + for route in app.routes: + path = getattr(route, "path", None) + methods = getattr(route, "methods", None) or set() + if not path or path.startswith("/static"): + continue + for method in methods & {"GET", "POST"}: + if (method, path) not in features.ROUTES: + missing.append(f"{method} {path}") + assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}" + + +def test_unknown_route_defaults_to_the_narrowest_permission(): + """Przeoczenie ma ZAMYKAĆ, nie otwierać.""" + assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN + + +def test_admin_permission_cannot_be_granted_from_a_form(): + """Nawet spreparowane żądanie nie nada uprawnień administracyjnych — + konto administracyjne pochodzi wyłącznie ze środowiska.""" + assert features.ADMIN not in features.normalise([features.ADMIN, "chart"]) + assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"}) + + +# ── magazyn kont ───────────────────────────────────────────────────────── + +def test_password_is_stored_only_as_a_hash(env): + store.create("ala", "hasło-ali", ["chart"]) + raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8") + assert "hasło-ali" not in raw + assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$") + + +def test_listing_accounts_never_exposes_secrets(env): + store.create("ala", "hasło-ali", ["chart"]) + assert "secret" not in store.all_users()["ala"] + + +def test_create_update_delete(env): + store.create("ala", "x", ["chart"], note="do testów") + assert store.permissions_of("ala") == frozenset({"chart"}) + store.update("ala", granted=["chart", "ai"]) + assert store.permissions_of("ala") == frozenset({"chart", "ai"}) + with pytest.raises(ValueError): + store.create("ala", "y", []) # login zajęty + store.delete("ala") + assert not store.exists("ala") + + +def test_empty_password_on_update_keeps_the_old_one(env): + store.create("ala", "stare", ["chart"]) + before = store.secret_of("ala") + store.update("ala", granted=["chart"], password="") + assert store.secret_of("ala") == before + + +# ── logowanie i rozpoznanie konta ──────────────────────────────────────── + +def test_environment_account_is_the_administrator(env): + who = security.principal(_auth("szef", "tajne-szefa")["Authorization"]) + assert who and who.is_admin + assert features.ADMIN in who.permissions + assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko" + + +def test_managed_account_gets_exactly_its_own_permissions(env): + store.create("ala", "hasło-ali", ["chart", "ai"]) + who = security.principal(_auth("ala", "hasło-ali")["Authorization"]) + assert who and not who.is_admin + assert who.permissions == frozenset({"chart", "ai"}) + + +def test_managed_account_cannot_shadow_the_administrator(env): + """Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić — + inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać.""" + store.create("szef", "podszywam-się", []) + who = security.principal(_auth("szef", "tajne-szefa")["Authorization"]) + assert who and who.is_admin and features.ADMIN in who.permissions + + +def test_wrong_password_is_refused_the_same_way_as_unknown_login(env): + store.create("ala", "hasło-ali", ["chart"]) + assert security.principal(_auth("ala", "złe")["Authorization"]) is None + assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None + + +# ── niewidzialność funkcji ─────────────────────────────────────────────── + +def test_forbidden_screen_answers_404_not_403(env, monkeypatch): + """403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od + adresu, którego nie ma.""" + store.create("ala", "x", ["chart"]) + c = _client(monkeypatch) + for path in ("/significators", "/interpret", "/timeline", "/synastry", + "/compile", "/settings", "/accounts"): + r = c.get(path, headers=_auth("ala", "x")) + assert r.status_code == 404, f"{path} → {r.status_code}" + assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404 + + +def test_menu_shows_only_granted_screens(env, monkeypatch): + store.create("ala", "x", ["chart", "significators"]) + c = _client(monkeypatch) + html = c.get("/", headers=_auth("ala", "x")).text + assert 'href="/significators"' in html + for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"', + 'href="/compile"', 'href="/settings"', 'href="/accounts"'): + assert absent not in html, f"menu zdradza {absent}" + for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"): + assert word not in html, f"nazwa „{word}” nie powinna paść" + + +def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch): + c = _client(monkeypatch) + html = c.get("/", headers=_auth("szef", "tajne-szefa")).text + for tab in features.SCREENS: + assert f'href="{tab.href}"' in html, tab.key + assert 'href="/accounts"' in html + + +def test_root_lands_on_the_first_granted_screen(env, monkeypatch): + """Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym — + wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji.""" + store.create("ala", "x", ["significators"]) + c = _client(monkeypatch) + r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False) + assert r.status_code == 303 and r.headers["location"] == "/significators" + + +# ── ograniczenia opcji egzekwowane NA SERWERZE ────────────────────────── + +def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch): + """Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś, + kto zna nazwy pól. Granicą jest handler.""" + store.create("ala", "x", ["chart"]) + seen = {} + from app.main import app, logic + from starlette.testclient import TestClient + + monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1]) + r = TestClient(app).post("/", headers=_auth("ala", "x"), data={ + **FORM, "stations": "true", "tables": "true", "aspect_minor": "true", + "zodiac": "sidereal_lahiri", "house_system": "koch", + "house_systems": ["koch", "campanus"]}) + assert r.status_code == 200, r.text[:300] + assert seen["stations"] is False and seen["tables"] is False + assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical" + assert seen["house_system"] == "whole_sign" and seen["house_systems"] == [] + + +def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch): + """Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać.""" + store.create("ala", "x", ["chart"]) + c = _client(monkeypatch) + html = c.post("/", headers=_auth("ala", "x"), data=FORM).text + for word in ("Aspektarian", "deklinacj", "ntyscj"): + assert word not in html, f"źródło strony zdradza „{word}”" + + +def test_the_same_account_with_the_extra_gets_them(env, monkeypatch): + """Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy, + gdyby rysunki były zepsute dla wszystkich.""" + store.create("ola", "x", ["chart", "extra_charts"]) + c = _client(monkeypatch, chart=_chart_with_objects()) + html = c.post("/", headers=_auth("ola", "x"), data=FORM).text + assert "Aspektarian" in html + + +def _chart_with_objects(): + """Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały.""" + base = _chart() + base["positions"] = [ + {"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "♈", "in_sign": "0", + "decimal": float(i * 30), "direction": "D", "speed": 1.0, + "declination": 10.0, "house": i + 1} + for i, (n, g) in enumerate((("Sun", "☉"), ("Moon", "☽"), ("Mars", "♂")))] + base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine", + "orb": 1.0, "allowed": 8.0}] + return base + + +def test_only_the_administrator_can_manage_accounts(env, monkeypatch): + store.create("ala", "x", ["chart", "ai", "export", "extra_charts"]) + c = _client(monkeypatch) + for method, path in (("get", "/accounts"), ("post", "/accounts/create"), + ("post", "/accounts/update"), ("post", "/accounts/delete")): + kwargs = {"headers": _auth("ala", "x")} + if method == "post": + kwargs["data"] = {"login": "ktoś"} + r = getattr(c, method)(path, **kwargs) + assert r.status_code == 404, f"{path} → {r.status_code}" + assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200 + + +# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ─────── + +def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch): + """Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na + tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu + aplikacji i bez dotykania konfiguracji środowiska.""" + c = _client(monkeypatch) + admin = _auth("szef", "tajne-szefa") + + r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={ + "login": "nowa", "password": "jej-hasło", "note": "praktykantka", + "granted": ["chart", "significators", "extra_charts"]}) + assert r.status_code == 303 + + listing = c.get("/accounts", headers=admin).text + assert "nowa" in listing and "praktykantka" in listing + assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran" + + her = _auth("nowa", "jej-hasło") + html = c.get("/", headers=her).text + assert 'href="/significators"' in html + assert 'href="/accounts"' not in html and 'href="/compile"' not in html + assert c.get("/compile", headers=her).status_code == 404 + + # odebranie uprawnienia działa od razu + c.post("/accounts/update", headers=admin, follow_redirects=False, + data={"login": "nowa", "granted": ["chart"]}) + assert c.get("/significators", headers=her).status_code == 404 + + # skasowanie konta odcina logowanie + c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"}) + assert c.get("/", headers=her).status_code == 401 + + +def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch): + """Konto administracyjne nie leży w pliku, więc nie ma czego skasować — + ale próba nie może też wywalić aplikacji ani skasować czegoś innego.""" + c = _client(monkeypatch) + admin = _auth("szef", "tajne-szefa") + store.create("ala", "x", ["chart"]) + r = c.post("/accounts/delete", headers=admin, follow_redirects=False, + data={"login": "szef"}) + assert r.status_code == 303 + assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne" + assert c.get("/", headers=admin).status_code == 200 diff --git a/services/presentation/tests/test_settings_bases.py b/services/presentation/tests/test_settings_bases.py index 5818cf3..278196e 100644 --- a/services/presentation/tests/test_settings_bases.py +++ b/services/presentation/tests/test_settings_bases.py @@ -9,7 +9,16 @@ CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8") def test_tab_is_in_the_menu(): - assert 'href="/settings"' in BASE and "Ustawienia" in BASE + """Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja. + + Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta + (PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło. + Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu.""" + from app import features + + tab = features.BY_KEY["settings"] + assert tab in features.SCREENS + assert tab.href and tab.label def test_route_and_client_exist(): diff --git a/services/presentation/tests/test_synastry.py b/services/presentation/tests/test_synastry.py index 6876ab4..a463794 100644 --- a/services/presentation/tests/test_synastry.py +++ b/services/presentation/tests/test_synastry.py @@ -20,7 +20,16 @@ def _client(): def test_tab_is_in_the_menu(): - assert 'href="/synastry"' in BASE and "Synastria" in BASE + """Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja. + + Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta + (PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło. + Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu.""" + from app import features + + tab = features.BY_KEY["synastry"] + assert tab in features.SCREENS + assert tab.href and tab.label def test_routes_exist():