feat(dane): interfejs zarządzania plikami baz — trzy poziomy dostępu (DAN-27)
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

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>
This commit is contained in:
2026-08-07 14:39:42 +02:00
parent a833965909
commit 6466ab89a9
14 changed files with 1232 additions and 10 deletions
+321
View File
@@ -0,0 +1,321 @@
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
STANY PLIKU
active — bierze udział w wyszukiwaniu,
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
osobie wgrywającej dane,
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
czy go skasować, podejmuje wyłącznie administrator.
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
restart tak samo.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
USABLE = frozenset({ACTIVE})
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
_lock = threading.Lock()
DEFAULT_RULES: dict = {
"extensions": [".xlsx"],
"max_size_mb": 50,
"min_rows": 1,
"required_columns": [], # puste = bez wymagań co do nagłówków
"reject_duplicate_content": True,
}
def state_path(root: Path | str) -> Path:
"""Plik stanu — obok baz, chyba że wskazano inaczej.
Sprawdzamy NAPIS ze środowiska, nie Path(napis): 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."""
override = os.getenv("FILES_STATE", "").strip()
return Path(override) if override else Path(root) / ".files-state.json"
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def sha256_of(path: Path | str) -> str:
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
na dysku rozjechał się z tym, co wczytano do bazy."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
# ── stan ─────────────────────────────────────────────────────────────────
def _read_state(root: Path) -> dict:
try:
with open(state_path(root), encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
data = {}
files = data.get("files")
rules = data.get("rules")
return {
"files": files if isinstance(files, dict) else {},
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
}
def _write_state(root: Path, data: dict) -> None:
path = state_path(root)
path.parent.mkdir(parents=True, exist_ok=True)
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
# zapisu skasowałoby wiedzę o wszystkich naraz.
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
def rules(root: Path | str) -> dict:
return _read_state(Path(root))["rules"]
def set_rules(root: Path | str, new: dict) -> dict:
root = Path(root)
with _lock:
data = _read_state(root)
merged = {**data["rules"]}
for key, value in (new or {}).items():
if key in DEFAULT_RULES:
merged[key] = value
data["rules"] = merged
_write_state(root, data)
return merged
# ── walidacja ────────────────────────────────────────────────────────────
def validate(path: Path | str, root: Path | str, *, digest: str = "",
known_digests: dict[str, str] | None = None) -> list[str]:
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
administracyjne ta lista nie wychodzi."""
p, rs = Path(path), rules(root)
why: list[str] = []
exts = [str(e).lower() for e in rs.get("extensions") or []]
if exts and p.suffix.lower() not in exts:
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
try:
size_mb = p.stat().st_size / (1024 * 1024)
except OSError:
return why + ["pliku nie da się odczytać"]
cap = float(rs.get("max_size_mb") or 0)
if cap and size_mb > cap:
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
if rs.get("reject_duplicate_content") and known_digests:
digest = digest or sha256_of(p)
twin = next((name for name, d in known_digests.items()
if d == digest and name != p.name), None)
if twin:
why.append(f"treść identyczna z plikiem „{twin}")
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
min_rows = int(rs.get("min_rows") or 0)
if required or min_rows:
why += _inspect_workbook(p, required, min_rows)
return why
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
"""Zagląda do arkusza: nagłówki i liczba wierszy.
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
try:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
except Exception as e: # noqa: BLE001 — każdy błąd = powód
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
why: list[str] = []
try:
ws = wb[wb.sheetnames[0]]
rows = ws.iter_rows(values_only=True)
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
missing = [c for c in required if c.strip().lower() not in header]
if missing:
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
if min_rows:
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
if seen < min_rows:
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
finally:
wb.close()
return why
# ── rejestr ──────────────────────────────────────────────────────────────
def _scan(root: Path) -> list[Path]:
return [p for p in sorted(root.glob("**/*"))
if p.is_file() and not p.name.startswith((".", "~$"))]
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
źródła strony, żeby poznać reguły walidacji."""
root = Path(root)
data = _read_state(root)
out: list[dict] = []
for p in _scan(root):
rel = str(p.relative_to(root))
row = data["files"].get(rel, {})
status = row.get("status") or READY
if status == QUARANTINE and not for_admin:
continue
try:
st = p.stat()
size_mb = round(st.st_size / (1024 * 1024), 2)
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
except OSError:
size_mb, modified = None, None
entry = {
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
"status": status, "in_use": status in USABLE,
"archived_at": row.get("archived_at") or "",
"uploaded_at": row.get("uploaded_at") or "",
"uploaded_by": row.get("uploaded_by") or "",
"sha256": row.get("sha256") or "",
}
if for_admin:
entry["rejected_for"] = list(row.get("rejected_for") or [])
out.append(entry)
return out
def usable_paths(root: Path | str) -> list[str]:
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
root = Path(root)
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
def _touch(root: Path, rel: str, **fields) -> dict:
with _lock:
data = _read_state(root)
row = {**data["files"].get(rel, {}), **fields}
data["files"][rel] = row
_write_state(root, data)
return row
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
root = Path(root)
target = root / rel
if not target.is_file():
raise ValueError(f"Nie ma pliku „{rel}”.")
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
raise ValueError(f"Nieznany stan: {status}")
data = _read_state(root)
current = (data["files"].get(rel) or {}).get("status") or READY
if status == ACTIVE:
if current == QUARANTINE:
raise ValueError("Plik nie może trafić do użytku.")
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
why = validate(target, root, known_digests=known)
if why:
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
raise ValueError("Plik nie może trafić do użytku.")
fields = {"status": status, "changed_at": _now(), "changed_by": by}
if status == ARCHIVED:
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
fields["archived_at"] = _now()
elif status == ACTIVE:
fields["archived_at"] = ""
fields["rejected_for"] = []
return _touch(root, rel, **fields)
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
"""Zapisuje wgrany plik i od razu go sprawdza.
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
root = Path(root)
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
target = root / safe
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
while target.exists(): # nie nadpisujemy cudzej bazy
target = root / f"{stem}-{n}{suffix}"
n += 1
root.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
rel = str(target.relative_to(root))
digest = sha256_of(target)
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
if e["sha256"] and e["path"] != rel}
why = validate(target, root, digest=digest, known_digests=known)
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
return {"path": rel, "name": target.name, "accepted": not why}
def delete(root: Path | str, rel: str) -> None:
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
root = Path(root)
target = root / rel
if not target.is_file():
raise ValueError(f"Nie ma pliku „{rel}”.")
target.unlink()
with _lock:
data = _read_state(root)
data["files"].pop(rel, None)
_write_state(root, data)
+77 -2
View File
@@ -7,13 +7,16 @@ ani prezentacji.
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import base64
import binascii
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from app import canary, link_crypto, security
from app import canary, files, link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from pydantic import BaseModel
from app.providers.factory import build_provider
provider = build_provider(settings)
@@ -54,6 +57,78 @@ def bases() -> dict:
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
# z nią wyłącznie warstwa logiczna.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
root = settings.excel_dir
return {"files": files.registry(root, for_admin=q.for_admin),
"rules": files.rules(root) if q.for_admin else {},
"root": str(root)}
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"path": a.path, "status": row.get("status")}
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
byłby obejściem PRE-16."""
try:
raw = base64.b64decode(u.content_b64, validate=True)
except (binascii.Error, ValueError) as e:
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
try:
files.delete(settings.excel_dir, a.path)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"deleted": a.path}
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+17 -7
View File
@@ -92,20 +92,30 @@ class ExcelDataProvider(DataProvider):
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
w interpretacji, choć fizycznie leżą na udziale."""
from app import bases
"""Bazy biorące udział w wyszukiwaniu.
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
wyłączoną świadomie na poziomie wdrożenia.
"""
from app import files
usable = set(files.usable_paths(self.s.excel_dir))
out = [p for p in paths if p in usable]
entries = bases.disabled_entries()
if not entries:
return paths
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
if entries:
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
return out
def list_bases(self) -> list[dict]:
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
from app import bases
return bases.list_bases(self.s.excel_dir, self._excel_files())
from app import files
return files.registry(self.s.excel_dir, for_admin=True)
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
+162
View File
@@ -0,0 +1,162 @@
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
walidacji" faktycznie trzymała.
"""
import pathlib
import pytest
from app import files
@pytest.fixture()
def root(tmp_path, monkeypatch):
monkeypatch.delenv("FILES_STATE", raising=False)
return tmp_path
def _xlsx(path, rows=3, header=("id", "opis")):
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(list(header))
for i in range(rows):
ws.append([i, f"treść {i}"])
wb.save(path)
return path
# ── rejestr i stany ──────────────────────────────────────────────────────
def test_new_file_is_visible_but_not_in_use(root):
_xlsx(root / "baza.xlsx")
entry = files.registry(root)[0]
assert entry["status"] == files.READY
assert entry["in_use"] is False, "nowy plik nie może sam wejść do wyszukiwania"
def test_only_active_files_reach_the_search(root):
_xlsx(root / "a.xlsx")
_xlsx(root / "b.xlsx")
assert files.usable_paths(root) == []
files.set_status(root, "a.xlsx", files.ACTIVE)
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
def test_state_survives_a_restart(root):
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
przywracałby bazy wyłączone świadomie."""
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
assert files.state_path(root).exists()
assert files.registry(root)[0]["in_use"] is True
# ── archiwizacja ─────────────────────────────────────────────────────────
def test_archiving_freezes_the_file_but_never_removes_it(root):
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
p = _xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ACTIVE)
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
entry = files.registry(root)[0]
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
assert entry["status"] == files.ARCHIVED
assert entry["in_use"] is False
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
_xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ARCHIVED)
assert files.usable_paths(root) == []
# ── walidacja: bramka do użytku ──────────────────────────────────────────
def test_upload_keeps_a_file_that_fails_validation(root):
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
files.set_rules(root, {"extensions": [".xlsx"]})
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
assert out["accepted"] is False
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
admin_view = files.registry(root, for_admin=True)[0]
assert admin_view["status"] == files.QUARANTINE
assert admin_view["rejected_for"], "administrator ma widzieć powód"
def test_a_held_file_is_invisible_without_admin_rights(root):
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora."""
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
assert files.registry(root, for_admin=False) == []
assert len(files.registry(root, for_admin=True)) == 1
def test_a_held_file_cannot_be_switched_into_use(root):
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
rel = files.registry(root, for_admin=True)[0]["path"]
with pytest.raises(ValueError):
files.set_status(root, rel, files.ACTIVE)
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
a nie tylko przy wgrywaniu."""
_xlsx(root / "mala.xlsx", rows=2)
files.set_status(root, "mala.xlsx", files.ACTIVE)
files.set_rules(root, {"min_rows": 500})
files.set_status(root, "mala.xlsx", files.READY)
with pytest.raises(ValueError):
files.set_status(root, "mala.xlsx", files.ACTIVE)
assert (root / "mala.xlsx").exists()
@pytest.mark.parametrize("rule,value,bad", [
("extensions", [".xlsx"], "plik.csv"),
("max_size_mb", 0.000001, "plik.xlsx"),
])
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
files.set_rules(root, {rule: value})
out = files.store_upload(root, bad, b"x" * 2048)
assert out["accepted"] is False
def test_required_columns_are_checked_inside_the_workbook(root):
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
why = files.validate(root / "tmp.xlsx", root)
assert why and "znaczenie" in why[0]
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
files.set_rules(root, {"reject_duplicate_content": True})
data = _xlsx(root / "wzor.xlsx").read_bytes()
first = files.store_upload(root, "pierwsza.xlsx", data)
assert first["accepted"] is True
second = files.store_upload(root, "inna-nazwa.xlsx", data)
assert second["accepted"] is False
def test_upload_never_overwrites_someone_elses_base(root):
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
(root / "w.xlsx").unlink()
files.set_rules(root, {"reject_duplicate_content": False})
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
assert out["name"] != "baza.xlsx"
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
# ── kasowanie ────────────────────────────────────────────────────────────
def test_delete_removes_the_file_and_its_entry(root):
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
files.delete(root, "a.xlsx")
assert not (root / "a.xlsx").exists()
assert files.registry(root, for_admin=True) == []
assert files.usable_paths(root) == []