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

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:
2026-08-06 21:45:23 +02:00
committed by gitea
parent baf4e0e38a
commit a833965909
22 changed files with 1086 additions and 56 deletions
+29 -6
View File
@@ -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():
+10 -1
View File
@@ -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():