diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 17f2259..2f226b1 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ 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 %} +
+ +
+ {{ login }} + {{ row.created[:10] }} +
+ + + {% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %} +
+ {{ group }} + {% for f in items %} + + {% endfor %} +
+ {% endfor %} + +
+ +
+
+ + +
+
+{% endfor %} + +
Nowe konto
+
+
+ + + +
+ {% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %} +
+ {{ group }} + {% for f in items %} + + {% endfor %} +
+ {% endfor %} +
+
+ +
+ 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():