feat(bezpieczeństwo): konta z uprawnieniami do zakładek i funkcji (PRE-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m58s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 3m37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
build / build (push) Successful in 18s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m58s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 3m37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
build / build (push) Successful in 18s
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 <noreply@anthropic.com>
This commit was merged in pull request #68.
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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("<h1>Brak przypisanych funkcji</h1>"
|
||||
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
||||
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"}
|
||||
|
||||
@@ -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(
|
||||
"<h1>401 — wymagane logowanie</h1>", 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("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||
response = await call_next(request)
|
||||
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
||||
return response
|
||||
|
||||
@@ -228,3 +228,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);
|
||||
}
|
||||
|
||||
@@ -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') %}
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<div class="opts systems" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
|
||||
{% set chosen = form.house_systems or [] %}
|
||||
<span class="muted small opts-label">Porównaj systemy domów
|
||||
@@ -16,6 +19,8 @@
|
||||
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
|
||||
{% set wo = form.wheel_orientation or 'asc' %}
|
||||
<span class="muted small">Kosmogram — po lewej stronie:</span>
|
||||
@@ -23,8 +28,11 @@
|
||||
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
||||
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
|
||||
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
|
||||
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% set nav_active = "accounts" %}
|
||||
{% block title %}Konta{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">
|
||||
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
||||
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ć.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
|
||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||
|
||||
<div class="meta">Konta ({{ users | length }})</div>
|
||||
{% if not users %}
|
||||
<p class="muted">Nie ma jeszcze żadnego konta poza administracyjnym.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for login, row in users | dictsort %}
|
||||
<form method="post" action="/accounts/update" class="account-card">
|
||||
<input type="hidden" name="login" value="{{ login }}">
|
||||
<div class="account-head">
|
||||
<strong>{{ login }}</strong>
|
||||
<span class="muted small">{{ row.created[:10] }}</span>
|
||||
</div>
|
||||
<label class="account-note">Opis
|
||||
<input type="text" name="note" value="{{ row.note }}" placeholder="np. do czego to konto służy">
|
||||
</label>
|
||||
|
||||
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
|
||||
<div class="opts systems" title="{{ 'Zakładki widoczne w nawigacji.' if group == 'Ekrany' else 'Poziomy złożoności wewnątrz ekranów.' }}">
|
||||
<span class="muted small opts-label">{{ group }}</span>
|
||||
{% for f in items %}
|
||||
<label title="{{ f.hint }}">
|
||||
<input type="checkbox" name="granted" value="{{ f.key }}"
|
||||
{{ 'checked' if f.key in row.features else '' }}> {{ f.label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="opts">
|
||||
<label>Nowe hasło (puste = bez zmiany)
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="••••••">
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Zapisz</button>
|
||||
<button type="submit" formaction="/accounts/delete" class="danger"
|
||||
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
|
||||
Skasuj konto
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
|
||||
<div class="meta">Nowe konto</div>
|
||||
<form method="post" action="/accounts/create" class="account-card">
|
||||
<div class="grid">
|
||||
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
||||
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
||||
<label>Opis <input type="text" name="note" placeholder="np. do czego to konto służy"></label>
|
||||
</div>
|
||||
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
|
||||
<div class="opts systems">
|
||||
<span class="muted small opts-label">{{ group }}</span>
|
||||
{% for f in items %}
|
||||
<label title="{{ f.hint }}"><input type="checkbox" name="granted" value="{{ f.key }}"> {{ f.label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="actions"><button type="submit">Załóż konto</button></div>
|
||||
</form>
|
||||
|
||||
<details class="loc">
|
||||
<summary>Gdzie to jest zapisane</summary>
|
||||
<p class="muted small">
|
||||
Konta trzymamy w pliku <code>{{ store_path }}</code> — 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.
|
||||
</p>
|
||||
</details>
|
||||
{% endblock %}
|
||||
@@ -19,14 +19,16 @@
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>astrololo</h1>
|
||||
{# 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). #}
|
||||
<nav>
|
||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
||||
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
||||
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
|
||||
{% for tab in nav_screens(request) %}
|
||||
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
|
||||
{% endfor %}
|
||||
{% if can(request, 'admin') %}
|
||||
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Horoskop{% endblock %}
|
||||
{% block nav_chart %}active{% endblock %}
|
||||
{% set nav_active = "chart" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||
@@ -31,6 +31,7 @@
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
@@ -39,6 +40,8 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<label>Zodiak
|
||||
<select name="zodiac">
|
||||
{% set zd = form.zodiac or 'tropical' %}
|
||||
@@ -49,6 +52,7 @@
|
||||
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "_location_picker.html" %}
|
||||
{% include "_form_options.html" %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Skompiluj{% endblock %}
|
||||
{% block nav_compile %}active{% endblock %}
|
||||
{% set nav_active = "compile" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">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
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
{% if can(request, 'houses_compare') %}
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
@@ -44,6 +45,8 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if can(request, 'advanced_calc') %}
|
||||
<label>Zodiak
|
||||
<select name="zodiac">
|
||||
{% set zd = form.zodiac or 'tropical' %}
|
||||
@@ -54,15 +57,16 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# 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" %}
|
||||
<div class="actions">
|
||||
<button type="submit">Złóż podsumowanie</button>
|
||||
<button type="button" id="pdfBtn" class="ghost"
|
||||
{% if can(request, 'export') %}<button type="button" id="pdfBtn" class="ghost"
|
||||
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
|
||||
<span id="pdfNote" class="muted small"></span>
|
||||
<span id="pdfNote" class="muted small"></span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interpretacje{% endblock %}
|
||||
{% block nav_interp %}active{% endblock %}
|
||||
{% set nav_active = "interpret" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||
<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>
|
||||
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ustawienia{% endblock %}
|
||||
{% block nav_settings %}active{% endblock %}
|
||||
{% set nav_active = "settings" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sygnifikatory{% endblock %}
|
||||
{% block nav_sig %}active{% endblock %}
|
||||
{% set nav_active = "significators" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Synastria{% endblock %}
|
||||
{% block nav_synastry %}active{% endblock %}
|
||||
{% set nav_active = "synastry" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Kalendarz{% endblock %}
|
||||
{% block nav_timeline %}active{% endblock %}
|
||||
{% set nav_active = "timeline" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user