"""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