f0d07ee8c3
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m18s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Basic nie miał wylogowania i nie dało się tego obejść: przeglądarka zapamiętuje
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
Poprzednia próba (LOG-32) opierała się na nakłonieniu przeglądarki, żeby porzuciła
zapamiętane dane — zachowaniu powszechnym, ale nigdzie nie zapisanym. Teraz to
serwer decyduje, czy dana przeglądarka jest w środku, i może to cofnąć.
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
1. wylogowanie = skasowanie ciasteczka. Natychmiastowe, bez magazynu.
2. zmiana hasła albo skasowanie konta = odcisk poświadczenia wpisany
w ciasteczko przestaje pasować. Dzieje się SAMO, bez pamiętania o tym.
Bez tego odebranie komuś dostępu nie odbierałoby dostępu aż do wygaśnięcia.
3. „zamknij sesje" z ekranu kont = licznik pokolenia. Jedyny wymagający zapisu,
więc jedyny opcjonalny: gdy licznika nie ma, poziomy 1 i 2 nadal działają.
KONTO ADMINISTRACYJNE ODSEPAROWANE. Sprawdzane pierwsze i BEZ DOTYKANIA pliku
kont, co daje dwie rzeczy naraz: konto z pliku o tym samym loginie nie przesłoni
administratora, a administrator zaloguje się także wtedy, gdy plik jest uszkodzony
— czyli w jedynej sytuacji, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
jego stanu w tym samym pliku dawałoby zakleszczenie: nie da się naprawić, bo nie
da się wejść. Jego odpowiednikiem „wyloguj zewsząd" jest zmiana APP_PASSWORD.
KLUCZ WYMAGANY, FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie
odróżniłaby ważnej sesji od podrobionej, więc nie wstaje — i lepiej przy starcie
niż przy pierwszym logowaniu człowieka. Losowanie klucza byłoby wygodne, ale
wylogowywałoby wszystkich przy każdym restarcie poda: wygląda jak awaria i uczy
ludzi ignorować ekran logowania.
Ciasteczko HttpOnly (jeden wstrzyknięty skrypt inaczej wynosi sesję) i
SameSite=Strict (obca strona nie zadziała w imieniu zalogowanego). Wylogowanie
POST-em, nie odsyłaczem: pod adresem GET wystarczyłby obrazek na obcej stronie.
Adres powrotu po zalogowaniu przechodzi przez sito — bez tego `?dokad=https://obcy`
zamieniłby nasz ekran logowania w narzędzie do wyłudzania haseł.
Kres bezczynności 8 h i twardy 30 dni. Znacznik aktywności odświeżany z progiem,
inaczej Set-Cookie leciałby przy każdym obrazku i arkuszu stylów.
Zdarzenia logowania w dzienniku (PRE-17). Nieudane próby są tam ważniejsze od
udanych: pojedyncza nic nie znaczy, ale seria pod jednym adresem to jedyny
widoczny ślad zgadywania haseł. Test pilnuje, że hasło tam nie trafia.
Sprawdzone w przeglądarce: po zalogowaniu ciasteczko jest NIEWIDOCZNE dla
JavaScriptu, a po wylogowaniu wejście na chronioną stronę ląduje na ekranie
logowania — czyli dokładnie to, czego Basic nie potrafił.
Testy: 21 na rdzeń podpisywania (w tym podrabianie ładunku, podpisu i klucza,
ciasteczko z przyszłości, śmieci na wejściu), reszta przepisana z Basic na sesje.
Prezentacja 338 zielonych.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
554 lines
25 KiB
Python
554 lines
25 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 os
|
|
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")
|
|
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
|
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http # 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=""):
|
|
"""Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym.
|
|
|
|
Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma
|
|
nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne
|
|
i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań —
|
|
sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie."""
|
|
from app import security, session
|
|
|
|
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
|
|
|
|
|
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.verify("szef", "tajne-szefa")
|
|
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.verify("ala", "hasło-ali")
|
|
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.verify("szef", "tajne-szefa")
|
|
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.verify("ala", "złe") is None
|
|
assert security.verify("nie-ma-takiego", "cokolwiek") 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 ubija też OTWARTĄ sesję — bez tego odebranie dostępu
|
|
# działałoby dopiero po jej wygaśnięciu
|
|
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
|
assert c.get("/", headers=her, follow_redirects=False).status_code == 303
|
|
|
|
|
|
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.
|
|
#
|
|
# AWARIĘ WYMUSZAMY PODMIANĄ, NIE PRAWAMI PLIKU. Pierwsza wersja robiła chmod 000
|
|
# i chmod 555 — i przechodziła lokalnie, a padała w CI, bo CI działa jako ROOT,
|
|
# a root omija bity uprawnień w Linuksie. Test zależny od tego, kto go uruchamia,
|
|
# jest gorszy niż jego brak: daje fałszywe poczucie pokrycia i zapala się
|
|
# w miejscu, które z badaną rzeczą nie ma nic wspólnego.
|
|
#
|
|
# Podmiana jest przy okazji CELNIEJSZA: sprawdzamy dokładnie ten punkt, w którym
|
|
# system plików mówi „nie", zamiast liczyć, że da się go do tego zmusić.
|
|
|
|
def _fail_reading_accounts(monkeypatch, exc=PermissionError(13, "Permission denied")):
|
|
"""Odczyt PLIKU KONT wywala się; wszystko inne (szablony!) czyta normalnie."""
|
|
import builtins
|
|
|
|
from app import accounts as store
|
|
|
|
real_open, target = builtins.open, store.store_path()
|
|
|
|
def guarded(file, *a, **kw):
|
|
if str(file) == target:
|
|
raise exc
|
|
return real_open(file, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "open", guarded)
|
|
|
|
|
|
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "stan" / "accounts.json"))
|
|
_fail_reading_accounts(monkeypatch)
|
|
|
|
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 "accounts.json" in r.text, "komunikat ma podać ŚCIEŻKĘ"
|
|
|
|
|
|
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
|
|
"""Podmieniamy mkstemp, bo to ON wywala się PIERWSZY przy katalogu tylko do
|
|
odczytu — jeszcze zanim dojdzie do zapisu i podmiany pliku. Obudowanie samego
|
|
os.replace niczego by nie dało i właśnie tego dotyczyła poprawka."""
|
|
import tempfile
|
|
|
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "stan" / "accounts.json"))
|
|
monkeypatch.setattr(tempfile, "mkstemp",
|
|
lambda *a, **kw: (_ for _ in ()).throw(
|
|
PermissionError(13, "Read-only file system")))
|
|
|
|
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"])
|
|
|
|
|
|
@pytest.mark.skipif(os.geteuid() == 0,
|
|
reason="root omija bity uprawnień — sprawdzian nic by nie znaczył")
|
|
def test_real_file_permissions_produce_the_same_message(env, tmp_path, monkeypatch):
|
|
"""Kontrola, że podmiana wyżej odpowiada RZECZYWISTOŚCI, a nie tylko sama
|
|
sobie. Pomijana przy uruchomieniu z roota — czyli w CI."""
|
|
d = tmp_path / "stan"
|
|
d.mkdir()
|
|
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
|
|
d.chmod(0o000)
|
|
try:
|
|
c = _client(monkeypatch)
|
|
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
|
|
assert r.status_code == 200
|
|
assert "Nie mogę odczytać pliku kont" in r.text
|
|
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"
|
|
|
|
|
|
# ── logowanie i wylogowanie (LOG-34) ─────────────────────────────────────
|
|
# Sesja zastąpiła HTTP Basic. Basic nie miał wylogowania, bo przeglądarka
|
|
# dosyłała hasło sama przy każdym żądaniu; teraz to serwer decyduje, czy dana
|
|
# przeglądarka jest w środku, i może to cofnąć.
|
|
|
|
def test_logging_in_hands_out_a_session_and_takes_you_where_you_were_going(env, monkeypatch):
|
|
store.create("ala", "hasło-ali", ["chart"])
|
|
c = _client(monkeypatch)
|
|
r = c.post("/logowanie", follow_redirects=False,
|
|
data={"login": "ala", "haslo": "hasło-ali", "dokad": "/"})
|
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
|
from app import session
|
|
|
|
assert session.COOKIE in r.cookies, "nie wydano ciasteczka sesji"
|
|
|
|
|
|
def test_a_bad_password_says_the_same_thing_as_an_unknown_login(env, monkeypatch):
|
|
"""Rozróżnienie mówiłoby, które konta istnieją."""
|
|
store.create("ala", "hasło-ali", ["chart"])
|
|
c = _client(monkeypatch)
|
|
zle = c.post("/logowanie", data={"login": "ala", "haslo": "złe"})
|
|
obcy = c.post("/logowanie", data={"login": "nie-ma-takiego", "haslo": "cokolwiek"})
|
|
assert zle.status_code == obcy.status_code == 401
|
|
# Odpowiedzi różnią się WYŁĄCZNIE odbitym polem loginu — czyli tym, co sam
|
|
# wpisał pytający. Wszystko poza tym musi być identyczne, inaczej dałoby się
|
|
# wyliczyć, które konta istnieją.
|
|
bez_loginu = lambda t, login: t.replace(f'value="{login}"', 'value="X"')
|
|
assert "Nieprawidłowy login lub hasło" in zle.text
|
|
assert bez_loginu(zle.text, "ala") == bez_loginu(obcy.text, "nie-ma-takiego")
|
|
|
|
|
|
def test_logging_out_drops_the_session(env, monkeypatch):
|
|
"""Sedno całej zmiany: po wylogowaniu przeglądarka nie ma już czego dosyłać."""
|
|
store.create("ala", "x", ["chart"])
|
|
c = _client(monkeypatch)
|
|
assert c.get("/", headers=_auth("ala"), follow_redirects=False).status_code == 200
|
|
|
|
r = c.post("/wyloguj", headers=_auth("ala"), follow_redirects=False)
|
|
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
|
# Ciasteczko skasowane: przeglądarka dostaje puste z natychmiastowym kresem.
|
|
from app import session
|
|
|
|
ustawione = r.headers.get("set-cookie", "")
|
|
assert session.COOKIE in ustawione
|
|
assert "Max-Age=0" in ustawione or 'expires=Thu, 01 Jan 1970' in ustawione.lower()
|
|
|
|
|
|
def test_logging_out_is_a_post_not_a_link(env, monkeypatch):
|
|
"""Pod adresem GET wystarczyłby obrazek na obcej stronie, żeby wylogować
|
|
kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa do uniknięcia."""
|
|
c = _client(monkeypatch)
|
|
assert c.get("/wyloguj", headers=_auth("szef"),
|
|
follow_redirects=False).status_code == 405
|
|
|
|
|
|
def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch):
|
|
"""Bez pokazania, KTO jest zalogowany, przycisk wyjścia jest w połowie
|
|
bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje."""
|
|
store.create("ala", "x", ["chart", "significators"])
|
|
c = _client(monkeypatch)
|
|
for kto in ("szef", "ala"):
|
|
html = c.get("/", headers=_auth(kto)).text
|
|
assert 'action="/wyloguj"' in html, f"{kto}: brak wyjścia"
|
|
assert kto in html, f"{kto}: nie widać, kto jest zalogowany"
|
|
|
|
|
|
def test_no_logout_button_when_authentication_is_off(env, monkeypatch):
|
|
"""Bez logowania nie ma z czego wychodzić, a przycisk sugerowałby ochronę,
|
|
której nie ma."""
|
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
|
monkeypatch.delenv("APP_USERS", raising=False)
|
|
c = _client(monkeypatch)
|
|
assert 'action="/wyloguj"' not in c.get("/").text
|
|
|
|
|
|
def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch):
|
|
"""Konto bez ani jednego ekranu też ma prawo wyjść."""
|
|
store.create("bez", "x", [])
|
|
c = _client(monkeypatch)
|
|
assert c.post("/wyloguj", headers=_auth("bez"),
|
|
follow_redirects=False).status_code == 303
|
|
|
|
|
|
def test_the_login_screen_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
|
"""`?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie do
|
|
odsyłania ludzi gdzie indziej — droga do wyłudzenia hasła, bo odsyłacz
|
|
prowadzi z zaufanego adresu."""
|
|
c = _client(monkeypatch)
|
|
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x", "\\\\obcy.pl"):
|
|
r = c.post("/logowanie", follow_redirects=False,
|
|
data={"login": "szef", "haslo": "tajne-szefa", "dokad": zly})
|
|
assert r.status_code == 303
|
|
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
|
|
|
|
|
def test_changing_the_password_kills_open_sessions(env, monkeypatch):
|
|
"""Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej
|
|
odebranie dostępu nie odbiera dostępu aż do wygaśnięcia."""
|
|
store.create("ala", "stare", ["chart"])
|
|
c = _client(monkeypatch)
|
|
ciasteczko = _auth("ala")
|
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
|
|
|
store.update("ala", password="nowe")
|
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
|
|
|
|
|
def test_the_administrator_can_close_all_sessions_of_an_account(env, monkeypatch):
|
|
"""„Wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił
|
|
zalogowany komputer."""
|
|
store.create("ala", "x", ["chart"])
|
|
c = _client(monkeypatch)
|
|
ciasteczko = _auth("ala")
|
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
|
|
|
c.post("/accounts/sesje", headers=_auth("szef"), follow_redirects=False,
|
|
data={"login": "ala"})
|
|
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
|
# ...a hasło dalej działa
|
|
assert security.verify("ala", "x") is not None
|
|
|
|
|
|
def test_the_administrator_gets_in_even_when_the_accounts_file_is_broken(env, monkeypatch):
|
|
"""Jedyna sytuacja, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie
|
|
stanu administratora w tym samym pliku dawałoby zakleszczenie."""
|
|
import pathlib as _p
|
|
|
|
_p.Path(store.store_path()).write_text("{to nie jest json", encoding="utf-8")
|
|
c = _client(monkeypatch)
|
|
assert c.get("/accounts", headers=_auth("szef")).status_code == 200
|
|
assert security.verify("szef", "tajne-szefa") is not None
|