Archive location (Gitea issue 7): ARCHIVE_ROOT, Admin file-locations API, archive-copy job, doctor check

A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker.
A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 17:50:04 -06:00
co-authored by Claude Opus 5.5
parent 3fdf063b70
commit da31b0a87a
21 changed files with 1537 additions and 11 deletions
+453
View File
@@ -0,0 +1,453 @@
"""The archive location (Gitea issue 7): the path rules, the check, the File locations API, the archive-copy job
(copy, idempotence, failure and backoff, the Admin condition, old seals queued) and filing with the archive off or
broken. Synthetic data only; the archive root is a temp folder."""
from __future__ import annotations
import hashlib
import json
import os
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from closehelpers import file_packet, filed, q1, qall, rich_complete, vclose
from fakedir import FakeDirectory
from pshelpers import make_settings, needs_pg
from shophelpers import Shop, ok, open_shop, refused, run
from packetstation.domain import archive
from packetstation.settings import Settings
from packetstation.worker import jobs, runs
from packetstation.worker.jobs import archive_copy
from packetstation.worker.scheduler import Scheduler
URL = "/api/admin/file-locations"
def share(tmp_path: Path) -> Path:
root = tmp_path / "share"
(root / "packets").mkdir(parents=True, exist_ok=True)
return root
# ---------------------------------------------------------------- settings and path rules
def test_archive_root_blank_is_off(tmp_path: Path) -> None:
assert make_settings(tmp_path).archive_root is None
assert make_settings(tmp_path, archive_root="").archive_root is None
assert make_settings(tmp_path, archive_root=" ").archive_root is None
assert archive.root_of(make_settings(tmp_path, archive_root=" ")) is None
assert make_settings(tmp_path, archive_root="/archive").archive_root == Path("/archive")
@pytest.mark.parametrize(
("given", "why"),
[
("", "Enter a folder"),
(" ", "Enter a folder"),
("..", "'..'"),
("packets/../..", "'..'"),
("a\\..\\b", "'..'"),
("pack\x00ets", "NUL"),
("x" * 401, "longer than"),
],
)
def test_relative_dir_refusals(tmp_path: Path, given: str, why: str) -> None:
with pytest.raises(archive.PathRefused, match=why):
archive.relative_dir(share(tmp_path), given)
def test_relative_dir_clean_and_absolute(tmp_path: Path) -> None:
root = share(tmp_path)
assert archive.relative_dir(root, "packets") == "packets"
assert archive.relative_dir(root, "./packets//2026/") == "packets/2026"
assert archive.relative_dir(root, "packets\\2026") == "packets/2026"
assert archive.relative_dir(root, str(root / "packets" / "2026")) == "packets/2026"
with pytest.raises(archive.PathRefused, match="must lie under"):
archive.relative_dir(root, str(tmp_path / "elsewhere"))
with pytest.raises(archive.PathRefused, match="must lie under"):
archive.relative_dir(root, "/etc")
with pytest.raises(archive.PathRefused, match="not the root"):
archive.relative_dir(root, str(root))
def test_resolve_dir(tmp_path: Path) -> None:
root = share(tmp_path)
rel, real = archive.resolve_dir(root, "packets")
assert rel == "packets" and real == (root / "packets").resolve()
with pytest.raises(archive.PathRefused, match="does not exist"):
archive.resolve_dir(root, "nope")
(root / "afile").write_bytes(b"x")
with pytest.raises(archive.PathRefused, match="not a folder"):
archive.resolve_dir(root, "afile")
with pytest.raises(archive.PathRefused, match="is not there"):
archive.resolve_dir(tmp_path / "unmounted", "packets")
def test_resolve_dir_refuses_a_link_out(tmp_path: Path) -> None:
root = share(tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
try:
os.symlink(outside, root / "escape", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("this OS or user cannot make symlinks")
with pytest.raises(archive.PathRefused, match="outside the archive root"):
archive.resolve_dir(root, "escape")
(outside / "deep").mkdir()
with pytest.raises(archive.PathRefused, match="outside the archive root"):
archive.resolve_dir(root, "escape/deep")
# a link that stays inside the root is fine
os.symlink(root / "packets", root / "inside", target_is_directory=True)
assert archive.resolve_dir(root, "inside")[1] == (root / "packets").resolve()
def test_check_and_probe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = share(tmp_path)
rel, res = archive.check(root, "packets")
assert rel == "packets" and res.ok and res.free_bytes and res.resolved == str((root / "packets").resolve())
assert list((root / "packets").iterdir()) == [] # the probe is gone
assert archive.check(None, "packets") == (None, archive.CheckResult(False, None, None, archive.OFF_MESSAGE))
rel, res = archive.check(root, "../x")
assert rel is None and not res.ok and "'..'" in res.message
def broken_fsync(fd: int) -> None:
raise OSError(5, "Input/output error")
monkeypatch.setattr(archive.os, "fsync", broken_fsync)
rel, res = archive.check(root, "packets")
assert rel == "packets" and not res.ok and "Cannot write" in res.message and "Input/output error" in res.message
assert list((root / "packets").iterdir()) == []
def test_backoff() -> None:
assert [archive.backoff(n) for n in (1, 2, 3, 4, 9)] == [
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=60),
timedelta(minutes=60),
timedelta(minutes=60),
]
def test_safe_names() -> None:
assert archive.safe_part("L5/12:3", "x") == "L5_12_3"
assert archive.safe_part("..", "x") == "x"
taken: set[str] = set()
assert [archive._unique(n, taken) for n in ("a.pdf", "A.pdf", "a.pdf", "manifest.json", "b")] == [
"a.pdf",
"A (2).pdf",
"a (3).pdf",
"manifest (2).json",
"b",
]
# ---------------------------------------------------------------- with PostgreSQL
@pytest.fixture
def on_settings(pg_settings: Settings, tmp_path: Path) -> Settings:
return pg_settings.model_copy(update={"archive_root": share(tmp_path)})
@pytest.fixture
def shop(on_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(on_settings, fake_dir)
@pytest.fixture
def off_shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def run_copy(settings: Settings) -> dict[str, Any]:
sch = Scheduler(settings, build="pytest", jobs={archive.JOB: archive_copy.SPEC})
return runs.run_job(sch, archive.JOB, "schedule")
def save_dir(settings: Settings, archive_dir: str = "packets") -> None:
run(
settings,
"INSERT INTO settings (key, value) VALUES ('files', jsonb_build_object('archive_dir', %s::text))"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
(archive_dir,),
)
def copies(settings: Settings) -> list[Any]:
return qall(settings, "SELECT seal_id, status, attempts, last_error, next_try_at, folder FROM archive_copies")
def audits(settings: Settings, action: str) -> list[dict[str, Any]]:
return [r[0] for r in qall(settings, "SELECT detail FROM audit_events WHERE action = %s ORDER BY id", (action,))]
def sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
@needs_pg
def test_api_off(off_shop: Shop) -> None:
ann = off_shop("admin.ann")
body = ok(ann.get(URL))
assert body == {
"enabled": False,
"root": None,
"archive_dir": None,
"version": 0,
"resolved": None,
"last_check": None,
"copies": {"pending": 0, "failed": 0, "done": 0, "recent_failures": []},
}
for r in (
ann.post(f"{URL}/check", json={"archive_dir": "packets"}),
ann.put(URL, json={"archive_dir": "packets", "version": 0}),
ann.post(f"{URL}/retry"),
):
refused(r, 409, "archive_off", archive.OFF_MESSAGE)
# Admin only, password session
assert off_shop("clerk.clara").get(URL).status_code == 403
@needs_pg
def test_api_check_save_and_retry(shop: Shop) -> None:
s = shop.settings
root = Path(str(s.archive_root))
ann = shop("admin.ann")
body = ok(ann.get(URL))
assert body["enabled"] is True and body["root"] == str(root) and body["archive_dir"] is None
refused(ann.post(f"{URL}/check", json={"archive_dir": "../etc"}), 422, "bad_path")
refused(ann.post(f"{URL}/check", json={"archive_dir": "missing"}), 422, "bad_path")
good = ok(ann.post(f"{URL}/check", json={"archive_dir": str(root / "packets")}))
assert good["ok"] is True and good["resolved"] == str((root / "packets").resolve()) and good["free_bytes"] > 0
assert ok(ann.get(URL))["archive_dir"] is None # the check saves nothing
checks = audits(s, "archive.checked")
assert [(c["source"], c["ok"]) for c in checks] == [("admin", False), ("admin", False), ("admin", True)]
assert checks[2]["archive_dir"] == "packets"
refused(ann.put(URL, json={"archive_dir": "missing", "version": 0}), 422, "bad_path")
refused(ann.put(URL, json={"archive_dir": "/elsewhere", "version": 0}), 422, "bad_path")
saved = ok(ann.put(URL, json={"archive_dir": str(root / "packets"), "version": 0}))
assert saved["archive_dir"] == "packets" and saved["version"] == 1
assert saved["resolved"] == str((root / "packets").resolve())
assert saved["last_check"]["ok"] is True and saved["last_check"]["by"] == "Ann Admin"
assert set(saved["last_check"]) == {"ok", "at", "by", "message"}
conflict = refused(ann.put(URL, json={"archive_dir": "packets", "version": 0}), 409, "version_conflict")
assert conflict["current"]["version"] == 1
(root / "other").mkdir()
assert ok(ann.put(URL, json={"archive_dir": "other", "version": 1}))["archive_dir"] == "other"
assert [a["archive_dir"] for a in audits(s, "admin.file_locations_updated")] == [
{"from": None, "to": "packets"},
{"from": "packets", "to": "other"},
]
# a check that fails after the path rules (the probe) answers check_failed on save and keeps the old folder
(root / "ro").mkdir()
orig = archive.probe
try:
archive.probe = lambda folder: archive.CheckResult(False, str(folder), None, "Cannot write in it.")
r = refused(ann.put(URL, json={"archive_dir": "ro", "version": 2}), 422, "check_failed", "Cannot write in it.")
assert r["check"]["ok"] is False
finally:
archive.probe = orig
assert ok(ann.get(URL))["archive_dir"] == "other"
# saving asks for a copy run; Retry now resets the backoff and asks again (202)
r = ann.post(f"{URL}/retry")
assert r.status_code == 202 and r.json()["requested"] is True
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 3
assert audits(s, "admin.archive_retry")
assert shop("clerk.clara").post(f"{URL}/retry").status_code == 403
@needs_pg
def test_copy_job(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
root = Path(str(s.archive_root))
events: list[tuple[str, str, dict[str, Any]]] = []
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: events.append(("raise", t, kw)))
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: events.append(("resolve", t, kw)))
# no folder saved: the job idles, nothing queued
row = run_copy(s)
assert row["status"] == "ok" and row["counts"]["skipped"] == "no archive folder saved"
save_dir(s)
pkt = rich_complete(shop)
pid = pkt["id"]
# rich_complete keeps one short open (a packet from before change C7); close it so FILE accepts
run(s, "UPDATE bom_shorts SET closed_at = now() WHERE packet_id = %s", (pid,))
out = ok(file_packet(shop, vclose(shop, pkt)))
assert out["status"] == "filed"
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy' AND claimed_at IS NULL")[0] == 1
seal_id, book_id = q1(s, "SELECT id, databook_asset_id FROM packet_seals WHERE packet_id = %s", (pid,))
row = run_copy(s)
assert row["status"] == "ok", row
assert row["trigger"] == "request" and row["counts"]["queued"] == 1 and row["counts"]["copied"] == 1
((sid, status, attempts, err, nxt, folder),) = copies(s)
assert (sid, status, attempts, err, nxt) == (seal_id, "done", 1, None, None)
project, unit = q1(
s,
"SELECT pr.number, pr.number || u.lot FROM packets p JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id WHERE p.id = %s",
(pid,),
)
assert folder.startswith(f"packets/{project}/{unit}/") and folder.endswith(f"-seal{seal_id}")
where = root.joinpath(*folder.split("/"))
manifest = json.loads((where / "manifest.json").read_text(encoding="utf-8"))
assert manifest["packet_id"] == pid and manifest["seal_id"] == seal_id and manifest["project"] == project
assert manifest["unit"] == unit and manifest["build"] == s.ps_build and manifest["filed_by"] == "Clara Clerk"
assert manifest["sealed_by"] == "Clara Clerk" and len(manifest["seal_hash"]) == 64 and manifest["key_id"]
kinds = [f["kind"] for f in manifest["files"]]
assert kinds[0] == "databook" and manifest["files"][0]["asset_id"] == str(book_id)
assert {"databook", "page", "doc", "qc_doc", "mtr"} <= set(kinds), kinds
assert kinds.count("mtr") == 2
names = [f["name"] for f in manifest["files"]]
assert len({n.lower() for n in names}) == len(names)
assert sorted(p.name for p in where.iterdir()) == sorted([*names, "manifest.json"])
for f in manifest["files"]:
assert sha(where / f["name"]) == f["sha256"] and (where / f["name"]).stat().st_size == f["size"]
assert q1(s, "SELECT manifest_sha256 FROM archive_copies")[0] == sha(where / "manifest.json")
(done,) = audits(s, "archive.copied")
assert done["seal_id"] == seal_id and done["folder"] == folder and done["files"] == len(names)
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
# idempotent: a second run copies nothing; a row set back to pending finds the identical files and is done
events.clear()
assert run_copy(s)["counts"]["copied"] == 0
run(s, "UPDATE archive_copies SET status = 'pending'")
before = {p.name: p.stat().st_mtime_ns for p in where.iterdir()}
assert run_copy(s)["counts"]["copied"] == 1
assert {p.name: p.stat().st_mtime_ns for p in where.iterdir()} == before
assert copies(s)[0][1] == "done"
# a different file in the way: never overwritten; failed with a 5-minute backoff, then 15, then 60 and raised
run(s, "UPDATE archive_copies SET status = 'pending', folder = NULL, done_at = NULL, attempts = 0")
(where / "manifest.json").unlink()
victim = where / names[1]
victim.write_bytes(b"something else")
events.clear()
t0 = datetime.now(UTC)
row = run_copy(s)
assert row["status"] == "degraded" and row["counts"]["failed"] == 1
_, status, attempts, err, nxt, _ = copies(s)[0]
assert (status, attempts) == ("failed", 1) and "not overwritten" in err and names[1] in err
assert timedelta(minutes=4) < nxt - t0 < timedelta(minutes=6)
assert victim.read_bytes() == b"something else" and not (where / "manifest.json").exists()
assert events == [] # one failure: not raised yet, not cleared either
assert run_copy(s)["counts"]["failed"] == 0 # not due
for n, wait in ((2, 15), (3, 60)):
run(s, "UPDATE archive_copies SET next_try_at = now() - interval '1 second'")
t0 = datetime.now(UTC)
run_copy(s)
_, status, attempts, _, nxt, _ = copies(s)[0]
assert (status, attempts) == ("failed", n)
assert timedelta(minutes=wait - 1) < nxt - t0 < timedelta(minutes=wait + 1)
raised = [e for e in events if e[0] == "raise"]
assert len(raised) == 1 and raised[0][1] == "archive_failed" and raised[0][2]["subject"] == "archive"
assert raised[0][2]["condition"] is True and f"seal {seal_id}" in raised[0][2]["body"]
assert len(audits(s, "archive.copy_failed")) == 3
st = ok(shop("admin.ann").get(URL))["copies"]
assert (st["failed"], st["done"], st["pending"]) == (1, 0, 0)
(f,) = st["recent_failures"]
assert (
f["seal_id"] == seal_id
and f["packet_id"] == pid
and f["attempts"] == 3
and "not overwritten" in f["last_error"]
)
assert f["packet_label"].startswith(project) and f["next_try_at"]
# the Admin removes the stray file and presses Retry now: done, condition cleared
victim.unlink()
events.clear()
assert shop("admin.ann").post(f"{URL}/retry").status_code == 202
row = run_copy(s)
assert row["counts"]["copied"] == 1 and copies(s)[0][1] == "done"
assert sha(victim) == manifest["files"][1]["sha256"]
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
@needs_pg
def test_old_seals_are_queued_when_the_archive_is_turned_on(off_shop: Shop, tmp_path: Path) -> None:
s = off_shop.settings
pkt = filed(off_shop)
# archive off: FILE asks for nothing, and the job does nothing
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
assert run_copy(s)["counts"] == {"enabled": False, "queued": 0, "copied": 0, "failed": 0}
assert copies(s) == []
on = s.model_copy(update={"archive_root": share(tmp_path)})
save_dir(on)
row = run_copy(on)
assert row["counts"]["queued"] == 1 and row["counts"]["copied"] == 1
seal = q1(s, "SELECT id FROM packet_seals WHERE packet_id = %s", (pkt["id"],))[0]
assert copies(s)[0][:2] == (seal, "done")
@needs_pg
def test_filing_succeeds_with_the_archive_broken(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
root = Path(str(s.archive_root))
events: list[tuple[str, str, dict[str, Any]]] = []
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: events.append(("raise", t, kw)))
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: events.append(("resolve", t, kw)))
save_dir(s, "gone") # saved, then the folder went away
pkt = filed(shop)
assert pkt["status"] == "filed"
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 1
row = run_copy(s)
assert row["status"] == "degraded" and "does not exist" in row["counts"]["check"]
assert copies(s) == []
((kind, etype, kw),) = events
assert (kind, etype, kw["subject"], kw["condition"]) == ("raise", "archive_failed", "archive", True)
checks = audits(s, "archive.checked")
assert [(c["source"], c["ok"]) for c in checks] == [("job", False)]
run_copy(s)
assert len(audits(s, "archive.checked")) == 1 # the same result again is not audited again
# the folder comes back: the copy is made and the condition cleared
(root / "gone").mkdir()
events.clear()
row = run_copy(s)
assert row["status"] == "ok" and row["counts"]["copied"] == 1
assert events == [("resolve", "archive_failed", {"subject": "archive", "title": "Archive copy failing"})]
assert [(c["source"], c["ok"]) for c in audits(s, "archive.checked")] == [("job", False), ("job", True)]
@needs_pg
def test_filing_succeeds_when_the_request_fails(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
s = shop.settings
save_dir(s)
def boom(*a: Any, **k: Any) -> None:
raise RuntimeError("no requests today")
monkeypatch.setattr(archive, "request_copy", boom)
pkt = filed(shop)
assert pkt["status"] == "filed"
assert q1(s, "SELECT count(*) FROM job_requests WHERE job = 'archive-copy'")[0] == 0
assert run_copy(s)["counts"]["copied"] == 1 # the scheduled run picks it up
@needs_pg
def test_doctor_archive_check(pg_settings: Settings, tmp_path: Path) -> None:
from packetstation import doctor
assert doctor.check_archive(pg_settings)[0].status == "SKIP"
on = pg_settings.model_copy(update={"archive_root": share(tmp_path)})
(c,) = doctor.check_archive(on)
assert c.status == "SKIP" and "no archive folder saved" in c.detail
save_dir(on)
(c,) = doctor.check_archive(on)
assert c.status == "PASS" and "writable" in c.detail
save_dir(on, "gone")
(c,) = doctor.check_archive(on)
assert c.status == "FAIL" and "does not exist" in c.detail
assert [(a["source"], a["ok"]) for a in audits(on, "archive.checked")] == [("doctor", True), ("doctor", False)]
+1 -1
View File
@@ -143,7 +143,7 @@ def test_get_seeds_every_rule(c: TestClient, base: Settings) -> None:
body = c.get(ADMIN).json()
assert body["dry_run"] is True and body["subject_prefix"] == "[Packet Station TEST]"
assert body["smtp"] == {"host": base.smtp_host, "port": base.smtp_port, "from": base.smtp_from, "starttls": True}
assert len(body["rules"]) == 13
assert len(body["rules"]) == 14
r = next(x for x in body["rules"] if x["event_type"] == "drawing_failing")
assert r["label"] == "Drawing lookup failing" and r["param"] == {"name": "hours", "value": 24, "default": 24}
assert r["enabled"] is True and r["delivery"] == "immediate" and r["digest_time"] == "07:00"
+3 -3
View File
@@ -136,12 +136,12 @@ def test_one_shot_dedupe_and_unknown_type(s: Settings, marker: int) -> None:
def test_seed_to_admins_is_idempotent(s: Settings, marker: int) -> None:
with app_conn(s) as conn:
added = seed(conn, s)
assert len(added) == 13
assert len(added) == 14
rows = conn.execute(
"SELECT r.event_type, r.enabled, r.delivery::text, r.digest_time::text, r.param, c.kind::text,"
" c.group_dn, c.group_name FROM notification_rules r JOIN notification_recipients c USING (event_type)"
).fetchall()
assert len(rows) == 13
assert len(rows) == 14
for r in rows:
assert r[1] is True and r[2] == "immediate" and r[3] == "07:00:00" and r[5] == "group"
assert r[6] == f"CN=PS-Admins,{s.ldap_group_base_dn}" and r[7] == "PS-Admins"
@@ -522,7 +522,7 @@ def test_notify_send_job(s: Settings, marker: int, relay: FakeSmtp, ndir: Notify
with app_conn(s) as conn:
ctx = ctx_for(conn, build, ndir)
counts = notify_send.run(ctx)
assert counts["seeded"] == 13 and counts["version_deployed"] == "sha-job" and counts["sent"] == 1
assert counts["seeded"] == 14 and counts["version_deployed"] == "sha-job" and counts["sent"] == 1
assert counts["drawings_failing"] == 0 and counts["co_waiting"] == 0
again = notify_send.run(ctx)
assert "version_deployed" not in again and "drawings_failing" not in again # watch waits 15 minutes
+1 -1
View File
@@ -44,7 +44,7 @@ def mail_settings(tmp: Path, server: FakeSmtp, ca: Path | None, **over: object)
def test_catalog() -> None:
assert len(EVENT_TYPES) == 13
assert len(EVENT_TYPES) == 14
assert SECURITY_TYPES == {"security_alert", "signin_burst", "integrity_failure"}
assert EVENT_TYPES["drawing_failing"].param == "hours" and EVENT_TYPES["drawing_failing"].param_default == 24
assert EVENT_TYPES["co_waiting"].param == "days" and EVENT_TYPES["co_waiting"].param_default == 3
+17
View File
@@ -414,6 +414,7 @@ def test_feed_tables_and_app_grants(pg_settings: Settings) -> None:
jobs = [r[0] for r in conn.execute("SELECT job FROM job_schedule ORDER BY job").fetchall()]
assert jobs == [
"ad-reconcile",
"archive-copy",
"backup-check",
"drawing-lookup",
"housekeeping",
@@ -632,3 +633,19 @@ def test_qc_columns_checks_and_items(pg_settings: Settings) -> None:
"ph_clean",
"ph_qty",
]
def test_archive_copies_grants(pg_settings: Settings) -> None:
"""0012: the app reads, queues and updates archive copies and never deletes one."""
with app_conn(pg_settings) as conn:
for priv, want in (
("SELECT", True),
("INSERT", True),
("UPDATE", True),
("DELETE", False),
("TRUNCATE", False),
):
got = one(conn, "SELECT has_table_privilege('ps_app', 'archive_copies', %s)", (priv,))
assert got is want, priv
with pytest.raises(psycopg.errors.CheckViolation):
conn.execute("INSERT INTO archive_copies (seal_id, packet_id, status) VALUES (1, 1, 'done')")