Files
Backup-Dashboard/backend/tests/test_api.py
T
kyleandClaude Opus 5.5 d4e821799c Add an admin page for managing collector API keys
Admins (Microsoft 365 sign-in names in BACKUP_DASH_ADMINS) get an
Admin link to /admin.html, where they can create, list and revoke the
API keys collectors use, instead of running manage.py on the server.

Backend
- /api/v1/admin/keys (list, create, revoke), admins only (403
  otherwise); the user comes from the headers nginx sets after sign-in
- Changes need an X-Requested-By header, which another site's page
  can't add, so a signed-in admin's browser can't be used to forge them
- A new key is returned once; only its hash is stored. Names are unique
  for good, so revoked keys' history stays unambiguous
- Keys record who created/revoked them and the company they last
  reported for (schema v2; databases migrate automatically on start)
- /api/v1/me reports is_admin; manage.py shares the key code and
  records itself as creator/revoker

Frontend
- admin.html / admin.js: name validation shown after interaction
  (:user-invalid, aria-invalid kept in sync), the one-time key with a
  copy button (falls back to selecting it if clipboard access is
  refused), a key table flagging keys quiet for over 24 hours, and
  revoking confirmed in a <dialog>; results announced in a status region
- Shared el() and signed-in helpers moved to dom.js / signed-in.js
- Mock mode (?mock=1) includes sample keys

Tested: 15 new backend tests (access, forged requests, names,
duplicates, revoking, last-used company, v1 -> v2 upgrade); in the
browser, every admin flow in mock mode and at phone width; end to end
through the nginx config with the real API (a key created on the API
works for ingest; revoking it makes ingest 401; non-admins see an
explanation and no Admin link).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:52:29 -06:00

131 lines
5.7 KiB
Python

from datetime import timedelta
import pytest
from fastapi.testclient import TestClient
from app import db
from app.auth import generate_key, hash_key
from app.main import create_app
from app.settings import Settings
from app.timeutil import to_db, utcnow
@pytest.fixture
def client(tmp_path):
settings = Settings(db_path=tmp_path / "test.db")
with TestClient(create_app(settings)) as c:
key = generate_key()
conn = db.connect(settings.db_path)
conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('test', ?, ?)",
(hash_key(key), to_db(utcnow())))
conn.close()
c.headers["Authorization"] = f"Bearer {key}"
yield c
def iso(delta: timedelta) -> str:
return (utcnow() + delta).isoformat()
def payload(run=None, **job):
body = {"tenant": "Acme Corp", "source_system": "Veeam B&R",
"job": {"external_id": "job-1", "name": "File Server", **job}}
if run is not None:
body["run"] = run
return body
def test_ingest_requires_valid_key(client):
assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": ""}).status_code == 401
assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": "Bearer nope"}).status_code == 401
def test_running_then_finished_merges_into_one_run(client):
started = iso(-timedelta(hours=1))
r = client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started}))
assert r.status_code == 200, r.text
job_id = r.json()["job_id"]
job = client.get("/api/v1/jobs").json()["jobs"][0]
assert job["status"] == "running"
assert job["running_since"] is not None
assert job["last_run_at"] is None # nothing has finished yet
finished = iso(-timedelta(minutes=10))
client.post("/api/v1/ingest", json=payload(
{"status": "warning", "started_at": started, "finished_at": finished, "message": "1 file skipped"}))
detail = client.get(f"/api/v1/jobs/{job_id}").json()
assert detail["status"] == "warning"
assert detail["running_since"] is None
assert detail["last_run_at"] == to_db(utcnow().fromisoformat(finished))
assert detail["duration_seconds"] == 50 * 60
assert detail["message"] == "1 file skipped"
assert len(client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]) == 1
def test_late_running_report_does_not_reopen_finished_run(client):
started = iso(-timedelta(hours=1))
client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": started, "finished_at": iso(-timedelta(minutes=5))}))
client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started}))
assert client.get("/api/v1/jobs").json()["jobs"][0]["status"] == "success"
def test_last_run_is_previous_finish_while_new_run_in_progress(client):
prev_end = iso(-timedelta(hours=23))
client.post("/api/v1/ingest", json=payload({"status": "failed", "started_at": iso(-timedelta(hours=24)), "finished_at": prev_end, "message": "boom"}))
client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": iso(-timedelta(minutes=5))}))
job = client.get("/api/v1/jobs").json()["jobs"][0]
assert job["status"] == "running"
assert job["last_run_at"] == to_db(utcnow().fromisoformat(prev_end))
detail = client.get(f"/api/v1/jobs/{job['id']}").json()
assert detail["last_status"] == "failed"
assert detail["message"] == "boom"
def test_next_run_at_only_changes_when_sent(client):
nxt = iso(timedelta(hours=5))
client.post("/api/v1/ingest", json=payload(next_run_at=nxt))
client.post("/api/v1/ingest", json=payload()) # omitted → keep
assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] == to_db(utcnow().fromisoformat(nxt))
client.post("/api/v1/ingest", json=payload(next_run_at=None)) # explicit null → clear
assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] is None
def test_history_window_and_run_detail(client):
for days_ago in (40, 20, 1):
start = iso(-timedelta(days=days_ago))
client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": start,
"finished_at": start, "message": f"{days_ago}d"}))
job_id = client.get("/api/v1/jobs").json()["jobs"][0]["id"]
runs = client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]
assert len(runs) == 2 # the 40-day-old run is outside the 30-day window
assert "message" not in runs[0]
run = client.get(f"/api/v1/runs/{runs[0]['id']}").json()
assert run["message"] == "1d"
assert client.get(f"/api/v1/jobs/{job_id}/runs?days=31").status_code == 422
@pytest.mark.parametrize("run", [
{"status": "running", "started_at": iso(-timedelta(hours=1)), "finished_at": iso(timedelta(0))},
{"status": "success", "started_at": iso(timedelta(0)), "finished_at": iso(-timedelta(hours=1))},
{"status": "success", "started_at": "2026-01-01T00:00:00"}, # no timezone
{"status": "done", "started_at": iso(timedelta(0))},
])
def test_rejects_bad_runs(client, run):
assert client.post("/api/v1/ingest", json=payload(run)).status_code == 422
def test_tenant_name_is_case_insensitive(client):
client.post("/api/v1/ingest", json=payload())
body = payload()
body["tenant"] = "ACME CORP"
client.post("/api/v1/ingest", json=body)
assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1
def test_me_reports_signed_in_user_from_proxy_headers(client):
assert client.get("/api/v1/me").json() == {"email": None, "name": None, "is_admin": False}
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
assert r.json() == {"email": "[email protected]", "name": "abc-123", "is_admin": False}