Files
gitea 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
feat(bezpieczeństwo): sesje logowania zamiast HTTP Basic (LOG-34)
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>
2026-08-21 00:18:15 +02:00

224 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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