Files
astrololo/services/presentation/tests/test_pliki.py
T
gitea 6466ab89a9
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
feat(dane): interfejs zarządzania plikami baz — trzy poziomy dostępu (DAN-27)
Ekran „Pliki" z trzema poziomami, wpiętymi w kontrolę dostępu z PRE-27:

  „files"        widzi listę i KLIKANIEM decyduje, z których baz program korzysta,
  „files_input"  dokłada wgrywanie i ARCHIWIZACJĘ,
  administrator  kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.

STAN JEST TERAZ TRWAŁY. DAN-15 trzymał go w zmiennej DISABLED_BASES, bo warstwa
danych nie miała gdzie zapisywać — udział był montowany read-only. Skoro stan ma
być klikany, musi przetrwać restart, więc udział jest zapisywalny, a stan leży
w pliku obok baz (zapis atomowy: plik opisuje CAŁY zbiór, więc obcięcie w połowie
skasowałoby wiedzę o wszystkich naraz). DISABLED_BASES zostaje jako awaryjne
wyłączenie z konfiguracji i odsiewa DODATKOWO — nie odwrotnie, bo inaczej ktoś
z dostępem do ekranu włączyłby bazę wyłączoną świadomie na poziomie wdrożenia.

ARCHIWIZACJA NIE KASUJE. Plik zostaje na dysku, zamrożony, ze znacznikiem czasu;
znika wyłącznie z użytku. To najdalej idąca operacja osoby wgrywającej dane —
kasować może tylko administrator. Test sprawdza, że plik po archiwizacji nadal
istnieje, bo to jest cała istota tej operacji.

WALIDACJA JEST BRAMKĄ DO UŻYTKU, NIE FILTREM NA WEJŚCIU. Plik wgrany zostaje
NIEZALEŻNIE od wyniku — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to,
czy da się go włączyć. Sprawdzenie biegnie też w chwili włączania, nie tylko przy
wgrywaniu: reguły mogą się zmienić po fakcie.

O WALIDACJI WIE TYLKO ADMINISTRATOR. Pliki wstrzymane są odsiewane W WARSTWIE
DANYCH przy for_admin=False, a nie ukrywane w szablonie — gdyby dochodziły do
przeglądarki, wystarczyłby podgląd źródła, żeby poznać reguły. Odmowa włączenia
wraca do konta bez uprawnień BEZ POWODU, bo powód zdradza regułę. Sekcja reguł
nie trafia nawet do źródła strony. Test parametryzowany po obu niższych poziomach
szuka w odpowiedzi śladów mechanizmu i wymaga, żeby żadnego nie było.

Każdy plik ma policzony sha256 — tożsamość niezależna od nazwy. Wykorzystuje ją
już odrzucanie duplikatów, a w kroku drugim posłuży do pilnowania zgodności
lustra w SQL.

Przy okazji naprawiony błąd, który dopiero co bym wprowadził: Path("") to
Path("."), czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna`
zawsze wybierało pustą zmienną i zapisywało stan do katalogu bieżącego.

Wymaga zapisywalnego udziału — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:39:42 +02:00

216 lines
10 KiB
Python
Raw 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")
return tmp_path
def _auth(user, password):
import base64
return {"Authorization": "Basic " + base64.b64encode(
f"{user}:{password}".encode()).decode()}
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