import csv import io from datetime import UTC, datetime, 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", retention_days=90) 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 ('t', ?, ?)", (hash_key(key), to_db(utcnow()))) conn.close() c.headers["Authorization"] = f"Bearer {key}" yield c def report_run(client, tenant, job, status, started, minutes=10, message="", size=None): run = {"status": status, "started_at": started.isoformat(), "message": message} if status != "running": run["finished_at"] = (started + timedelta(minutes=minutes)).isoformat() if size is not None: run["size_bytes"] = size r = client.post("/api/v1/ingest", json={ "tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": job, "name": job}, "run": run}) assert r.status_code == 200, r.text @pytest.fixture def alpha(client): now = utcnow() report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=1), size=1000, message="Job completed successfully.") report_run(client, "Alpha Construction", "File Server", "failed", now - timedelta(days=2), message="=HYPERLINK(\"http://evil\")\nRepository unreachable") report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=45)) # outside 30 days report_run(client, "Alpha Construction", "SQL01", "warning", now - timedelta(hours=5), message="Log truncation skipped") report_run(client, "Alpha Construction", "SQL01", "running", now - timedelta(minutes=5)) report_run(client, "Bluewater Dental", "Imaging", "success", now - timedelta(days=1)) return now def test_lists_companies_with_jobs(client, alpha): assert client.get("/api/v1/tenants").json()["tenants"] == [ {"name": "Alpha Construction", "jobs": 2}, {"name": "Bluewater Dental", "jobs": 1}] def test_report_covers_one_company_for_the_period(client, alpha): r = client.get("/api/v1/reports/tenant", params={"tenant": "alpha construction"}) # any case assert r.status_code == 200 report = r.json() assert report["tenant"] == "Alpha Construction" assert report["days"] == 30 jobs = {j["job_name"]: j for j in report["jobs"]} assert set(jobs) == {"File Server", "SQL01"} # not Bluewater's assert [run["status"] for run in jobs["File Server"]["runs"]] == ["failed", "success"] # oldest first; 45-day-old run left out assert [run["status"] for run in jobs["SQL01"]["runs"]] == ["warning", "running"] assert jobs["SQL01"]["status"] == "running" def test_report_says_when_each_job_was_first_tracked(client, alpha): jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]} # File Server's earliest run (45 days ago, outside the report) predates its first report to the API assert jobs["File Server"]["tracked_since"] == to_db(alpha - timedelta(days=45)) # SQL01's runs are all recent; it was first reported (created) when its first run arrived assert jobs["SQL01"]["tracked_since"] <= to_db(alpha - timedelta(hours=5)) def test_report_includes_messages_only_for_warnings_and_failures(client, alpha): jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]} failed, success = jobs["File Server"]["runs"] assert "Repository unreachable" in failed["message"] assert success["message"] is None assert success["size_bytes"] == 1000 assert success["duration_seconds"] == 600 def test_report_period_is_limited_to_retention(client, alpha): assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 90}).status_code == 200 r = client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 91}) assert r.status_code == 422 assert "90 days" in r.json()["detail"] assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 0}).status_code == 422 def test_unknown_company_is_404(client, alpha): assert client.get("/api/v1/reports/tenant", params={"tenant": "Nobody Inc"}).status_code == 404 def read_csv(response): text = response.content.decode("utf-8") assert text.startswith("") # so Excel reads it as UTF-8 return list(csv.reader(io.StringIO(text[1:]))) def test_csv_has_every_run_with_times_in_the_given_zone(client): started = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) # 05:00 in Edmonton (MST, -7) # The fixture's timestamps are relative to now; this run needs a fixed winter date in range client.app.state.settings = Settings(db_path=client.app.state.settings.db_path, retention_days=10_000) report_run(client, "Alpha Construction", "File Server", "success", started, minutes=20, size=5, message="Job completed successfully.") r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "days": 5000, "tz": "America/Edmonton"}) assert r.status_code == 200 assert r.headers["content-type"].startswith("text/csv") rows = read_csv(r) assert rows[0] == ["Company", "Job", "Source", "Started (America/Edmonton)", "Finished (America/Edmonton)", "Duration (seconds)", "Status", "Size (bytes)", "Message"] assert rows[1] == ["Alpha Construction", "File Server", "Veeam B&R", "2026-01-15 05:00:00", "2026-01-15 05:20:00", "1200", "success", "5", "Job completed successfully."] def test_csv_defuses_spreadsheet_formulas_and_keeps_full_messages(client, alpha): rows = read_csv(client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"})) by_status = {row[6]: row for row in rows[1:]} assert by_status["failed"][8].startswith("'=HYPERLINK") assert by_status["success"][8] == "Job completed successfully." # successes' messages too, unlike the JSON assert by_status["running"][4] == "" # not finished assert len(rows) == 1 + 4 # header + the 4 runs in the last 30 days def test_csv_filename_and_bad_time_zone(client, alpha): r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"}) assert 'filename="backup-report-alpha-construction-' in r.headers["content-disposition"] assert client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "tz": "Mars/Olympus"}).status_code == 422