"""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 # ── awaria magazynu kont ───────────────────────────────────────────────── # Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem # z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500 # (tak było na pierwszym wdrożeniu) zostawia go z niczym. def _unreadable(tmp_path, monkeypatch, mode): d = tmp_path / "stan" d.mkdir() monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json")) d.chmod(mode) return d def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch): d = _unreadable(tmp_path, monkeypatch, 0o000) try: c = _client(monkeypatch) r = c.get("/accounts", headers=_auth("szef", "tajne-szefa")) assert r.status_code == 200, "problem z magazynem nie może wywalać strony" assert "Nie mogę odczytać pliku kont" in r.text assert str(d / "accounts.json") in r.text, "komunikat ma podać ŚCIEŻKĘ" finally: d.chmod(0o755) def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch): d = _unreadable(tmp_path, monkeypatch, 0o555) try: c = _client(monkeypatch) r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"), follow_redirects=False, data={"login": "ala", "password": "x", "granted": ["chart"]}) assert r.status_code == 303 # Komunikat jedzie w parametrze zapytania, więc jest zakodowany — # porównanie na surowym nagłówku sprawdzałoby procenty, nie treść. from urllib.parse import unquote_plus assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"]) finally: d.chmod(0o755) def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch): """Nadpisanie uszkodzonego pliku pustym zbiorem skasowałoby WSZYSTKIE konta. Lepiej odmówić i powiedzieć, co jest nie tak.""" path = tmp_path / "accounts.json" path.write_text("{to nie jest json", encoding="utf-8") monkeypatch.setenv("ACCOUNTS_FILE", str(path)) c = _client(monkeypatch) r = c.get("/accounts", headers=_auth("szef", "tajne-szefa")) assert r.status_code == 200 and "uszkodzony" in r.text c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"), follow_redirects=False, data={"login": "ala", "password": "x"}) assert path.read_text(encoding="utf-8") == "{to nie jest json", \ "uszkodzony plik został nadpisany — konta by zniknęły"