Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
169 lines
7.8 KiB
Python
169 lines
7.8 KiB
Python
"""The worker's job API (/api/jobs) and the Admin feed settings and resource map (/api/admin/feed)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
from pshelpers import app_conn, needs_pg, sign_in
|
|
|
|
from packetstation import db
|
|
from packetstation.settings import Settings
|
|
|
|
pytestmark = needs_pg
|
|
|
|
|
|
def listen(settings: Settings, channel: str) -> Any:
|
|
conn = db.connect_as("worker", settings, autocommit=True)
|
|
conn.execute(f"LISTEN {channel}")
|
|
return conn
|
|
|
|
|
|
def notes(conn: Any) -> list[Any]:
|
|
return list(conn.notifies(timeout=3, stop_after=1))
|
|
|
|
|
|
def test_jobs_list_and_run_now(pg_client: TestClient, pg_settings: Settings) -> None:
|
|
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
|
r = pg_client.get("/api/jobs")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
jobs = [j["job"] for j in body["jobs"]]
|
|
assert {"drawing-lookup", "housekeeping", "visual-feed", "ad-reconcile", "notify-send"} <= set(jobs)
|
|
assert jobs == sorted(jobs)
|
|
assert body["heartbeat"]["stale"] is True and body["heartbeat"]["at"] is None
|
|
assert all(j["last_run"] is None and j["pending"] == 0 for j in body["jobs"])
|
|
|
|
listener = listen(pg_settings, "ps_job_request")
|
|
try:
|
|
r = pg_client.post("/api/jobs/visual-feed/run", json={"note": "check"})
|
|
assert r.status_code == 200 and r.json() == {"requested": True, "pending": 1}, r.text
|
|
got = notes(listener)
|
|
assert got and json.loads(got[0].payload) == {"job": "visual-feed"}
|
|
finally:
|
|
listener.close()
|
|
r = pg_client.post("/api/jobs/visual-feed/run")
|
|
assert r.json()["pending"] == 2
|
|
with app_conn(pg_settings) as conn:
|
|
req = conn.execute(
|
|
"SELECT r.note, u.sam_account FROM job_requests r JOIN users u ON u.id = r.requested_by ORDER BY r.id"
|
|
).fetchall()
|
|
assert req == [("check", "clerk.clara"), (None, "clerk.clara")]
|
|
audit = conn.execute(
|
|
"SELECT role_snapshot, subject_id FROM audit_events WHERE action = 'job.run_requested'"
|
|
).fetchall()
|
|
assert len(audit) == 2 and audit[0][1] == "visual-feed"
|
|
assert [j["pending"] for j in pg_client.get("/api/jobs").json()["jobs"] if j["job"] == "visual-feed"] == [2]
|
|
|
|
|
|
def test_jobs_unknown_and_refused(pg_client: TestClient) -> None:
|
|
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
|
assert pg_client.post("/api/jobs/no-such/run").status_code == 404
|
|
assert pg_client.get("/api/jobs/no-such/runs").status_code == 404
|
|
assert pg_client.post("/api/jobs/DROP TABLE/run").status_code == 404
|
|
assert sign_in(pg_client, "wh.wade").status_code == 200
|
|
assert pg_client.get("/api/jobs").status_code == 403
|
|
assert pg_client.post("/api/jobs/visual-feed/run").status_code == 403
|
|
|
|
|
|
def test_job_runs_listing(pg_client: TestClient, pg_settings: Settings) -> None:
|
|
with app_conn(pg_settings) as conn:
|
|
for i in range(12):
|
|
conn.execute(
|
|
"INSERT INTO job_runs (job, trigger, status, counts, finished_at) VALUES"
|
|
" ('visual-feed', 'schedule', 'ok', %s, now())",
|
|
(json.dumps({"new_drafts": i}),),
|
|
)
|
|
assert sign_in(pg_client, "admin.ann").status_code == 200
|
|
runs = pg_client.get("/api/jobs/visual-feed/runs").json()
|
|
assert len(runs) == 10 and runs[0]["counts"] == {"new_drafts": 11}
|
|
assert len(pg_client.get("/api/jobs/visual-feed/runs?limit=3").json()) == 3
|
|
last = next(j for j in pg_client.get("/api/jobs").json()["jobs"] if j["job"] == "visual-feed")["last_run"]
|
|
assert last["status"] == "ok" and last["counts"] == {"new_drafts": 11}
|
|
|
|
|
|
def test_feed_settings_patch_is_audited_and_notifies(pg_client: TestClient, pg_settings: Settings) -> None:
|
|
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
|
assert pg_client.get("/api/admin/feed/settings").status_code == 403
|
|
assert pg_client.patch("/api/admin/feed/settings", json={"paused": True}).status_code == 403
|
|
|
|
assert sign_in(pg_client, "admin.ann").status_code == 200
|
|
r = pg_client.get("/api/admin/feed/settings")
|
|
assert r.status_code == 200, r.text
|
|
s = r.json()
|
|
assert s["interval_shop_minutes"] == 5 and s["paused"] is False and s["auto_release_max"] == 25
|
|
assert "runs" in s and "next_run_at" in s
|
|
|
|
listener = listen(pg_settings, "ps_settings")
|
|
try:
|
|
r = pg_client.patch(
|
|
"/api/admin/feed/settings",
|
|
json={"interval_shop_minutes": 7, "golive_date": "2026-11-02", "departments": ["03", "06"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["interval_shop_minutes"] == 7 and r.json()["golive_date"] == "2026-11-02"
|
|
assert notes(listener)
|
|
finally:
|
|
listener.close()
|
|
with app_conn(pg_settings) as conn:
|
|
detail, role = conn.execute(
|
|
"SELECT detail, role_snapshot FROM audit_events WHERE action = 'feed.settings_changed' ORDER BY id DESC"
|
|
).fetchone()
|
|
assert detail["changed"]["interval_shop_minutes"] == {"from": 5, "to": 7}
|
|
assert detail["changed"]["departments"] == {"from": [], "to": ["03", "06"]}
|
|
assert "paused" not in detail["changed"] and "admin" in role
|
|
assert conn.execute("SELECT value->>'interval_shop_minutes' FROM settings WHERE key = 'feed'").fetchone() == (
|
|
"7",
|
|
)
|
|
|
|
for bad in (
|
|
{"interval_shop_minutes": 0},
|
|
{"shop_start": "6am"},
|
|
{"nope": 1},
|
|
{"departments": ["99"]},
|
|
{"window_back_days": 500},
|
|
):
|
|
r = pg_client.patch("/api/admin/feed/settings", json=bad)
|
|
assert r.status_code == 422 and r.json()["code"] == "bad_settings", (bad, r.text)
|
|
|
|
|
|
def test_resource_map(pg_client: TestClient, pg_settings: Settings) -> None:
|
|
assert sign_in(pg_client, "clerk.clara").status_code == 200
|
|
assert pg_client.get("/api/admin/feed/resource-map").status_code == 403
|
|
assert sign_in(pg_client, "admin.ann").status_code == 200
|
|
rows = {r["resource"]: r for r in pg_client.get("/api/admin/feed/resource-map").json()}
|
|
assert rows["WELD-PART"]["target"] == "03" and rows["WELD-PART"]["confirmed"] is False
|
|
assert rows["MFG"]["target"] == "fold" and rows["FORKLIFT"]["target"] == "ignore"
|
|
assert rows["Electical"]["target"] == "14"
|
|
|
|
r = pg_client.put("/api/admin/feed/resource-map/NEWRES", json={"target": "03"})
|
|
assert r.status_code == 200 and r.json()["target"] == "03" and r.json()["confirmed"] is True, r.text
|
|
r = pg_client.put("/api/admin/feed/resource-map/NEWRES", json={"target": "Fold"})
|
|
assert r.status_code == 200 and r.json()["target"] == "fold"
|
|
for bad in ("77", "WH", "", "fold; drop"):
|
|
r = pg_client.put("/api/admin/feed/resource-map/NEWRES", json={"target": bad})
|
|
assert r.status_code == 422, (bad, r.text)
|
|
assert pg_client.delete("/api/admin/feed/resource-map/NEWRES").status_code == 204
|
|
assert pg_client.delete("/api/admin/feed/resource-map/NEWRES").status_code == 404
|
|
with app_conn(pg_settings) as conn:
|
|
acts = conn.execute(
|
|
"SELECT action, detail FROM audit_events WHERE subject_type = 'visual_resource' ORDER BY id"
|
|
).fetchall()
|
|
assert [a[0] for a in acts] == ["feed.resource_mapped", "feed.resource_mapped", "feed.resource_unmapped"]
|
|
assert acts[1][1]["before"]["target"] == "03" and acts[1][1]["after"]["target"] == "fold"
|
|
assert acts[2][1]["after"] is None
|
|
|
|
|
|
def test_resource_map_lists_unmapped_from_the_last_batch(
|
|
pg_client: TestClient, pg_settings: Settings, tmp_path: Any
|
|
) -> None:
|
|
from feedhelpers import feed_settings_over, run_feed
|
|
|
|
fx = feed_settings_over(pg_settings, tmp_path)
|
|
run_feed(fx, 1)
|
|
assert sign_in(pg_client, "admin.ann").status_code == 200
|
|
rows = {r["resource"]: r for r in pg_client.get("/api/admin/feed/resource-map").json()}
|
|
assert rows["NEWRES"]["target"] is None and rows["NEWRES"]["rows_last_run"] == 2
|
|
assert rows["WELD-PART"]["rows_last_run"] >= 1
|