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>
224 lines
11 KiB
Python
224 lines
11 KiB
Python
"""Trzy poziomy dostępu do plików baz (DAN-27 × PRE-27).
|
||
|
||
user („files") — widzi listę i decyduje, z czego program korzysta,
|
||
data_input (+„files_input")— dokłada wgrywanie i ARCHIWIZACJĘ,
|
||
administrator — kasowanie, przywracanie i REGUŁY WALIDACJI.
|
||
|
||
Osobno pilnujemy własności negatywnej: poza administratorem NIKT nie ma skąd
|
||
wiedzieć, że walidacja w ogóle istnieje. To łatwo zepsuć dobrą intencją —
|
||
„pokażmy człowiekowi, czemu plik nie przeszedł" — więc testy są wprost o tym.
|
||
"""
|
||
import pytest
|
||
|
||
from app import accounts as store
|
||
from app import features
|
||
|
||
|
||
@pytest.fixture()
|
||
def env(tmp_path, monkeypatch):
|
||
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
|
||
return tmp_path
|
||
|
||
|
||
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)}"}
|
||
|
||
|
||
REGISTRY = [
|
||
{"name": "glowna.xlsx", "path": "glowna.xlsx", "size_mb": 1.0, "modified": "2026-01-01",
|
||
"status": "active", "in_use": True, "archived_at": "", "uploaded_at": "",
|
||
"uploaded_by": "", "sha256": "abc"},
|
||
{"name": "stara.xlsx", "path": "stara.xlsx", "size_mb": 2.0, "modified": "2025-01-01",
|
||
"status": "archived", "in_use": False, "archived_at": "2026-02-02T10:00:00+00:00",
|
||
"uploaded_at": "", "uploaded_by": "", "sha256": "def"},
|
||
]
|
||
HELD = {"name": "podejrzana.xlsx", "path": "podejrzana.xlsx", "size_mb": 0.1,
|
||
"modified": "2026-03-03", "status": "quarantine", "in_use": False,
|
||
"archived_at": "", "uploaded_at": "2026-03-03T09:00:00+00:00",
|
||
"uploaded_by": "dane", "sha256": "ghi",
|
||
"rejected_for": ["brak wymaganych kolumn: znaczenie"]}
|
||
RULES = {"extensions": [".xlsx"], "max_size_mb": 50, "min_rows": 1,
|
||
"required_columns": ["id", "znaczenie"], "reject_duplicate_content": True}
|
||
|
||
|
||
def _client(monkeypatch, calls=None):
|
||
"""Klient z zastubowaną warstwą logiczną. `calls` zbiera to, co poszło w dół."""
|
||
from starlette.testclient import TestClient
|
||
|
||
from app.main import app, logic
|
||
|
||
calls = calls if calls is not None else []
|
||
|
||
def files_list(for_admin=False):
|
||
rows = list(REGISTRY) + ([HELD] if for_admin else [])
|
||
return {"files": rows, "rules": RULES if for_admin else {}, "root": "/x"}
|
||
|
||
monkeypatch.setattr(logic, "files_list", files_list)
|
||
for name in ("files_status", "files_upload", "files_delete", "files_rules"):
|
||
monkeypatch.setattr(logic, name,
|
||
lambda *a, _n=name, **kw: (calls.append((_n, a, kw)),
|
||
{"accepted": True, "name": "x.xlsx"})[1])
|
||
return TestClient(app), calls
|
||
|
||
|
||
# ── poziom „user" ────────────────────────────────────────────────────────
|
||
|
||
def test_user_sees_the_list_and_can_switch_bases_on_and_off(env, monkeypatch):
|
||
store.create("ula", "x", ["files"])
|
||
c, calls = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("ula", "x")).text
|
||
assert "glowna.xlsx" in html and "stara.xlsx" in html
|
||
|
||
r = c.post("/files/use", headers=_auth("ula", "x"), follow_redirects=False,
|
||
data={"path": "glowna.xlsx", "use": "0"})
|
||
assert r.status_code == 303
|
||
assert calls and calls[0][0] == "files_status"
|
||
|
||
|
||
def test_user_gets_no_upload_no_archive_no_delete(env, monkeypatch):
|
||
store.create("ula", "x", ["files"])
|
||
c, _ = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("ula", "x")).text
|
||
for absent in ("/files/upload", "/files/archive", "/files/delete",
|
||
"/files/restore", "/files/rules"):
|
||
assert absent not in html, f"strona zdradza {absent}"
|
||
for path in ("/files/upload", "/files/archive", "/files/delete",
|
||
"/files/restore", "/files/rules"):
|
||
r = c.post(path, headers=_auth("ula", "x"), data={"path": "glowna.xlsx"})
|
||
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||
|
||
|
||
# ── poziom „data_input" ──────────────────────────────────────────────────
|
||
|
||
def test_data_input_can_upload_and_archive(env, monkeypatch):
|
||
store.create("dane", "x", ["files", "files_input"])
|
||
c, calls = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("dane", "x")).text
|
||
assert "/files/upload" in html and "/files/archive" in html
|
||
|
||
r = c.post("/files/upload", headers=_auth("dane", "x"), follow_redirects=False,
|
||
files={"upload": ("nowa.xlsx", b"zawartosc", "application/vnd.ms-excel")})
|
||
assert r.status_code == 303 and calls[-1][0] == "files_upload"
|
||
|
||
r = c.post("/files/archive", headers=_auth("dane", "x"), follow_redirects=False,
|
||
data={"path": "glowna.xlsx"})
|
||
assert r.status_code == 303
|
||
assert calls[-1][1][1] == "archived", "archiwizacja ma ustawiać stan `archived`"
|
||
|
||
|
||
def test_data_input_cannot_delete_restore_or_set_rules(env, monkeypatch):
|
||
"""Wymóg wprost: osoba wgrywająca dane może CO NAJWYŻEJ zarchiwizować."""
|
||
store.create("dane", "x", ["files", "files_input"])
|
||
c, _ = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("dane", "x")).text
|
||
for absent in ("/files/delete", "/files/restore", "/files/rules"):
|
||
assert absent not in html, f"strona zdradza {absent}"
|
||
for path in ("/files/delete", "/files/restore", "/files/rules"):
|
||
assert c.post(path, headers=_auth("dane", "x"),
|
||
data={"path": "glowna.xlsx"}).status_code == 404
|
||
|
||
|
||
# ── własność negatywna: walidacja jest tajemnicą administratora ─────────
|
||
|
||
@pytest.mark.parametrize("perms", [["files"], ["files", "files_input"]])
|
||
def test_nobody_below_admin_learns_that_validation_exists(env, monkeypatch, perms):
|
||
store.create("ktos", "x", perms)
|
||
c, _ = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("ktos", "x")).text
|
||
assert "podejrzana.xlsx" not in html, "plik wstrzymany nie ma prawa się pokazać"
|
||
for leak in ("walidacj", "Walidacj", "reguł", "Reguł", "znaczenie",
|
||
"brak wymaganych kolumn", "rozszerzeni", "Maksymalny rozmiar"):
|
||
assert leak not in html, f"strona zdradza mechanizm: „{leak}”"
|
||
|
||
|
||
def test_refusal_to_activate_gives_no_reason_below_admin(env, monkeypatch):
|
||
"""Powód odmowy zdradzałby regułę. Komunikat ma być bez treści."""
|
||
import httpx
|
||
|
||
from app.main import app, logic
|
||
from starlette.testclient import TestClient
|
||
|
||
store.create("ula", "x", ["files"])
|
||
|
||
def boom(*a, **kw):
|
||
raise httpx.HTTPStatusError(
|
||
"422", request=httpx.Request("POST", "http://x"),
|
||
response=httpx.Response(422, json={"detail": "brak wymaganych kolumn: znaczenie"}))
|
||
|
||
monkeypatch.setattr(logic, "files_list", lambda for_admin=False: {"files": [], "rules": {}})
|
||
monkeypatch.setattr(logic, "files_status", boom)
|
||
r = TestClient(app).post("/files/use", headers=_auth("ula", "x"),
|
||
follow_redirects=False, data={"path": "x.xlsx", "use": "1"})
|
||
assert r.status_code == 303
|
||
assert "znaczenie" not in r.headers["location"]
|
||
assert "kolumn" not in r.headers["location"]
|
||
|
||
|
||
# ── administrator ────────────────────────────────────────────────────────
|
||
|
||
def test_admin_sees_held_files_with_the_reason_and_the_rules(env, monkeypatch):
|
||
c, _ = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("szef", "tajne-szefa")).text
|
||
assert "podejrzana.xlsx" in html
|
||
assert "brak wymaganych kolumn: znaczenie" in html
|
||
assert "Reguły przyjmowania baz" in html
|
||
for op in ("/files/delete", "/files/restore", "/files/rules",
|
||
"/files/upload", "/files/archive"):
|
||
assert op in html, f"administratorowi brakuje {op}"
|
||
|
||
|
||
def test_admin_can_delete_restore_and_change_the_rules(env, monkeypatch):
|
||
c, calls = _client(monkeypatch)
|
||
admin = _auth("szef", "tajne-szefa")
|
||
c.post("/files/delete", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
|
||
c.post("/files/restore", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
|
||
c.post("/files/rules", headers=admin, follow_redirects=False,
|
||
data={"extensions": ".xlsx, .xlsm", "max_size_mb": "10", "min_rows": "5",
|
||
"required_columns": "id, znaczenie", "reject_duplicate_content": "1"})
|
||
names = [c[0] for c in calls]
|
||
assert names == ["files_delete", "files_status", "files_rules"]
|
||
sent = calls[-1][1][0]
|
||
assert sent["extensions"] == [".xlsx", ".xlsm"]
|
||
assert sent["required_columns"] == ["id", "znaczenie"]
|
||
assert sent["reject_duplicate_content"] is True
|
||
|
||
|
||
def test_archived_file_cannot_be_switched_on_from_the_page(env, monkeypatch):
|
||
"""Archiwum jest ZAMROŻONE: przełącznik przy nim w ogóle się nie pojawia,
|
||
a przywrócić może wyłącznie administrator."""
|
||
store.create("dane", "x", ["files", "files_input"])
|
||
c, _ = _client(monkeypatch)
|
||
html = c.get("/files", headers=_auth("dane", "x")).text
|
||
row = html[html.index("stara.xlsx") - 700:html.index("stara.xlsx")]
|
||
assert "/files/use" not in row, "zarchiwizowana baza ma przełącznik użycia"
|
||
|
||
|
||
def test_files_tab_is_hidden_without_the_permission(env, monkeypatch):
|
||
store.create("bez", "x", ["chart"])
|
||
c, _ = _client(monkeypatch)
|
||
html = c.get("/", headers=_auth("bez", "x")).text
|
||
assert 'href="/files"' not in html and "Pliki" not in html
|
||
assert c.get("/files", headers=_auth("bez", "x")).status_code == 404
|
||
|
||
|
||
def test_route_map_covers_every_file_route():
|
||
for route in ("/files", "/files/use", "/files/upload", "/files/archive",
|
||
"/files/restore", "/files/delete", "/files/rules"):
|
||
method = "GET" if route == "/files" else "POST"
|
||
assert (method, route) in features.ROUTES, route
|