e2b50b284f
Testy / Testy warstwy logicznej (silnik) (push) Successful in 26m56s
build / build (push) Successful in 9s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4m54s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Ekran „Konta" wywalał się na produkcji błędem 500 bez słowa wyjaśnienia. Odtworzone lokalnie: `_read()` łapał wyłącznie brak pliku i zły JSON, więc każdy inny błąd systemu plików — a na udziale NFS to głównie prawa — leciał na wierzch jako nieobsłużony wyjątek. To jest szczególnie zły sposób na awarię AKURAT TUTAJ: ekran kont jest jedynym miejscem, z którego administrator może taki problem naprawić, a gołe 500 nie mówi mu ani co, ani gdzie. Teraz każdy błąd magazynu ma twarz: osobny wyjątek AccountsUnavailable niosący ŚCIEŻKĘ i powód z systemu operacyjnego, plus podpowiedź najczęstszej przyczyny (prawa katalogu na udziale albo wolumen zamontowany tylko do odczytu). Strona renderuje się normalnie z tym komunikatem u góry. Objęte są wszystkie cztery drogi zapisu, a nie tylko odczyt. W szczególności mkstemp: przy katalogu tylko do odczytu wywala się ONO pierwsze, jeszcze zanim dojdzie do zapisu i podmiany — więc obudowanie samego os.replace nic by nie dało (złapane testem, nie przeglądem kodu). USZKODZONY PLIK NIE JEST NADPISYWANY. Wcześniej niepoprawny JSON dawał pusty zbiór kont, co przy pierwszym zapisie skasowałoby WSZYSTKIE konta bez śladu. Teraz to odmowa z komunikatem — plik zostaje nietknięty, a test tego pilnuje. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
371 lines
16 KiB
Python
371 lines
16 KiB
Python
"""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"
|