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). #}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" %}
Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).
@@ -40,7 +40,7 @@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():