Release govoplan-files v0.1.26: speed archive workflows and unify file tools
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""Isolated synthetic archive benchmark; never reads the configured Files store.
|
||||
|
||||
Run from this repository: python tests/benchmark_archive_storage.py.
|
||||
Uses the existing temporary SQLite/local-storage recovery fixture so durable
|
||||
ledger, checksum verification, commit settlement and compensation stay enabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import cProfile
|
||||
from contextlib import ExitStack
|
||||
from collections import Counter
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pstats
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
import pyzipper
|
||||
from sqlalchemy import event
|
||||
|
||||
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
||||
from govoplan_files.backend.storage.archives import (
|
||||
extract_archive_upload,
|
||||
_read_selected_zip_members,
|
||||
)
|
||||
from govoplan_files.backend.storage.native_zip import native_zip_library
|
||||
from test_storage_recovery import StorageRecoveryTests
|
||||
|
||||
|
||||
def storage_probe(
|
||||
member_count: int,
|
||||
member_size: int,
|
||||
profile: bool,
|
||||
baseline_revision: str | None = None,
|
||||
):
|
||||
case = StorageRecoveryTests()
|
||||
case.setUp()
|
||||
try:
|
||||
register_files_change_tracking()
|
||||
data = BytesIO()
|
||||
rng = random.Random(20260907)
|
||||
with zipfile.ZipFile(data, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for index in range(member_count):
|
||||
archive.writestr(
|
||||
f"documents/{index:04d}.dat", rng.randbytes(member_size)
|
||||
)
|
||||
queries = []
|
||||
event.listen(
|
||||
case.engine,
|
||||
"before_cursor_execute",
|
||||
lambda conn, cursor, statement, parameters, context, executemany: (
|
||||
queries.append(statement)
|
||||
),
|
||||
)
|
||||
profiler = cProfile.Profile()
|
||||
if profile:
|
||||
profiler.enable()
|
||||
with ExitStack() as overrides:
|
||||
overrides.enter_context(
|
||||
patch(
|
||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||
return_value=case.backend,
|
||||
)
|
||||
)
|
||||
if baseline_revision:
|
||||
# Read an earlier local revision into this isolated process;
|
||||
# never replace shared worktree files or connect to live data.
|
||||
source = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"show",
|
||||
f"{baseline_revision}:src/govoplan_files/backend/storage/files.py",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
namespace = {"__name__": "govoplan_files.backend.storage.files"}
|
||||
exec(
|
||||
compile(source, f"{baseline_revision}:files.py", "exec"), namespace
|
||||
)
|
||||
namespace.update(
|
||||
get_storage_backend=lambda: case.backend,
|
||||
_storage_backend_name=lambda: case.backend.name,
|
||||
_storage_bucket_name=lambda: "",
|
||||
)
|
||||
overrides.enter_context(
|
||||
patch(
|
||||
"govoplan_files.backend.storage.archives.create_file_asset",
|
||||
namespace["create_file_asset"],
|
||||
)
|
||||
)
|
||||
started = time.perf_counter()
|
||||
result = extract_archive_upload(
|
||||
case.session,
|
||||
tenant_id="tenant-1",
|
||||
owner_type="user",
|
||||
owner_id="user-1",
|
||||
user_id="user-1",
|
||||
archive_data=data.getvalue(),
|
||||
filename="synthetic.zip",
|
||||
folder="imported",
|
||||
campaign_id=None,
|
||||
)
|
||||
storage_done = time.perf_counter()
|
||||
storage_queries = len(queries)
|
||||
case.session.commit()
|
||||
completed = time.perf_counter()
|
||||
if profile:
|
||||
profiler.disable()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"probe": "durable-storage",
|
||||
"storage_revision": baseline_revision or "working-tree",
|
||||
"members": len(result),
|
||||
"member_bytes": member_size,
|
||||
"extract_store_seconds": round(storage_done - started, 4),
|
||||
"settlement_seconds": round(completed - storage_done, 4),
|
||||
"total_seconds": round(completed - started, 4),
|
||||
"store_queries": storage_queries,
|
||||
"total_queries": len(queries),
|
||||
"queries_by_table": dict(
|
||||
Counter(
|
||||
(
|
||||
match.group(1)
|
||||
if (
|
||||
match := re.search(
|
||||
r"(?:FROM|INTO|UPDATE)\s+([a-z_]+)", statement
|
||||
)
|
||||
)
|
||||
else "other"
|
||||
)
|
||||
for statement in queries
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
if profile:
|
||||
pstats.Stats(profiler).strip_dirs().sort_stats("cumulative").print_stats(24)
|
||||
finally:
|
||||
case.doCleanups()
|
||||
|
||||
|
||||
def zipcrypto_probe(size: int):
|
||||
binary = shutil.which("zip")
|
||||
if not binary:
|
||||
print(
|
||||
json.dumps(
|
||||
{"probe": "zipcrypto", "skipped": "fixture ZIP writer unavailable"}
|
||||
)
|
||||
)
|
||||
return
|
||||
# Public fixture password only: never pass a user credential to a process.
|
||||
with tempfile.TemporaryDirectory(prefix="files-synthetic-zipcrypto-") as temporary:
|
||||
path = Path(temporary)
|
||||
content = random.Random(20260907).randbytes(size)
|
||||
(path / "synthetic.bin").write_bytes(content)
|
||||
subprocess.run(
|
||||
[binary, "-q", "-P", "fixture-only", "synthetic.zip", "synthetic.bin"],
|
||||
cwd=path,
|
||||
check=True,
|
||||
)
|
||||
for implementation in (pyzipper.AESZipFile, zipfile.ZipFile):
|
||||
started = time.perf_counter()
|
||||
with implementation(path / "synthetic.zip") as archive:
|
||||
result = archive.read("synthetic.bin", pwd=b"fixture-only")
|
||||
assert result == content
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"probe": "zipcrypto",
|
||||
"reader": implementation.__module__,
|
||||
"bytes": size,
|
||||
"seconds": round(time.perf_counter() - started, 4),
|
||||
}
|
||||
)
|
||||
)
|
||||
if native_zip_library() is not None:
|
||||
started = time.perf_counter()
|
||||
result = list(
|
||||
_read_selected_zip_members(
|
||||
path / "synthetic.zip",
|
||||
selected_files={"synthetic.bin"},
|
||||
password="fixture-only",
|
||||
max_file_bytes=size,
|
||||
max_total_bytes=size,
|
||||
)
|
||||
)
|
||||
assert result == [("synthetic.bin", content)]
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"probe": "zipcrypto",
|
||||
"reader": "libarchive-with-independent-crc",
|
||||
"bytes": size,
|
||||
"seconds": round(time.perf_counter() - started, 4),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--members", type=int, default=100)
|
||||
parser.add_argument("--member-size", type=int, default=4096)
|
||||
parser.add_argument("--zipcrypto-bytes", type=int, default=4 * 1024 * 1024)
|
||||
parser.add_argument("--profile", action="store_true")
|
||||
parser.add_argument(
|
||||
"--baseline-storage-revision",
|
||||
help="Read a trusted earlier local Git revision for isolated storage comparison, without changing the worktree",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
storage_probe(
|
||||
args.members, args.member_size, args.profile, args.baseline_storage_revision
|
||||
)
|
||||
if args.zipcrypto_bytes:
|
||||
zipcrypto_probe(args.zipcrypto_bytes)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tarfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_files.backend.storage.archives import _safe_member_path, inspect_archive
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
from test_archives import _tar_bytes, _zip_bytes
|
||||
|
||||
|
||||
class _HeadersOnly:
|
||||
def __init__(self, count: int, size: int = 0):
|
||||
self.count = count
|
||||
self.size = size
|
||||
self.seen = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def getmembers(self):
|
||||
raise AssertionError("TAR preview must not inflate every member before checking limits")
|
||||
|
||||
def __iter__(self):
|
||||
for index in range(self.count):
|
||||
member = tarfile.TarInfo(f"file-{index}.txt")
|
||||
member.size = self.size
|
||||
self.seen += 1
|
||||
yield member
|
||||
raise AssertionError("Inspection advanced beyond the rejecting header")
|
||||
|
||||
|
||||
class ArchiveInspectionBoundTests(unittest.TestCase):
|
||||
def test_tar_entry_limit_stops_at_first_excess_header(self):
|
||||
headers = _HeadersOnly(4)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", return_value=headers):
|
||||
with self.assertRaisesRegex(FileStorageError, "too many entries"):
|
||||
inspect_archive(b"fixture", filename="fixture.tar.gz", max_entries=2)
|
||||
self.assertEqual(3, headers.seen)
|
||||
|
||||
def test_tar_expanded_size_rejected_before_payload_decompression(self):
|
||||
headers = _HeadersOnly(1, size=100)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", return_value=headers):
|
||||
with self.assertRaisesRegex(FileStorageError, "too large after extraction"):
|
||||
inspect_archive(b"fixture", filename="fixture.tar.gz", max_expanded_bytes=10)
|
||||
self.assertEqual(1, headers.seen)
|
||||
|
||||
def test_tar_ratio_rejected_before_payload_decompression(self):
|
||||
headers = _HeadersOnly(1, size=100)
|
||||
with patch("govoplan_files.backend.storage.archives._open_tar", return_value=headers):
|
||||
with self.assertRaisesRegex(FileStorageError, "expansion ratio"):
|
||||
inspect_archive(b"fixture", filename="fixture.tar.gz", max_expansion_ratio=2)
|
||||
|
||||
def test_derived_directories_are_included_in_entry_limit(self):
|
||||
for filename, payload in (("fixture.zip", _zip_bytes({"a/b/c/file.txt": b"x"})), ("fixture.tar.gz", _tar_bytes({"a/b/c/file.txt": b"x"}))):
|
||||
with self.subTest(filename=filename), self.assertRaisesRegex(FileStorageError, "including parent directories"):
|
||||
inspect_archive(payload, filename=filename, max_entries=3)
|
||||
|
||||
def test_extreme_path_depth_and_byte_lengths_fail_before_deriving_directories(self):
|
||||
for path, error in (("a/" * 128 + "file.txt", "components"), ("ü" * 2049, "UTF-8 bytes")):
|
||||
with self.subTest(path_length=len(path)), self.assertRaisesRegex(FileStorageError, error):
|
||||
inspect_archive(_zip_bytes({path: b"x"}), filename="fixture.zip")
|
||||
|
||||
def test_directory_at_exact_depth_limit_accepts_trailing_separator(self):
|
||||
path = "a/" * 128
|
||||
self.assertEqual(path.rstrip("/"), _safe_member_path(path))
|
||||
|
||||
def test_invalid_unicode_member_name_has_controlled_validation_error(self):
|
||||
with self.assertRaisesRegex(FileStorageError, "not valid Unicode"):
|
||||
_safe_member_path("invalid-\udcff.txt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyzipper
|
||||
from sqlalchemy import event
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
||||
from govoplan_files.backend.storage.archives import (
|
||||
_read_selected_zip_members,
|
||||
_safe_member_path,
|
||||
extract_archive_upload,
|
||||
)
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
from govoplan_files.backend.storage.files import (
|
||||
archive_storage_backend_scope,
|
||||
_archive_write_backend,
|
||||
)
|
||||
from govoplan_files.backend.storage.native_zip import (
|
||||
native_zip_library,
|
||||
read_native_zip_members,
|
||||
)
|
||||
from test_archives import _zip_bytes, _tar_bytes, _encrypted_zip_bytes
|
||||
import test_storage_recovery as recovery_fixture
|
||||
|
||||
|
||||
def legacy_archive(entries: dict[str, bytes]) -> bytes:
|
||||
# Fixture-only known password, never an application secret.
|
||||
with tempfile.TemporaryDirectory(prefix="files-legacy-fixture-") as temporary:
|
||||
root = Path(temporary)
|
||||
for name, content in entries.items():
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
subprocess.run(
|
||||
[shutil.which("zip"), "-q", "-P", "fixture-only", "fixture.zip", *entries],
|
||||
cwd=root,
|
||||
check=True,
|
||||
)
|
||||
return (root / "fixture.zip").read_bytes()
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
shutil.which("zip") and native_zip_library() is not None,
|
||||
"optional libarchive and synthetic ZIP writer required",
|
||||
)
|
||||
class NativeArchivePerformanceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.contents = {
|
||||
"first.dat": random.Random(123).randbytes(8192),
|
||||
"folder/second.txt": b"second",
|
||||
}
|
||||
self.archive = legacy_archive(self.contents)
|
||||
|
||||
def read(self, **kwargs):
|
||||
return list(
|
||||
_read_selected_zip_members(
|
||||
self.archive,
|
||||
selected_files={"first.dat"},
|
||||
password="fixture-only",
|
||||
max_file_bytes=100_000,
|
||||
max_total_bytes=100_000,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
def test_native_selection_and_missing_library_python_fallback_match(self):
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives._read_member",
|
||||
side_effect=AssertionError("Python decoder must not run"),
|
||||
):
|
||||
native = self.read()
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives.native_zip_library",
|
||||
return_value=None,
|
||||
):
|
||||
fallback = self.read()
|
||||
self.assertEqual(native, fallback)
|
||||
self.assertEqual([("first.dat", self.contents["first.dat"])], native)
|
||||
|
||||
def test_utf8_flagged_german_names_use_native_reader(self):
|
||||
# Info-ZIP stores UTF-8 names without the language flag on some hosts.
|
||||
# Set the standard UTF-8 flag in local and central headers for this
|
||||
# fixture; encryption header verification is unaffected by bit 11.
|
||||
encoded_name = "Grüße.txt".encode("utf-8")
|
||||
placeholder = "x" * len(encoded_name)
|
||||
data = bytearray(
|
||||
legacy_archive({placeholder: b"german"}).replace(
|
||||
placeholder.encode(), encoded_name
|
||||
)
|
||||
)
|
||||
for signature, offset in ((b"PK\x03\x04", 6), (b"PK\x01\x02", 8)):
|
||||
position = data.index(signature) + offset
|
||||
flags = int.from_bytes(data[position : position + 2], "little") | 0x800
|
||||
data[position : position + 2] = flags.to_bytes(2, "little")
|
||||
self.archive = bytes(data)
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives._read_member",
|
||||
side_effect=AssertionError("Python decoder must not run"),
|
||||
):
|
||||
result = list(
|
||||
_read_selected_zip_members(
|
||||
self.archive,
|
||||
selected_files={"Grüße.txt"},
|
||||
password="fixture-only",
|
||||
max_file_bytes=100,
|
||||
max_total_bytes=100,
|
||||
)
|
||||
)
|
||||
self.assertEqual([("Grüße.txt", b"german")], result)
|
||||
|
||||
def test_wrong_password_and_corruption_never_fall_back(self):
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives._read_member",
|
||||
side_effect=AssertionError("No fallback after native error"),
|
||||
):
|
||||
with self.assertRaisesRegex(FileStorageError, "Native ZIP decoding failed"):
|
||||
list(
|
||||
_read_selected_zip_members(
|
||||
self.archive,
|
||||
selected_files={"first.dat"},
|
||||
password="wrong",
|
||||
max_file_bytes=100_000,
|
||||
max_total_bytes=100_000,
|
||||
)
|
||||
)
|
||||
corrupted = bytearray(self.archive)
|
||||
with pyzipper.AESZipFile(BytesIO(self.archive)) as archive:
|
||||
first = archive.infolist()[0]
|
||||
payload_start = (
|
||||
first.header_offset
|
||||
+ 30
|
||||
+ len(first.filename.encode())
|
||||
+ len(first.extra)
|
||||
)
|
||||
corrupted[payload_start + first.compress_size - 1] ^= 0x40
|
||||
self.archive = bytes(corrupted)
|
||||
with self.assertRaises(FileStorageError):
|
||||
self.read()
|
||||
|
||||
def test_independent_crc_check_rejects_mismatched_metadata(self):
|
||||
with pyzipper.AESZipFile(BytesIO(self.archive)) as archive:
|
||||
infos = {
|
||||
_safe_member_path(info.filename): info for info in archive.infolist()
|
||||
}
|
||||
infos["first.dat"].CRC ^= 1
|
||||
with self.assertRaisesRegex(FileStorageError, "CRC verification"):
|
||||
list(
|
||||
read_native_zip_members(
|
||||
native_zip_library(),
|
||||
self.archive,
|
||||
infos_by_path=infos,
|
||||
selected_files={"first.dat"},
|
||||
normalize_name=_safe_member_path,
|
||||
password="fixture-only",
|
||||
max_file_bytes=100_000,
|
||||
max_total_bytes=100_000,
|
||||
)
|
||||
)
|
||||
|
||||
def test_legacy_encoded_non_utf8_names_choose_python_before_decoding(self):
|
||||
encoded_name = "ä.txt".encode("cp437")
|
||||
placeholder = "x" * len(encoded_name)
|
||||
self.archive = legacy_archive({placeholder: b"legacy"}).replace(
|
||||
placeholder.encode(), encoded_name
|
||||
)
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives.native_zip_library",
|
||||
side_effect=AssertionError("Legacy encoding must use original reader"),
|
||||
):
|
||||
result = list(
|
||||
_read_selected_zip_members(
|
||||
self.archive,
|
||||
selected_files={"ä.txt"},
|
||||
password="fixture-only",
|
||||
max_file_bytes=100,
|
||||
max_total_bytes=100,
|
||||
)
|
||||
)
|
||||
self.assertEqual([("ä.txt", b"legacy")], result)
|
||||
|
||||
def test_suspended_native_reader_is_freed_after_destination_failure(self):
|
||||
library = native_zip_library()
|
||||
|
||||
class LibraryProxy:
|
||||
def __getattr__(self, name):
|
||||
return getattr(library, name)
|
||||
|
||||
proxy = LibraryProxy()
|
||||
with (
|
||||
patch.object(
|
||||
proxy, "archive_read_free", wraps=library.archive_read_free
|
||||
) as close,
|
||||
patch(
|
||||
"govoplan_files.backend.storage.archives.native_zip_library",
|
||||
return_value=proxy,
|
||||
),
|
||||
patch(
|
||||
"govoplan_files.backend.storage.archives.create_file_asset",
|
||||
side_effect=FileStorageError("Destination failure"),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(FileStorageError, "Destination failure"):
|
||||
extract_archive_upload(
|
||||
object(),
|
||||
tenant_id="tenant",
|
||||
owner_type="user",
|
||||
owner_id="user",
|
||||
user_id="user",
|
||||
archive_data=self.archive,
|
||||
filename="archive.zip",
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
password="fixture-only",
|
||||
)
|
||||
close.assert_called_once()
|
||||
|
||||
def test_native_read_enforces_actual_member_and_total_limits(self):
|
||||
for member_limit, total_limit in ((10, 100_000), (100_000, 10)):
|
||||
with self.subTest(member_limit=member_limit, total_limit=total_limit):
|
||||
with self.assertRaises(FileStorageError):
|
||||
list(
|
||||
_read_selected_zip_members(
|
||||
self.archive,
|
||||
selected_files={"first.dat"},
|
||||
password="fixture-only",
|
||||
max_file_bytes=member_limit,
|
||||
max_total_bytes=total_limit,
|
||||
)
|
||||
)
|
||||
|
||||
def test_native_emits_progress_inside_a_large_member(self):
|
||||
self.archive = legacy_archive(
|
||||
{"first.dat": random.Random(234).randbytes(3 * 1024 * 1024)}
|
||||
)
|
||||
progress = []
|
||||
result = list(
|
||||
_read_selected_zip_members(
|
||||
self.archive,
|
||||
selected_files={"first.dat"},
|
||||
password="fixture-only",
|
||||
max_file_bytes=4 * 1024 * 1024,
|
||||
max_total_bytes=4 * 1024 * 1024,
|
||||
total_bytes=3 * 1024 * 1024,
|
||||
progress=lambda *args: progress.append(args),
|
||||
)
|
||||
)
|
||||
self.assertEqual(3 * 1024 * 1024, len(result[0][1]))
|
||||
self.assertTrue(
|
||||
any(item[1] == 0 and 0 < item[3] < 3 * 1024 * 1024 for item in progress)
|
||||
)
|
||||
self.assertEqual(
|
||||
("extracting", 1, 1, 3 * 1024 * 1024, 3 * 1024 * 1024), progress[-1]
|
||||
)
|
||||
|
||||
|
||||
class ArchiveStreamingTests(unittest.TestCase):
|
||||
def test_plain_zip_and_tar_store_one_member_before_reading_the_next(self):
|
||||
entries = {"one.txt": b"one", "two.txt": b"two"}
|
||||
for filename, archive in (
|
||||
("files.zip", _zip_bytes(entries)),
|
||||
("files.tar.gz", _tar_bytes(entries)),
|
||||
):
|
||||
with self.subTest(filename=filename):
|
||||
progress = []
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives.create_file_asset",
|
||||
return_value=SimpleNamespace(asset=object()),
|
||||
) as create:
|
||||
result = extract_archive_upload(
|
||||
object(),
|
||||
tenant_id="tenant",
|
||||
owner_type="user",
|
||||
owner_id="user",
|
||||
user_id="user",
|
||||
archive_data=archive,
|
||||
filename=filename,
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
progress=lambda *args: progress.append(args),
|
||||
)
|
||||
self.assertEqual(2, len(result))
|
||||
self.assertEqual(2, create.call_count)
|
||||
stored_first = progress.index(("storing", 1, 2, 3, 6))
|
||||
extracted_second = progress.index(("extracting", 2, 2, 6, 6))
|
||||
self.assertLess(stored_first, extracted_second)
|
||||
|
||||
def test_aes_archive_keeps_existing_decoder(self):
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives.native_zip_library",
|
||||
side_effect=AssertionError("AES must not use native path"),
|
||||
):
|
||||
result = list(
|
||||
_read_selected_zip_members(
|
||||
_encrypted_zip_bytes("correct"),
|
||||
selected_files={"secure/report.txt"},
|
||||
password="correct",
|
||||
max_file_bytes=100,
|
||||
max_total_bytes=100,
|
||||
)
|
||||
)
|
||||
self.assertEqual([("secure/report.txt", b"classified")], result)
|
||||
|
||||
def test_late_unsafe_header_prevents_every_write(self):
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.archives.create_file_asset"
|
||||
) as create:
|
||||
with self.assertRaisesRegex(FileStorageError, "Unsafe archive member"):
|
||||
extract_archive_upload(
|
||||
object(),
|
||||
tenant_id="tenant",
|
||||
owner_type="user",
|
||||
owner_id="user",
|
||||
user_id="user",
|
||||
archive_data=_zip_bytes({"good.txt": b"good", "../unsafe": b"bad"}),
|
||||
filename="unsafe.zip",
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
)
|
||||
create.assert_not_called()
|
||||
|
||||
|
||||
class ArchivePersistencePerformanceTests(unittest.TestCase):
|
||||
def test_backend_scope_is_lazy_nested_and_reset_after_exception(self):
|
||||
first_backend = object()
|
||||
second_backend = object()
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||
side_effect=[first_backend, second_backend],
|
||||
) as factory:
|
||||
with self.assertRaisesRegex(RuntimeError, "fixture failure"):
|
||||
with archive_storage_backend_scope():
|
||||
factory.assert_not_called()
|
||||
self.assertIs(first_backend, _archive_write_backend())
|
||||
with archive_storage_backend_scope():
|
||||
self.assertIs(first_backend, _archive_write_backend())
|
||||
factory.assert_called_once()
|
||||
raise RuntimeError("fixture failure")
|
||||
with archive_storage_backend_scope():
|
||||
self.assertIs(second_backend, _archive_write_backend())
|
||||
self.assertEqual(2, factory.call_count)
|
||||
|
||||
def test_backend_reuse_does_not_cache_tenant_identity_or_blob_dedup(self):
|
||||
fixture = recovery_fixture.StorageRecoveryTests()
|
||||
fixture.setUp()
|
||||
self.addCleanup(fixture.doCleanups)
|
||||
results = []
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||
return_value=fixture.backend,
|
||||
) as factory:
|
||||
for tenant in ("tenant-one", "tenant-two"):
|
||||
results.append(
|
||||
extract_archive_upload(
|
||||
fixture.session,
|
||||
tenant_id=tenant,
|
||||
owner_type="user",
|
||||
owner_id="user-1",
|
||||
user_id="user-1",
|
||||
archive_data=_zip_bytes({"one.txt": b"one", "two.txt": b"two"}),
|
||||
filename="files.zip",
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
)
|
||||
)
|
||||
fixture.session.commit()
|
||||
self.assertEqual(2, factory.call_count)
|
||||
self.assertEqual(
|
||||
["tenant-one", "tenant-two"], [items[0].blob.tenant_id for items in results]
|
||||
)
|
||||
self.assertNotEqual(results[0][0].blob.id, results[1][0].blob.id)
|
||||
|
||||
def test_asset_and_version_are_published_once_without_redundant_update(self):
|
||||
fixture = recovery_fixture.StorageRecoveryTests()
|
||||
fixture.setUp()
|
||||
self.addCleanup(fixture.doCleanups)
|
||||
register_files_change_tracking()
|
||||
queries = []
|
||||
event.listen(
|
||||
fixture.engine,
|
||||
"before_cursor_execute",
|
||||
lambda conn, cursor, statement, parameters, context, executemany: (
|
||||
queries.append(statement)
|
||||
),
|
||||
)
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||
return_value=fixture.backend,
|
||||
):
|
||||
stored = extract_archive_upload(
|
||||
fixture.session,
|
||||
tenant_id="tenant-1",
|
||||
owner_type="user",
|
||||
owner_id="user-1",
|
||||
user_id="user-1",
|
||||
archive_data=_zip_bytes({"one.txt": b"one", "two.txt": b"two"}),
|
||||
filename="files.zip",
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
)
|
||||
fixture.session.commit()
|
||||
self.assertFalse(
|
||||
any(statement.startswith("UPDATE file_assets") for statement in queries)
|
||||
)
|
||||
events = (
|
||||
fixture.session.query(ChangeSequenceEntry)
|
||||
.filter(ChangeSequenceEntry.module_id == "files")
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(["created", "created"], [entry.operation for entry in events])
|
||||
self.assertTrue(
|
||||
all(item.asset.current_version_id == item.version.id for item in stored)
|
||||
)
|
||||
|
||||
def test_streamed_late_content_limit_failure_rolls_back_previous_blobs(self):
|
||||
fixture = recovery_fixture.StorageRecoveryTests()
|
||||
fixture.setUp()
|
||||
self.addCleanup(fixture.doCleanups)
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||
return_value=fixture.backend,
|
||||
):
|
||||
with self.assertRaisesRegex(FileStorageError, "per-file limit"):
|
||||
extract_archive_upload(
|
||||
fixture.session,
|
||||
tenant_id="tenant-1",
|
||||
owner_type="user",
|
||||
owner_id="user-1",
|
||||
user_id="user-1",
|
||||
archive_data=_zip_bytes(
|
||||
{"one.txt": b"one", "too-large.txt": b"x" * 200}
|
||||
),
|
||||
filename="files.zip",
|
||||
folder="",
|
||||
campaign_id=None,
|
||||
max_file_bytes=100,
|
||||
)
|
||||
fixture.session.rollback()
|
||||
self.assertEqual([], list(fixture.backend.root.rglob("*.blob")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.secrets import open_transient_payload, seal_transient_payload
|
||||
from govoplan_files.backend.archive_work import ArchiveProgress, use_staged_upload
|
||||
from govoplan_files.backend import archive_work
|
||||
from govoplan_files.backend.db.models import FileAsset
|
||||
from govoplan_files.backend.routes import uploads
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
|
||||
import test_managed_archives as managed_archive_tests
|
||||
|
||||
|
||||
class ArchiveStagingTests(unittest.TestCase):
|
||||
store_blob = managed_archive_tests.ManagedArchiveTests.store_blob
|
||||
seed_source = managed_archive_tests.ManagedArchiveTests.seed_source
|
||||
assert_no_extraction = managed_archive_tests.ManagedArchiveTests.assert_no_extraction
|
||||
|
||||
def setUp(self):
|
||||
managed_archive_tests.ManagedArchiveTests.setUp(self)
|
||||
self.directory = Path(self.enterContext(tempfile.TemporaryDirectory(prefix="archive-staging-tests-")))
|
||||
self.settings.file_archive_work_root = str(self.directory / "work")
|
||||
self.settings.file_archive_staged_per_actor = 4
|
||||
self.settings.file_archive_staged_max_bytes = 20 * 1024 * 1024
|
||||
app = FastAPI()
|
||||
app.include_router(uploads.router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||
app.dependency_overrides[get_session] = lambda: self.session
|
||||
self.client = self.enterContext(TestClient(app, raise_server_exceptions=False))
|
||||
|
||||
def preview(self, *, archive=None, **changes):
|
||||
return self.client.post(
|
||||
"/api/v1/files/archive-preview",
|
||||
data={"path": "extracted", "retain_upload": "true", **changes},
|
||||
files={"file": ("private-source.zip", archive or managed_archive_tests.archive_bytes(), "application/zip")},
|
||||
)
|
||||
|
||||
def staged_preview(self, preview, **changes):
|
||||
return self.client.post(
|
||||
"/api/v1/files/archive-preview",
|
||||
data={"path": "extracted", "preview_token": preview["preview_token"], "staged_upload_id": preview["staged_upload_id"], **changes},
|
||||
)
|
||||
|
||||
def confirm(self, preview, **changes):
|
||||
return self.client.post(
|
||||
"/api/v1/files/archive-confirm",
|
||||
data={"path": "extracted", "preview_token": preview["preview_token"], "staged_upload_id": preview["staged_upload_id"], "selected_paths_json": json.dumps(["folder/one.txt"]), **changes},
|
||||
)
|
||||
|
||||
def staged_path(self, preview):
|
||||
with use_staged_upload(self.settings, preview["staged_upload_id"], tenant_id="tenant-1", user_id="user-1") as filename:
|
||||
return Path(filename)
|
||||
|
||||
def progress(self, operation_id):
|
||||
return self.client.get(f"/api/v1/files/archive-progress/{operation_id}")
|
||||
|
||||
def test_preview_stages_once_password_repreview_and_confirm_reuse_upload(self):
|
||||
password = "staged secret password"
|
||||
preview = self.preview(archive=managed_archive_tests.archive_bytes(password=password))
|
||||
self.assertEqual(preview.status_code, 200, preview.text)
|
||||
self.assertTrue(preview.json()["requires_password"])
|
||||
self.assertFalse(preview.json()["password_verified"])
|
||||
stage = self.staged_path(preview.json())
|
||||
self.assert_no_extraction()
|
||||
operation_id = str(uuid4())
|
||||
with patch.object(uploads, "_spool_limited_upload_to_temp") as spool:
|
||||
wrong = self.staged_preview(preview.json(), password="incorrect")
|
||||
self.assertEqual(wrong.status_code, 400, wrong.text)
|
||||
self.assertTrue(stage.exists())
|
||||
verified = self.staged_preview(preview.json(), password=password)
|
||||
self.assertEqual(verified.status_code, 200, verified.text)
|
||||
self.assertTrue(verified.json()["password_verified"])
|
||||
self.assertEqual(verified.json()["staged_upload_id"], preview.json()["staged_upload_id"])
|
||||
confirmed = self.confirm(verified.json(), password=password, operation_id=operation_id)
|
||||
spool.assert_not_called()
|
||||
self.assertEqual(confirmed.status_code, 200, confirmed.text)
|
||||
self.assertEqual([item["display_path"] for item in confirmed.json()["files"]], ["extracted/folder/one.txt"])
|
||||
self.assertEqual(self.session.query(FileAsset).count(), 2)
|
||||
self.assertFalse(stage.exists())
|
||||
progress = self.progress(operation_id)
|
||||
self.assertEqual(progress.status_code, 200, progress.text)
|
||||
self.assertEqual(progress.json()["status"], "complete")
|
||||
for value in (verified.text, confirmed.text, progress.text, str(self.audit.call_args_list)):
|
||||
self.assertNotIn(password, value)
|
||||
self.assertNotIn("private-source.zip", progress.text)
|
||||
self.assertNotIn("folder/one.txt", progress.text)
|
||||
payload = open_transient_payload(verified.json()["preview_token"], ttl_seconds=1800)
|
||||
self.assertNotIn(password, json.dumps(payload))
|
||||
|
||||
def test_staging_rejects_token_actor_tenant_stage_destination_and_digest_mismatch_without_writes(self):
|
||||
response = self.preview()
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
preview = response.json()
|
||||
for change in ({"preview_token": "tampered"}, {"staged_upload_id": str(uuid4())}, {"owner_id": "other-user"}, {"path": "elsewhere"}):
|
||||
with self.subTest(change=change):
|
||||
rejected = self.confirm(preview, **change)
|
||||
self.assertEqual(rejected.status_code, 400, rejected.text)
|
||||
self.assert_no_extraction()
|
||||
for field, original, foreign in (("tenant_id", "tenant-1", "other-tenant"), ("user_id", "user-1", "other-user")):
|
||||
target = self.principal if field == "tenant_id" else self.principal.user
|
||||
attribute = "tenant_id" if field == "tenant_id" else "id"
|
||||
setattr(target, attribute, foreign)
|
||||
self.assertEqual(self.confirm(preview).status_code, 400)
|
||||
setattr(target, attribute, original)
|
||||
path = self.staged_path(preview)
|
||||
path.write_bytes(b"tampered archive bytes")
|
||||
for response in (self.staged_preview(preview), self.confirm(preview)):
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertIn("contents changed", response.text)
|
||||
self.assert_no_extraction()
|
||||
self.assertTrue(path.exists())
|
||||
self.assertEqual(list(path.parent.glob("*.progress")), [])
|
||||
|
||||
def test_expired_stage_and_explicit_discard_are_bound_to_current_actor(self):
|
||||
preview = self.preview().json()
|
||||
path = self.staged_path(preview)
|
||||
self.principal.user.id = "other-user"
|
||||
self.assertEqual(self.client.delete(f"/api/v1/files/archive-staging/{preview['staged_upload_id']}").status_code, 204)
|
||||
self.assertTrue(path.exists())
|
||||
self.principal.user.id = "user-1"
|
||||
self.assertEqual(self.client.delete(f"/api/v1/files/archive-staging/{preview['staged_upload_id']}").status_code, 204)
|
||||
self.assertFalse(path.exists())
|
||||
self.assertEqual(self.confirm(preview).status_code, 410)
|
||||
preview = self.preview().json()
|
||||
path = self.staged_path(preview)
|
||||
old = time.time() - self.settings.file_archive_preview_ttl_seconds - 1
|
||||
os.utime(path, (old, old))
|
||||
self.assertEqual(self.confirm(preview).status_code, 410)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_progress_stays_running_through_commit_and_completes_only_after_commit(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
original_commit = self.session.commit
|
||||
observed = []
|
||||
|
||||
def commit():
|
||||
before = self.progress(operation_id).json()
|
||||
self.assertEqual(before["status"], "running")
|
||||
self.assertEqual(before["phase"], "finalizing")
|
||||
original_commit()
|
||||
observed.append(self.progress(operation_id).json()["status"])
|
||||
|
||||
with patch.object(self.session, "commit", side_effect=commit):
|
||||
response = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(observed, ["running"])
|
||||
progress = self.progress(operation_id).json()
|
||||
self.assertEqual(progress["status"], "complete")
|
||||
self.assertEqual(progress["completed_files"], 1)
|
||||
self.assertEqual(progress["completed_bytes"], 3)
|
||||
|
||||
def test_commit_error_marks_progress_failed_and_preserves_stage_for_retry(self):
|
||||
preview = self.preview().json()
|
||||
stage = self.staged_path(preview)
|
||||
operation_id = str(uuid4())
|
||||
with patch.object(self.session, "commit", side_effect=FileStorageError("Commit refused")):
|
||||
response = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertEqual(self.progress(operation_id).json()["status"], "failed")
|
||||
self.assertEqual(self.session.query(FileAsset).count(), 1)
|
||||
self.assertTrue(stage.exists())
|
||||
retry = self.confirm(preview, operation_id=str(uuid4()))
|
||||
self.assertEqual(retry.status_code, 200, retry.text)
|
||||
|
||||
def test_post_commit_stage_cleanup_failure_does_not_report_a_failed_import(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
with patch.object(uploads, "discard_staged_upload", side_effect=OSError("Temporary cleanup failure")):
|
||||
response = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(self.session.query(FileAsset).count(), 2)
|
||||
self.assertEqual(self.progress(operation_id).json()["status"], "complete")
|
||||
|
||||
def test_duplicate_operation_cannot_start_another_write_or_overwrite_progress(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
progress = ArchiveProgress(self.settings, operation_id, tenant_id="tenant-1", user_id="user-1")
|
||||
progress("extracting", 1, 5, 10, 50)
|
||||
before = self.progress(operation_id).json()
|
||||
response = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
self.assertIn("already been used", response.text)
|
||||
self.assertEqual(self.progress(operation_id).json(), before)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_progress_and_discard_require_upload_permission_and_do_not_disclose_foreign_receipts(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
ArchiveProgress(self.settings, operation_id, tenant_id="tenant-1", user_id="user-1")
|
||||
self.principal.user.id = "other-user"
|
||||
self.assertEqual(self.progress(operation_id).status_code, 404)
|
||||
self.principal.user.id = "user-1"
|
||||
self.scopes.remove("files:file:upload")
|
||||
self.assertEqual(self.progress(operation_id).status_code, 403)
|
||||
self.assertEqual(self.client.delete(f"/api/v1/files/archive-staging/{preview['staged_upload_id']}").status_code, 403)
|
||||
self.assertTrue(self.staged_path(preview).exists())
|
||||
|
||||
def test_malformed_signed_digest_cannot_bypass_stage_binding(self):
|
||||
preview = self.preview().json()
|
||||
payload = open_transient_payload(preview["preview_token"], ttl_seconds=1800)
|
||||
payload["archive_sha256"] = None
|
||||
invalid = self.confirm(preview, preview_token=seal_transient_payload(payload))
|
||||
self.assertEqual(invalid.status_code, 400, invalid.text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_progress_persistence_failure_does_not_turn_a_committed_import_into_an_error(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
with patch.object(archive_work.os, "replace", side_effect=OSError("Progress write unavailable")):
|
||||
result = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
self.assertEqual(self.session.query(FileAsset).count(), 2)
|
||||
# A failed observation write may leave an older running receipt, but
|
||||
# the confirmation response remains the authoritative outcome.
|
||||
self.assertEqual(self.progress(operation_id).json()["status"], "running")
|
||||
|
||||
def test_lease_cleanup_failure_cannot_mask_a_committed_import(self):
|
||||
preview = self.preview().json()
|
||||
operation_id = str(uuid4())
|
||||
original_unlink = Path.unlink
|
||||
|
||||
def unlink(path, *args, **kwargs):
|
||||
if path.suffix == ".lease":
|
||||
raise OSError("Lease cleanup temporarily unavailable")
|
||||
return original_unlink(path, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "unlink", new=unlink):
|
||||
result = self.confirm(preview, operation_id=operation_id)
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
self.assertEqual(self.session.query(FileAsset).count(), 2)
|
||||
self.assertEqual(self.progress(operation_id).json()["status"], "complete")
|
||||
|
||||
def test_invalid_operation_identifier_and_ambiguous_source_are_rejected_before_writes(self):
|
||||
preview = self.preview().json()
|
||||
response = self.confirm(preview, operation_id="../../outside")
|
||||
self.assertEqual(response.status_code, 400, response.text)
|
||||
ambiguous = self.client.post(
|
||||
"/api/v1/files/archive-confirm",
|
||||
data={"path": "extracted", "preview_token": preview["preview_token"], "staged_upload_id": preview["staged_upload_id"], "selected_paths_json": '["folder"]'},
|
||||
files={"file": ("other.zip", managed_archive_tests.archive_bytes(), "application/zip")},
|
||||
)
|
||||
self.assertEqual(ambiguous.status_code, 400, ambiguous.text)
|
||||
self.assertIn("either", ambiguous.text)
|
||||
self.assert_no_extraction()
|
||||
self.assertTrue(self.staged_path(preview).exists())
|
||||
|
||||
def test_staged_preview_requires_a_token_without_exposing_a_server_error(self):
|
||||
preview = self.preview().json()
|
||||
missing = self.client.post(
|
||||
"/api/v1/files/archive-preview",
|
||||
data={"path": "extracted", "staged_upload_id": preview["staged_upload_id"]},
|
||||
)
|
||||
self.assertIn(missing.status_code, (400, 422), missing.text)
|
||||
self.assertTrue(self.staged_path(preview).exists())
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_repreview_does_not_extend_the_original_stage_expiry(self):
|
||||
preview = self.preview().json()
|
||||
path = self.staged_path(preview)
|
||||
original_mtime = time.time() - 120
|
||||
os.utime(path, (original_mtime, original_mtime))
|
||||
original_deadline = path.stat().st_mtime + self.settings.file_archive_preview_ttl_seconds
|
||||
for _ in range(2):
|
||||
response = self.staged_preview(preview)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
preview = response.json()
|
||||
returned_deadline = datetime.fromisoformat(preview["expires_at"]).timestamp()
|
||||
self.assertLessEqual(returned_deadline, original_deadline + 0.000001)
|
||||
self.assertAlmostEqual(returned_deadline, original_deadline, places=5)
|
||||
self.assertEqual(path.stat().st_mtime, original_mtime)
|
||||
self.assert_no_extraction()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from govoplan_files.backend import archive_work
|
||||
from govoplan_files.backend.archive_work import (
|
||||
ArchiveProgress,
|
||||
ArchiveWorkExpired,
|
||||
discard_staged_upload,
|
||||
read_progress,
|
||||
stage_upload,
|
||||
use_staged_upload,
|
||||
)
|
||||
from govoplan_files.backend.storage.common import FileStorageError
|
||||
|
||||
|
||||
class ArchiveWorkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = Path(self.enterContext(tempfile.TemporaryDirectory(prefix="archive-work-tests-")))
|
||||
self.settings = SimpleNamespace(
|
||||
file_archive_work_root=str(self.directory / "work"),
|
||||
file_archive_preview_ttl_seconds=60,
|
||||
file_archive_staged_per_actor=2,
|
||||
file_archive_staged_max_bytes=100,
|
||||
)
|
||||
self.source = self.directory / "source.zip"
|
||||
self.source.write_bytes(b"archive bytes")
|
||||
self.actor = {"tenant_id": "tenant-1", "user_id": "user-1"}
|
||||
|
||||
def stage(self, **actor):
|
||||
return stage_upload(self.settings, str(self.source), **(self.actor | actor))
|
||||
|
||||
def use(self, stage_id, **actor):
|
||||
return use_staged_upload(self.settings, stage_id, **(self.actor | actor))
|
||||
|
||||
def progress(self, operation_id=None, **actor):
|
||||
return ArchiveProgress(self.settings, operation_id or str(uuid4()), **(self.actor | actor))
|
||||
|
||||
def test_staging_is_private_and_actor_and_tenant_bound(self):
|
||||
stage_id = self.stage()
|
||||
with self.use(stage_id) as filename:
|
||||
staged = Path(filename)
|
||||
self.assertEqual(staged.read_bytes(), self.source.read_bytes())
|
||||
self.assertEqual(staged.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(staged.parent.stat().st_mode & 0o777, 0o700)
|
||||
for actor in ({"user_id": "other-user"}, {"tenant_id": "other-tenant"}):
|
||||
with self.subTest(actor=actor):
|
||||
with self.assertRaises(ArchiveWorkExpired), self.use(stage_id, **actor):
|
||||
self.fail("Foreign actor obtained a staged archive")
|
||||
discard_staged_upload(self.settings, stage_id, **(self.actor | actor))
|
||||
with self.use(stage_id) as filename:
|
||||
self.assertTrue(Path(filename).is_file())
|
||||
|
||||
def test_noncanonical_identifiers_cannot_escape_the_private_root(self):
|
||||
stage_id = self.stage()
|
||||
for invalid in ("../source.zip", "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", stage_id.replace("-", ""), "", "not-a-uuid"):
|
||||
with self.subTest(invalid=invalid):
|
||||
with self.assertRaises(FileStorageError), self.use(invalid):
|
||||
self.fail("Invalid identifier accepted")
|
||||
with self.assertRaises(FileStorageError):
|
||||
discard_staged_upload(self.settings, invalid, **self.actor)
|
||||
with self.assertRaises(FileStorageError):
|
||||
read_progress(self.settings, invalid, **self.actor)
|
||||
self.assertEqual(self.source.read_bytes(), b"archive bytes")
|
||||
|
||||
def test_expired_stage_and_progress_are_not_readable(self):
|
||||
stage_id = self.stage()
|
||||
with self.use(stage_id) as filename:
|
||||
staged = Path(filename)
|
||||
progress = self.progress()
|
||||
old = time.time() - self.settings.file_archive_preview_ttl_seconds - 1
|
||||
os.utime(staged, (old, old))
|
||||
os.utime(progress.path, (old, old))
|
||||
with self.assertRaises(ArchiveWorkExpired), self.use(stage_id):
|
||||
self.fail("Expired stage accepted")
|
||||
with self.assertRaises(ArchiveWorkExpired):
|
||||
read_progress(self.settings, progress.path.stem[-36:], **self.actor)
|
||||
|
||||
def test_quota_evicts_oldest_unleased_stage_and_explicit_discard_is_idempotent(self):
|
||||
first = self.stage()
|
||||
with self.use(first) as filename:
|
||||
first_path = Path(filename)
|
||||
old = time.time() - 5
|
||||
os.utime(first_path, (old, old))
|
||||
second = self.stage()
|
||||
third = self.stage()
|
||||
self.assertFalse(first_path.exists())
|
||||
for stage_id in (second, third):
|
||||
with self.use(stage_id):
|
||||
pass
|
||||
discard_staged_upload(self.settings, third, **self.actor)
|
||||
discard_staged_upload(self.settings, third, **self.actor)
|
||||
with self.assertRaises(ArchiveWorkExpired), self.use(third):
|
||||
self.fail("Discarded archive retained")
|
||||
|
||||
def test_active_lease_blocks_duplicate_processing_discard_and_quota_eviction(self):
|
||||
self.settings.file_archive_staged_per_actor = 1
|
||||
first = self.stage()
|
||||
with self.use(first) as filename:
|
||||
with self.assertRaisesRegex(FileStorageError, "already being processed"), self.use(first):
|
||||
self.fail("Duplicate lease accepted")
|
||||
discard_staged_upload(self.settings, first, **self.actor)
|
||||
self.assertTrue(Path(filename).exists())
|
||||
with self.assertRaises(FileStorageError):
|
||||
self.stage()
|
||||
with self.use(first):
|
||||
pass
|
||||
|
||||
def test_long_running_lease_remains_protected_after_preview_ttl(self):
|
||||
self.settings.file_archive_staged_per_actor = 1
|
||||
first = self.stage()
|
||||
with self.use(first) as filename:
|
||||
path = Path(filename)
|
||||
old = time.time() - self.settings.file_archive_preview_ttl_seconds - 1
|
||||
os.utime(path, (old, old))
|
||||
os.utime(path.with_suffix(".lease"), (old, old))
|
||||
with self.assertRaises(FileStorageError):
|
||||
self.stage()
|
||||
self.assertTrue(path.exists(), "A live import must remain quota-accounted even after its preview TTL")
|
||||
|
||||
def test_global_byte_quota_does_not_evict_other_actor_archives(self):
|
||||
first = self.stage()
|
||||
self.settings.file_archive_staged_max_bytes = len(self.source.read_bytes())
|
||||
with self.assertRaisesRegex(FileStorageError, "storage is full"):
|
||||
self.stage(user_id="other-user")
|
||||
with self.use(first):
|
||||
pass
|
||||
|
||||
def test_progress_is_private_actor_bound_and_duplicate_operation_is_rejected(self):
|
||||
operation_id = str(uuid4())
|
||||
progress = self.progress(operation_id)
|
||||
progress("extracting", 2, 5, 20, 50)
|
||||
for actor in ({"user_id": "other-user"}, {"tenant_id": "other-tenant"}):
|
||||
with self.assertRaises(ArchiveWorkExpired):
|
||||
read_progress(self.settings, operation_id, **(self.actor | actor))
|
||||
before = read_progress(self.settings, operation_id, **self.actor)
|
||||
with self.assertRaisesRegex(FileStorageError, "already been used"):
|
||||
self.progress(operation_id)
|
||||
self.assertEqual(read_progress(self.settings, operation_id, **self.actor), before)
|
||||
self.assertEqual(set(before), {"phase", "status", "completed_files", "total_files", "completed_bytes", "total_bytes"})
|
||||
self.assertEqual(before["status"], "running")
|
||||
progress.finish(True)
|
||||
self.assertEqual(read_progress(self.settings, operation_id, **self.actor)["status"], "complete")
|
||||
with self.assertRaises(FileStorageError):
|
||||
self.progress(operation_id)
|
||||
|
||||
def test_progress_write_failure_never_interrupts_an_import(self):
|
||||
progress = self.progress()
|
||||
with patch.object(archive_work.os, "replace", side_effect=OSError("Temporary storage unavailable")):
|
||||
progress("extracting", 1, 2, 10, 20)
|
||||
progress.finish(True)
|
||||
self.assertEqual(progress.value["status"], "complete")
|
||||
self.assertEqual(list(progress.path.parent.glob(".progress-*")), [])
|
||||
self.assertEqual(json.loads(progress.path.read_text())["status"], "running")
|
||||
|
||||
def test_missing_optional_operation_id_does_not_create_a_work_directory(self):
|
||||
progress = ArchiveProgress(self.settings, None, **self.actor)
|
||||
progress("extracting", 1, 1, 10, 10)
|
||||
progress.finish(True)
|
||||
self.assertIsNone(progress.path)
|
||||
self.assertFalse(Path(self.settings.file_archive_work_root).exists())
|
||||
|
||||
def test_work_directory_with_public_permissions_fails_closed(self):
|
||||
root = Path(self.settings.file_archive_work_root) / "v1"
|
||||
root.mkdir(parents=True, mode=0o755)
|
||||
root.chmod(0o755)
|
||||
with self.assertRaisesRegex(FileStorageError, "must be private"):
|
||||
self.stage()
|
||||
self.assertEqual(list(root.glob("*.archive")), [])
|
||||
|
||||
def test_orphan_lease_does_not_permanently_block_staging(self):
|
||||
stage_id = self.stage()
|
||||
with self.use(stage_id) as filename:
|
||||
staged = Path(filename)
|
||||
# Simulate the lock file left behind after a worker exits: no live
|
||||
# process holds its kernel lock, so a new request can reclaim it.
|
||||
staged.with_suffix(".lease").touch(mode=0o600)
|
||||
with self.use(stage_id):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Managed archive routes: real authorization, preview and extraction pipeline.
|
||||
|
||||
Only external blob persistence, directory membership and audit delivery are
|
||||
isolated; source/version/share checks and destination writes use SQLite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import tarfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pyzipper
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_files.backend.db.models import (
|
||||
FileAsset,
|
||||
FileBlob,
|
||||
FileConnectorPolicy,
|
||||
FileShare,
|
||||
FileVersion,
|
||||
)
|
||||
from govoplan_files.backend.routes.managed_archives import router
|
||||
from govoplan_files.backend.storage.common import FileStorageError, utcnow
|
||||
from govoplan_files.backend.storage.files import create_file_asset
|
||||
|
||||
|
||||
def archive_bytes(entries=None, *, password=None, tar=False):
|
||||
entries = entries or {"folder/one.txt": b"one", "other/two.txt": b"two"}
|
||||
output = io.BytesIO()
|
||||
if tar:
|
||||
with tarfile.open(fileobj=output, mode="w:gz") as archive:
|
||||
for name, data in entries.items():
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(data)
|
||||
archive.addfile(member, io.BytesIO(data))
|
||||
else:
|
||||
archive_type = pyzipper.AESZipFile if password else zipfile.ZipFile
|
||||
options = {"encryption": pyzipper.WZ_AES} if password else {}
|
||||
with archive_type(
|
||||
output, "w", compression=zipfile.ZIP_DEFLATED, **options
|
||||
) as archive:
|
||||
if password:
|
||||
archive.setpassword(password.encode())
|
||||
for name, data in entries.items():
|
||||
archive.writestr(name, data)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class ManagedArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
model.__table__
|
||||
for model in (
|
||||
Account,
|
||||
User,
|
||||
Group,
|
||||
ChangeSequenceEntry,
|
||||
FileBlob,
|
||||
FileAsset,
|
||||
FileVersion,
|
||||
FileShare,
|
||||
FileConnectorPolicy,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.session = Session(self.engine, expire_on_commit=False)
|
||||
self.addCleanup(self.engine.dispose)
|
||||
self.addCleanup(self.session.close)
|
||||
self.objects = {}
|
||||
self.scopes = {"files:file:read", "files:file:download", "files:file:upload"}
|
||||
self.principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
has=lambda scope: scope in self.scopes,
|
||||
)
|
||||
self.settings = SimpleNamespace(
|
||||
file_upload_zip_max_bytes=10 * 1024 * 1024,
|
||||
file_archive_max_entries=10_000,
|
||||
file_archive_max_expanded_bytes=20 * 1024 * 1024,
|
||||
file_archive_max_expansion_ratio=100,
|
||||
file_archive_preview_ttl_seconds=1800,
|
||||
file_upload_max_bytes=1024 * 1024,
|
||||
)
|
||||
for module in ("uploads", "managed_archives"):
|
||||
self.enterContext(
|
||||
patch(f"govoplan_files.backend.routes.{module}.settings", self.settings)
|
||||
)
|
||||
self.enterContext(
|
||||
patch(
|
||||
"govoplan_files.backend.storage.files.user_group_ids", return_value=[]
|
||||
)
|
||||
)
|
||||
self.enterContext(
|
||||
patch(
|
||||
"govoplan_files.backend.storage.files._get_or_create_blob",
|
||||
side_effect=self.store_blob,
|
||||
)
|
||||
)
|
||||
self.enterContext(
|
||||
patch(
|
||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||
return_value=SimpleNamespace(
|
||||
name="test", get_bytes=lambda key: self.objects[key]
|
||||
),
|
||||
)
|
||||
)
|
||||
self.audit = self.enterContext(
|
||||
patch("govoplan_files.backend.route_support.audit_from_principal")
|
||||
)
|
||||
self.enterContext(
|
||||
patch(
|
||||
"govoplan_files.backend.route_support.asset_is_audit_relevant",
|
||||
return_value=False,
|
||||
)
|
||||
)
|
||||
self.enterContext(
|
||||
patch(
|
||||
"govoplan_files.backend.route_support._sent_campaign_asset_ids",
|
||||
return_value=set(),
|
||||
)
|
||||
)
|
||||
self.source = self.seed_source(archive_bytes())
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||
app.dependency_overrides[get_session] = lambda: self.session
|
||||
self.client = self.enterContext(TestClient(app))
|
||||
|
||||
def store_blob(self, session, *, tenant_id, data, content_type=None, **kwargs):
|
||||
checksum = hashlib.sha256(data).hexdigest()
|
||||
blob = FileBlob(
|
||||
tenant_id=tenant_id,
|
||||
storage_backend="test",
|
||||
storage_key=f"objects/{checksum}",
|
||||
checksum_sha256=checksum,
|
||||
size_bytes=len(data),
|
||||
content_type=content_type,
|
||||
ref_count=1,
|
||||
)
|
||||
session.add(blob)
|
||||
session.flush()
|
||||
self.objects[blob.storage_key] = data
|
||||
return blob
|
||||
|
||||
def seed_source(self, data, filename="source.zip"):
|
||||
stored = create_file_asset(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
owner_type="user",
|
||||
owner_id="user-1",
|
||||
user_id="user-1",
|
||||
filename=filename,
|
||||
data=data,
|
||||
folder="archives",
|
||||
)
|
||||
self.session.commit()
|
||||
return stored
|
||||
|
||||
def preview(self, **changes):
|
||||
return self.client.post(
|
||||
f"/api/v1/files/{self.source.asset.id}/archive-preview",
|
||||
json={
|
||||
"source_version_id": self.source.version.id,
|
||||
"path": "extracted",
|
||||
**changes,
|
||||
},
|
||||
)
|
||||
|
||||
def confirm(self, token, **changes):
|
||||
return self.client.post(
|
||||
f"/api/v1/files/{self.source.asset.id}/archive-confirm",
|
||||
json={
|
||||
"source_version_id": self.source.version.id,
|
||||
"path": "extracted",
|
||||
"preview_token": token,
|
||||
"selected_paths": ["folder/one.txt"],
|
||||
**changes,
|
||||
},
|
||||
)
|
||||
|
||||
def token(self):
|
||||
response = self.preview()
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()["preview_token"]
|
||||
|
||||
def assert_no_extraction(self):
|
||||
self.assertEqual(1, self.session.query(FileAsset).count())
|
||||
self.assertIsNone(self.source.asset.deleted_at)
|
||||
self.assertEqual(self.source.version.id, self.source.asset.current_version_id)
|
||||
self.audit.assert_not_called()
|
||||
|
||||
def test_preview_is_read_only_and_confirmation_extracts_only_selected_members(self):
|
||||
token = self.token()
|
||||
self.assert_no_extraction()
|
||||
original_data = self.objects[self.source.blob.storage_key]
|
||||
response = self.confirm(token)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
files = response.json()["files"]
|
||||
self.assertEqual(
|
||||
["extracted/folder/one.txt"], [file["display_path"] for file in files]
|
||||
)
|
||||
self.assertEqual(2, self.session.query(FileAsset).count())
|
||||
self.assertEqual(original_data, self.objects[self.source.blob.storage_key])
|
||||
self.assertEqual(self.source.version.id, self.source.asset.current_version_id)
|
||||
self.assertIsNone(self.source.asset.deleted_at)
|
||||
self.assertEqual(
|
||||
self.source.asset.id,
|
||||
files[0]["source_provenance"]["metadata"]["archive_source_file_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.source.version.id,
|
||||
files[0]["source_provenance"]["metadata"]["archive_source_version_id"],
|
||||
)
|
||||
self.audit.assert_called_once()
|
||||
|
||||
def test_all_three_permissions_are_required(self):
|
||||
token = self.token()
|
||||
for scope in tuple(self.scopes):
|
||||
with self.subTest(scope=scope):
|
||||
self.scopes.remove(scope)
|
||||
self.assertEqual(403, self.preview().status_code)
|
||||
self.assertEqual(403, self.confirm(token).status_code)
|
||||
self.scopes.add(scope)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_changed_source_version_is_rejected_before_read_or_write(self):
|
||||
token = self.token()
|
||||
self.source.asset.current_version_id = "replacement-version"
|
||||
self.session.commit()
|
||||
self.assertIn("version changed", self.confirm(token).text)
|
||||
self.assertEqual(400, self.preview().status_code)
|
||||
self.assertEqual(1, self.session.query(FileAsset).count())
|
||||
|
||||
def test_mismatched_version_or_blob_tenant_is_rejected(self):
|
||||
self.source.version.file_asset_id = "other-file"
|
||||
self.session.commit()
|
||||
self.assertIn("does not belong", self.preview().text)
|
||||
self.source.version.file_asset_id = self.source.asset.id
|
||||
self.source.blob.tenant_id = "other-tenant"
|
||||
self.session.commit()
|
||||
self.assertIn("does not belong", self.preview().text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_expired_preview_cannot_extract(self):
|
||||
with patch("cryptography.fernet.time.time", return_value=1):
|
||||
token = self.token()
|
||||
response = self.confirm(token)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertIn("expired", response.text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_source_refresh_during_verified_read_cannot_change_preview_or_extract(self):
|
||||
token = self.token()
|
||||
replacement_version = SimpleNamespace(id="refreshed-version")
|
||||
with patch(
|
||||
"govoplan_files.backend.routes.managed_archives.read_asset_bytes",
|
||||
return_value=(
|
||||
self.objects[self.source.blob.storage_key],
|
||||
replacement_version,
|
||||
self.source.blob,
|
||||
),
|
||||
):
|
||||
preview = self.preview()
|
||||
confirmed = self.confirm(token)
|
||||
for response in (preview, confirmed):
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertIn("version changed during the verified read", response.text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_revoked_shared_access_cannot_reuse_preview(self):
|
||||
self.source.asset.owner_user_id = "other-user"
|
||||
share = FileShare(
|
||||
tenant_id="tenant-1",
|
||||
file_asset_id=self.source.asset.id,
|
||||
target_type="user",
|
||||
target_id="user-1",
|
||||
permission="read",
|
||||
)
|
||||
self.session.add(share)
|
||||
self.session.commit()
|
||||
token = self.token()
|
||||
share.revoked_at = utcnow()
|
||||
self.session.commit()
|
||||
response = self.confirm(token)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertIn("No access", response.text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_deleted_or_cross_tenant_sources_are_rejected(self):
|
||||
token = self.token()
|
||||
self.source.asset.deleted_at = utcnow()
|
||||
self.session.commit()
|
||||
self.assertIn("File not found", self.confirm(token).text)
|
||||
self.source.asset.deleted_at = None
|
||||
self.source.asset.tenant_id = "other-tenant"
|
||||
self.session.commit()
|
||||
self.assertIn("File not found", self.confirm(token).text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_confirmation_is_bound_to_destination_actor_version_and_file(self):
|
||||
token = self.token()
|
||||
for change in (
|
||||
{"path": "elsewhere"},
|
||||
{"owner_id": "other-user"},
|
||||
{"source_version_id": "wrong"},
|
||||
):
|
||||
with self.subTest(change=change):
|
||||
response = self.confirm(token, **change)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertIn("does not match", response.text)
|
||||
self.principal.user.id = "other-user"
|
||||
self.assertIn("does not match", self.confirm(token).text)
|
||||
self.principal.user.id = "user-1"
|
||||
self.assertEqual(400, self.confirm("tampered-token").status_code)
|
||||
response = self.client.post(
|
||||
"/api/v1/files/other-file/archive-confirm",
|
||||
json={
|
||||
"source_version_id": self.source.version.id,
|
||||
"path": "extracted",
|
||||
"preview_token": token,
|
||||
"selected_paths": ["folder"],
|
||||
},
|
||||
)
|
||||
self.assertIn("does not match", response.text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_destination_ownership_is_checked_during_preview(self):
|
||||
response = self.preview(owner_id="other-user")
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertIn("No access to this user file space", response.text)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_connector_policy_is_rechecked_at_confirmation(self):
|
||||
self.source.asset.metadata_ = {
|
||||
"source_provenance": {
|
||||
"source_type": "webdav",
|
||||
"provider": "webdav",
|
||||
"external_id": "archive",
|
||||
}
|
||||
}
|
||||
self.session.commit()
|
||||
token = self.token()
|
||||
self.session.add(
|
||||
FileConnectorPolicy(
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
policy={"deny": {"providers": ["webdav"]}},
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
response = self.confirm(token)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
self.assertEqual(403, self.preview().status_code)
|
||||
self.assert_no_extraction()
|
||||
|
||||
def test_source_collision_rolls_back_all_destination_writes(self):
|
||||
self.source = self.seed_source(
|
||||
archive_bytes(
|
||||
{"a-first.txt": b"first", "collision.zip": b"cannot replace source"}
|
||||
),
|
||||
"collision.zip",
|
||||
)
|
||||
response = self.preview(path="archives")
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
result = self.confirm(
|
||||
response.json()["preview_token"],
|
||||
path="archives",
|
||||
selected_paths=["a-first.txt", "collision.zip"],
|
||||
)
|
||||
self.assertEqual(400, result.status_code, result.text)
|
||||
self.assertIn("already exists", result.text)
|
||||
self.assertEqual(2, self.session.query(FileAsset).count())
|
||||
self.assertEqual(self.source.version.id, self.source.asset.current_version_id)
|
||||
self.assertIsNone(self.source.asset.deleted_at)
|
||||
|
||||
def test_encrypted_zip_uses_the_same_password_and_selection_flow(self):
|
||||
self.source = self.seed_source(
|
||||
archive_bytes(password="correct horse"), "encrypted.zip"
|
||||
)
|
||||
preview = self.preview()
|
||||
self.assertTrue(preview.json()["requires_password"])
|
||||
self.assertFalse(preview.json()["password_verified"])
|
||||
token = preview.json()["preview_token"]
|
||||
self.assertEqual(400, self.confirm(token).status_code)
|
||||
self.assertEqual(400, self.preview(password="wrong").status_code)
|
||||
verified = self.preview(password="correct horse")
|
||||
self.assertTrue(verified.json()["password_verified"])
|
||||
response = self.confirm(
|
||||
verified.json()["preview_token"], password="correct horse"
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertNotIn("correct horse", response.text)
|
||||
self.assertNotIn("correct horse", str(self.audit.call_args_list))
|
||||
|
||||
def test_tar_reuses_the_same_confirmation_flow(self):
|
||||
self.source = self.seed_source(archive_bytes(tar=True), "source.tar.gz")
|
||||
preview = self.preview()
|
||||
self.assertEqual("tar.gz", preview.json()["archive_format"])
|
||||
self.assertEqual(200, self.confirm(preview.json()["preview_token"]).status_code)
|
||||
|
||||
def test_unsafe_members_and_size_limits_fail_before_extraction(self):
|
||||
self.settings.file_upload_zip_max_bytes = 1
|
||||
self.assertIn("size limit", self.preview().text)
|
||||
self.settings.file_upload_zip_max_bytes = 10 * 1024 * 1024
|
||||
self.source = self.seed_source(
|
||||
archive_bytes({"../escape": b"no"}), "unsafe.zip"
|
||||
)
|
||||
self.assertIn("Unsafe archive member", self.preview().text)
|
||||
self.audit.assert_not_called()
|
||||
|
||||
def test_quarantined_or_protected_sources_use_verified_read_path(self):
|
||||
self.source.blob.quarantined_at = utcnow()
|
||||
self.session.commit()
|
||||
self.assertIn("quarantin", self.preview().text.lower())
|
||||
self.source.blob.quarantined_at = None
|
||||
self.source.blob.encryption_envelope_id = "protected-envelope"
|
||||
self.session.commit()
|
||||
with patch(
|
||||
"govoplan_files.backend.storage.content_protection.unprotect_blob_content",
|
||||
side_effect=FileStorageError("Decryption is unavailable"),
|
||||
) as decrypt:
|
||||
response = self.preview()
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertIn("Decryption is unavailable", response.text)
|
||||
decrypt.assert_called_once()
|
||||
self.assert_no_extraction()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ STATIC_TOPIC_IDS = {
|
||||
"files.quick-access-and-product-area",
|
||||
"files.search.managed-content",
|
||||
"files.workflow.organize-managed-files",
|
||||
"files.workflow.unpack-managed-archive",
|
||||
"files.workflow.find-and-download-files",
|
||||
"files.workflow.share-managed-files",
|
||||
"files.workflow.delete-managed-files",
|
||||
@@ -44,6 +45,17 @@ class FilesManifestDocumentationTests(unittest.TestCase):
|
||||
def topic(self, topic_id: str):
|
||||
return self.topics[topic_id]
|
||||
|
||||
def test_workspace_action_locations_and_read_only_reload_are_documented_in_both_languages(self) -> None:
|
||||
topic = self.topic("files.quick-access-and-product-area")
|
||||
self.assertEqual({"user", "admin"}, set(topic.documentation_types))
|
||||
for label in ("Reload", "Create folder", "Upload", "Connections and imports", "Manage selection"):
|
||||
self.assertIn(label, topic.body)
|
||||
self.assertIn("never imports or synchronizes", topic.body)
|
||||
german = topic.translations["de"]["body"]
|
||||
for label in ("Neu laden", "Ordner erstellen", "Hochladen", "Verbindungen und Importe", "Auswahl verwalten"):
|
||||
self.assertIn(label, german)
|
||||
self.assertIn("niemals", german)
|
||||
|
||||
def test_static_topics_have_role_scope_module_and_link_contracts(self) -> None:
|
||||
self.assertEqual(STATIC_TOPIC_IDS, set(self.topics))
|
||||
self.assertEqual(
|
||||
|
||||
@@ -22,6 +22,7 @@ from govoplan_files.backend.routes.shares import router as shares_router
|
||||
from govoplan_files.backend.routes.spaces import router as spaces_router
|
||||
from govoplan_files.backend.routes.transfers import router as transfers_router
|
||||
from govoplan_files.backend.routes.uploads import router as uploads_router
|
||||
from govoplan_files.backend.routes.managed_archives import router as managed_archives_router
|
||||
|
||||
|
||||
class FilesRouterContractTests(unittest.TestCase):
|
||||
@@ -42,6 +43,7 @@ class FilesRouterContractTests(unittest.TestCase):
|
||||
lifecycle_router,
|
||||
listing_router,
|
||||
uploads_router,
|
||||
managed_archives_router,
|
||||
connector_settings_router,
|
||||
connector_io_router,
|
||||
connector_profiles_router,
|
||||
@@ -57,7 +59,7 @@ class FilesRouterContractTests(unittest.TestCase):
|
||||
actual = self._operation_keys(router)
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
self.assertEqual(63, len(actual))
|
||||
self.assertEqual(67, len(actual))
|
||||
self.assertFalse(
|
||||
[operation for operation, count in Counter(actual).items() if count > 1]
|
||||
)
|
||||
@@ -69,6 +71,10 @@ class FilesRouterContractTests(unittest.TestCase):
|
||||
|
||||
self.assertIn((("POST",), "/files/archive-preview"), routes)
|
||||
self.assertIn((("POST",), "/files/archive-confirm"), routes)
|
||||
self.assertIn((("GET",), "/files/archive-progress/{operation_id}"), routes)
|
||||
self.assertIn((("DELETE",), "/files/archive-staging/{stage_id}"), routes)
|
||||
self.assertIn((("POST",), "/files/{file_id}/archive-preview"), routes)
|
||||
self.assertIn((("POST",), "/files/{file_id}/archive-confirm"), routes)
|
||||
|
||||
def test_connector_routes_keep_existing_api_paths(self) -> None:
|
||||
routes = {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Response-only parity/query regression with isolated synthetic SQLite data.
|
||||
|
||||
This does not benchmark imports, mutate storage, or replace route authorization.
|
||||
The isolated schema uses string columns for external module identities instead
|
||||
of importing optional modules. All response, share-state and Campaign-use
|
||||
queries execute through the real Files ORM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import Column, MetaData, String, Table, create_engine, event, inspect
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.types import NullType
|
||||
|
||||
from govoplan_files.backend.db.models import (
|
||||
CampaignAttachmentUse,
|
||||
FileAsset,
|
||||
FileBlob,
|
||||
FileShare,
|
||||
FileVersion,
|
||||
)
|
||||
from govoplan_files.backend.route_support import _asset_list_response, _asset_response
|
||||
|
||||
|
||||
class UploadResponseBatchingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://")
|
||||
self.addCleanup(self.engine.dispose)
|
||||
metadata = MetaData()
|
||||
for model in (
|
||||
FileBlob,
|
||||
FileAsset,
|
||||
FileVersion,
|
||||
FileShare,
|
||||
CampaignAttachmentUse,
|
||||
):
|
||||
Table(
|
||||
model.__tablename__,
|
||||
metadata,
|
||||
*(
|
||||
Column(
|
||||
column.name,
|
||||
String(36)
|
||||
if isinstance(column.type, NullType)
|
||||
else column.type,
|
||||
primary_key=column.primary_key,
|
||||
nullable=column.nullable,
|
||||
default=column.default.arg
|
||||
if column.default is not None
|
||||
else None,
|
||||
server_default=column.server_default.arg
|
||||
if column.server_default is not None
|
||||
else None,
|
||||
)
|
||||
for column in model.__table__.columns
|
||||
),
|
||||
)
|
||||
metadata.create_all(self.engine)
|
||||
now = datetime.now(UTC)
|
||||
with self.engine.begin() as connection:
|
||||
|
||||
def seed(row):
|
||||
# Core inserts into separate fixture metadata avoid resolving
|
||||
# foreign identities or changing global ORM declarations.
|
||||
values = {
|
||||
attribute.columns[0].name: getattr(row, attribute.key)
|
||||
for attribute in inspect(type(row)).column_attrs
|
||||
if attribute.key in row.__dict__
|
||||
}
|
||||
connection.execute(metadata.tables[row.__tablename__].insert(), values)
|
||||
|
||||
for index in range(64):
|
||||
asset_id = f"asset-{index:03}"
|
||||
version_id = f"version-{index:03}"
|
||||
blob_id = f"blob-{index:03}"
|
||||
checksum = f"{index:064x}"
|
||||
owner_type = "group" if index % 2 else "user"
|
||||
seed(
|
||||
FileBlob(
|
||||
id=blob_id,
|
||||
tenant_id="tenant-fixture",
|
||||
storage_backend="local",
|
||||
storage_key=f"synthetic/{blob_id}",
|
||||
checksum_sha256=checksum,
|
||||
size_bytes=index + 10,
|
||||
content_type="text/plain",
|
||||
ref_count=1,
|
||||
)
|
||||
)
|
||||
seed(
|
||||
FileAsset(
|
||||
id=asset_id,
|
||||
tenant_id="tenant-fixture",
|
||||
owner_type=owner_type,
|
||||
owner_user_id="user-fixture" if owner_type == "user" else None,
|
||||
owner_group_id="group-fixture"
|
||||
if owner_type == "group"
|
||||
else None,
|
||||
current_version_id=version_id,
|
||||
display_path=f"imported/{index:03}.txt",
|
||||
filename=f"{index:03}.txt",
|
||||
description=f"Synthetic response {index}",
|
||||
retained_until=now + timedelta(days=365) if index % 2 else None,
|
||||
legal_hold=bool(index % 3),
|
||||
lifecycle_revision=index + 2,
|
||||
lifecycle_reason=f"Retention decision {index}",
|
||||
deleted_at=now - timedelta(days=1) if index % 7 == 0 else None,
|
||||
metadata_={
|
||||
"fixture": index,
|
||||
"source_provenance": {
|
||||
"source_type": "archive",
|
||||
"revision": f"source-{index}",
|
||||
"metadata": {
|
||||
"archive_source_file_id": "synthetic-archive"
|
||||
},
|
||||
},
|
||||
"source_revision": f"source-{index}",
|
||||
},
|
||||
)
|
||||
)
|
||||
seed(
|
||||
FileVersion(
|
||||
id=version_id,
|
||||
tenant_id="tenant-fixture",
|
||||
file_asset_id=asset_id,
|
||||
blob_id=blob_id,
|
||||
version_number=1,
|
||||
filename_at_upload=f"{index:03}.txt",
|
||||
display_path_at_upload=f"imported/{index:03}.txt",
|
||||
size_bytes=index + 10,
|
||||
checksum_sha256=checksum,
|
||||
content_type="text/plain",
|
||||
)
|
||||
)
|
||||
for rank, state in enumerate(("active", "expired", "revoked")):
|
||||
seed(
|
||||
FileShare(
|
||||
id=f"share-{index:03}-{state}",
|
||||
tenant_id="tenant-fixture",
|
||||
file_asset_id=asset_id,
|
||||
target_type="group" if rank == 0 else "user",
|
||||
target_id=f"target-{state}",
|
||||
permission="write" if rank == 0 else "read",
|
||||
created_by_user_id="user-fixture",
|
||||
created_at=now - timedelta(hours=rank + 1),
|
||||
expires_at=now - timedelta(days=1)
|
||||
if state == "expired"
|
||||
else None,
|
||||
revoked_at=now - timedelta(days=1)
|
||||
if state == "revoked"
|
||||
else None,
|
||||
revoked_by_user_id="user-fixture"
|
||||
if state == "revoked"
|
||||
else None,
|
||||
)
|
||||
)
|
||||
# Duplicate sent evidence must still produce one response;
|
||||
# built-only usage and missing usage are not audit-relevant.
|
||||
stages = (
|
||||
("sent", "sent")
|
||||
if index % 3 == 0
|
||||
else ("built",)
|
||||
if index % 3 == 1
|
||||
else ()
|
||||
)
|
||||
for use_index, stage in enumerate(stages):
|
||||
seed(
|
||||
CampaignAttachmentUse(
|
||||
id=f"use-{index:03}-{use_index}",
|
||||
tenant_id="tenant-fixture",
|
||||
campaign_id="campaign-fixture",
|
||||
campaign_version_id="campaign-version-fixture",
|
||||
campaign_job_id=f"job-{use_index}",
|
||||
file_asset_id=asset_id,
|
||||
file_version_id=version_id,
|
||||
file_blob_id=blob_id,
|
||||
filename_used=f"{index:03}.txt",
|
||||
checksum_sha256=checksum,
|
||||
size_bytes=index + 10,
|
||||
content_type="text/plain",
|
||||
use_stage=stage,
|
||||
)
|
||||
)
|
||||
|
||||
def render(self, *, batched: bool, include_shares: bool):
|
||||
statements = []
|
||||
|
||||
def record_select(
|
||||
connection, cursor, statement, parameters, context, executemany
|
||||
):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
statements.append(statement)
|
||||
|
||||
# A fresh session ensures neither path receives preloaded versions or
|
||||
# blobs. Loading the already-authorized asset list is deliberately
|
||||
# outside the measurement; this measures response construction only.
|
||||
with Session(self.engine) as session:
|
||||
assets = session.query(FileAsset).order_by(FileAsset.id).all()
|
||||
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||
try:
|
||||
responses = (
|
||||
_asset_list_response(session, assets, include_shares=include_shares)
|
||||
if batched
|
||||
else [
|
||||
_asset_response(session, asset, include_shares=include_shares)
|
||||
for asset in assets
|
||||
]
|
||||
)
|
||||
result = [response.model_dump(mode="json") for response in responses]
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||
return result, statements
|
||||
|
||||
def test_batched_response_matches_all_metadata_lifecycle_shares_and_audit_flags(
|
||||
self,
|
||||
):
|
||||
for include_shares in (False, True):
|
||||
with self.subTest(include_shares=include_shares):
|
||||
batched, _ = self.render(batched=True, include_shares=include_shares)
|
||||
individual, _ = self.render(
|
||||
batched=False, include_shares=include_shares
|
||||
)
|
||||
self.assertEqual(individual, batched)
|
||||
self.assertEqual(64, len(batched))
|
||||
for index, item in enumerate(batched):
|
||||
self.assertEqual(f"asset-{index:03}", item["id"])
|
||||
self.assertEqual(index + 2, item["lifecycle_revision"])
|
||||
self.assertEqual(
|
||||
f"Retention decision {index}", item["lifecycle_reason"]
|
||||
)
|
||||
self.assertEqual(
|
||||
bool(index % 2), item["retained_until"] is not None
|
||||
)
|
||||
self.assertEqual(bool(index % 3), item["legal_hold"])
|
||||
self.assertEqual(index % 3 == 0, item["audit_relevant"])
|
||||
self.assertEqual(index % 7 == 0, item["deleted_at"] is not None)
|
||||
self.assertEqual(f"source-{index}", item["source_revision"])
|
||||
if include_shares:
|
||||
self.assertEqual(
|
||||
[
|
||||
f"share-{index:03}-{state}"
|
||||
for state in ("active", "expired", "revoked")
|
||||
],
|
||||
[share["id"] for share in item["shares"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
[True, False, False],
|
||||
[share["active"] for share in item["shares"]],
|
||||
)
|
||||
else:
|
||||
self.assertEqual([], item["shares"])
|
||||
|
||||
def test_64_asset_response_batches_real_selects_instead_of_querying_per_item(self):
|
||||
for include_shares, per_item_queries, batch_queries in (
|
||||
(False, 192, 2),
|
||||
(True, 256, 3),
|
||||
):
|
||||
with self.subTest(include_shares=include_shares):
|
||||
individual, individual_queries = self.render(
|
||||
batched=False, include_shares=include_shares
|
||||
)
|
||||
batched, batched_queries = self.render(
|
||||
batched=True, include_shares=include_shares
|
||||
)
|
||||
self.assertEqual(individual, batched)
|
||||
self.assertEqual(per_item_queries, len(individual_queries))
|
||||
self.assertEqual(batch_queries, len(batched_queries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user