feat(reports): store and browse report artifacts
This commit is contained in:
1 parent
5967a43eb5
commit
6f2e479958
13 files changed
+875
-93
No files matched your search
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for legacy report artifact backfill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import connect
|
||||
from app.db.backfill import backfill_report_artifacts
|
||||
from app.db import repositories as repo
|
||||
|
||||
|
||||
class ReportBackfillTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.conn = connect(str(self.root / "test.db"))
|
||||
self.roots = {
|
||||
"copyparty": str(self.root / "copyparty"),
|
||||
"npm": str(self.root / "npm"),
|
||||
"app": str(self.root / "app"),
|
||||
"mikrotik": str(self.root / "mikrotik"),
|
||||
}
|
||||
for path in self.roots.values():
|
||||
Path(path).mkdir(parents=True)
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_dry_run_and_import_are_idempotent(self):
|
||||
Path(self.roots["copyparty"], "2026-11-06-11-30-10.txt").write_text(
|
||||
"copyparty summary\n", encoding="utf-8")
|
||||
Path(self.roots["npm"], "2026-11-06.csv").write_text(
|
||||
"ip,count\n192.0.2.1,3\n", encoding="utf-8")
|
||||
Path(self.roots["app"], "2026-11-06.json").write_text(
|
||||
'{"summary":{"blocked":1}}', encoding="utf-8")
|
||||
Path(self.roots["app"], "data").mkdir()
|
||||
Path(self.roots["app"], "data", "requests.csv").write_text(
|
||||
"ip\n192.0.2.2\n", encoding="utf-8")
|
||||
|
||||
preview = backfill_report_artifacts(
|
||||
self.conn, dry_run=True, roots=self.roots)["reports"]
|
||||
self.assertEqual(preview["would_import"], 3)
|
||||
self.assertEqual(preview["skipped_canonical"], [
|
||||
{"source": "app", "path": "data/requests.csv"}])
|
||||
self.assertEqual(preview["by_source"]["copyparty"]["would_import"], 1)
|
||||
self.assertEqual(preview["by_format"]["csv"]["would_import"], 1)
|
||||
self.assertEqual(repo.list_report_artifacts(self.conn)["items"], [])
|
||||
|
||||
imported = backfill_report_artifacts(
|
||||
self.conn, roots=self.roots)["reports"]
|
||||
self.assertEqual(imported["imported"], 3)
|
||||
self.assertEqual(imported["unmapped"], [])
|
||||
|
||||
repeated = backfill_report_artifacts(
|
||||
self.conn, roots=self.roots)["reports"]
|
||||
self.assertEqual(repeated["imported"], 0)
|
||||
self.assertEqual(repeated["already_imported"], 3)
|
||||
|
||||
items = repo.list_report_artifacts(
|
||||
self.conn, source="copyparty")["items"]
|
||||
self.assertEqual(items[0]["original_path"],
|
||||
"copyparty/2026-11-06-11-30-10.txt")
|
||||
self.assertEqual(items[0]["generated_at"],
|
||||
"2026-11-06T11:30:10+00:00")
|
||||
|
||||
def test_malformed_json_is_preserved_with_parse_error(self):
|
||||
Path(self.roots["app"], "2026-11-06.json").write_text(
|
||||
'{"incomplete":', encoding="utf-8")
|
||||
|
||||
result = backfill_report_artifacts(
|
||||
self.conn, roots=self.roots)["reports"]
|
||||
self.assertEqual(result["imported"], 1)
|
||||
artifact = repo.list_report_artifacts(
|
||||
self.conn, source="app")["items"][0]
|
||||
detail = repo.get_report_artifact(self.conn, artifact["id"])
|
||||
self.assertEqual(detail["payload"]["content"], '{"incomplete":')
|
||||
self.assertIn("parse_error", detail["payload"])
|
||||
|
||||
def test_artifacts_survive_sqlite_backup_and_restore(self):
|
||||
artifact_id = repo.insert_report_artifact(
|
||||
self.conn, source="app", report_type="summary",
|
||||
generated_at="2026-11-06T00:00:00+00:00",
|
||||
payload={"summary": {"count": 4}},
|
||||
original_path="app/2026-11-06.json", content_sha256="c" * 64,
|
||||
)
|
||||
restored = connect(str(self.root / "restored.db"))
|
||||
try:
|
||||
self.conn.backup(restored)
|
||||
artifact = repo.get_report_artifact(restored, artifact_id)
|
||||
self.assertEqual(artifact["payload"], {"summary": {"count": 4}})
|
||||
finally:
|
||||
restored.close()
|
||||
|
||||
def test_oversized_file_is_reported_without_deleting_source(self):
|
||||
source = Path(self.roots["npm"], "large.txt")
|
||||
source.write_text("keep", encoding="utf-8")
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.db.backfill.MAX_REPORT_ARTIFACT_BYTES", 1):
|
||||
result = backfill_report_artifacts(
|
||||
self.conn, roots=self.roots)["reports"]
|
||||
|
||||
self.assertEqual(len(result["unmapped"]), 1)
|
||||
self.assertTrue(source.exists())
|
||||
self.assertEqual(result["unmapped"][0]["path"], "large.txt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for dual-writing generated reports to SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import app.scan
|
||||
from app.analyzer import Analyzer
|
||||
from app.db import connect
|
||||
from app.db import repositories as repo
|
||||
from app.utils.blocklist_history import write_blocklist_report
|
||||
|
||||
|
||||
class ReportPersistenceTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.conn = connect(str(self.root / "test.db"))
|
||||
self.connection_patch = patch("app.db.get_connection",
|
||||
return_value=self.conn)
|
||||
self.connection_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.connection_patch.stop()
|
||||
self.conn.close()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_analyzer_summary_is_stored_with_file_provenance(self):
|
||||
report_dir = self.root / "copyparty"
|
||||
report_dir.mkdir()
|
||||
path = report_dir / "2026-11-06-11-30-10.txt"
|
||||
path.write_text("summary body", encoding="utf-8")
|
||||
analyzer = Analyzer("logs", str(report_dir), quiet=True)
|
||||
|
||||
analyzer.persist_report_artifact(str(path), scan_id=14)
|
||||
|
||||
item = repo.list_report_artifacts(
|
||||
self.conn, source="ids")["items"][0]
|
||||
detail = repo.get_report_artifact(self.conn, item["id"])
|
||||
self.assertEqual(item["original_path"], "ids/2026-11-06-11-30-10.txt")
|
||||
self.assertEqual(detail["scan_id"], 14)
|
||||
self.assertEqual(detail["payload"]["content"], "summary body")
|
||||
|
||||
def test_blocklist_report_is_stored_as_structured_artifact(self):
|
||||
path = write_blocklist_report(
|
||||
str(self.root / "reports"), history_v4=set(), history_v6=set(),
|
||||
prev_v4=set(), prev_v6=set(),
|
||||
complete_v4={"192.0.2.1/32"}, complete_v6=set(),
|
||||
firewall_changes={"added_ipv4": ["192.0.2.1/32"]},
|
||||
dry_run=True, scan_id=22,
|
||||
)
|
||||
|
||||
items = repo.list_report_artifacts(
|
||||
self.conn, source="app", report_type="blocklist_change")["items"]
|
||||
self.assertEqual(len(items), 1)
|
||||
detail = repo.get_report_artifact(self.conn, items[0]["id"])
|
||||
self.assertEqual(detail["scan_id"], 22)
|
||||
self.assertEqual(detail["payload"]["blocked_ipv4"], ["192.0.2.1/32"])
|
||||
self.assertTrue(Path(path).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for database-backed report API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.db import connect
|
||||
from app.db import repositories as repo
|
||||
from app.web import create_app
|
||||
|
||||
|
||||
class ReportRoutesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.conn = connect(str(Path(self.temp_dir.name) / "test.db"))
|
||||
self.app = create_app()
|
||||
self.app.config["TESTING"] = True
|
||||
self.client = self.app.test_client()
|
||||
self.connection_patch = patch("app.db.get_connection",
|
||||
return_value=self.conn)
|
||||
self.connection_patch.start()
|
||||
self.artifact_id = repo.insert_report_artifact(
|
||||
self.conn, source="copyparty", report_type="text_report",
|
||||
generated_at="2026-11-06T10:00:00+00:00",
|
||||
payload={"format": "text", "content": "summary"},
|
||||
original_path="copyparty/2026-11-06.txt",
|
||||
content_sha256="b" * 64,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.connection_patch.stop()
|
||||
self.conn.close()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_list_detail_and_text_export(self):
|
||||
listed = self.client.get("/api/reports?source=copyparty&limit=1")
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(listed.json["items"][0]["id"], self.artifact_id)
|
||||
|
||||
detail = self.client.get(
|
||||
f"/api/report-artifacts/{self.artifact_id}")
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(detail.json["payload"]["content"], "summary")
|
||||
|
||||
exported = self.client.get(
|
||||
f"/api/report-artifacts/{self.artifact_id}/export?format=txt")
|
||||
self.assertEqual(exported.status_code, 200)
|
||||
self.assertEqual(exported.data.decode(), "summary")
|
||||
self.assertIn("attachment", exported.headers["Content-Disposition"])
|
||||
self.assertNotIn(".txt.txt", exported.headers["Content-Disposition"])
|
||||
|
||||
def test_invalid_source_cursor_and_missing_id(self):
|
||||
self.assertEqual(
|
||||
self.client.get("/api/reports?source=unknown").status_code, 400)
|
||||
self.assertEqual(
|
||||
self.client.get("/api/reports?cursor=invalid").status_code, 400)
|
||||
self.assertEqual(
|
||||
self.client.get("/api/report-artifacts/9999").status_code, 404)
|
||||
|
||||
def test_legacy_file_routes_remain_registered(self):
|
||||
response = self.client.get("/api/reports/copyparty")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("files", response.json)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -47,7 +47,7 @@ class SchemaTest(unittest.TestCase):
|
||||
"blocklist_reasons", "whitelist_entries",
|
||||
"whitelist_sources", "firewall_snapshots",
|
||||
"firewall_address_list_entries", "firewall_rule_entries",
|
||||
"whois_info", "log_sources"):
|
||||
"whois_info", "log_sources", "report_artifacts"):
|
||||
self.assertIn(t, tables, f"missing table {t}")
|
||||
conn.close()
|
||||
|
||||
@@ -195,5 +195,54 @@ class FirewallTest(unittest.TestCase):
|
||||
"SELECT COUNT(*) FROM firewall_rule_entries").fetchone()[0], 1)
|
||||
|
||||
|
||||
class ReportArtifactTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conn = _new_conn()
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
|
||||
def test_artifact_insert_is_idempotent_and_detail_decodes_payload(self):
|
||||
kwargs = {
|
||||
"source": "copyparty", "report_type": "analyzer_summary",
|
||||
"generated_at": "2026-11-01T10:00:00+00:00",
|
||||
"payload": {"summary": {"requests": 12}},
|
||||
"original_path": "reports/copyparty/2026-11-01.txt",
|
||||
"content_sha256": "a" * 64,
|
||||
}
|
||||
first_id = repo.insert_report_artifact(self.conn, **kwargs)
|
||||
duplicate_id = repo.insert_report_artifact(self.conn, **kwargs)
|
||||
|
||||
self.assertEqual(first_id, duplicate_id)
|
||||
self.assertEqual(self.conn.execute(
|
||||
"SELECT COUNT(*) FROM report_artifacts").fetchone()[0], 1)
|
||||
self.assertEqual(repo.get_report_artifact(self.conn, first_id)["payload"],
|
||||
kwargs["payload"])
|
||||
|
||||
def test_artifact_cursor_orders_stably_and_filters_source(self):
|
||||
for day, source in (("01", "npm"), ("02", "copyparty"),
|
||||
("03", "npm")):
|
||||
repo.insert_report_artifact(
|
||||
self.conn, source=source, report_type="summary",
|
||||
generated_at=f"2026-11-{day}T00:00:00+00:00",
|
||||
payload={"day": day}, original_path=f"reports/{day}.txt",
|
||||
content_sha256=day * 64,
|
||||
)
|
||||
|
||||
first = repo.list_report_artifacts(self.conn, source="npm", limit=1)
|
||||
self.assertEqual(len(first["items"]), 1)
|
||||
self.assertIsNotNone(first["next_cursor"])
|
||||
second = repo.list_report_artifacts(
|
||||
self.conn, source="npm", limit=1, cursor=first["next_cursor"])
|
||||
self.assertEqual(len(second["items"]), 1)
|
||||
self.assertIsNone(second["next_cursor"])
|
||||
self.assertGreater(first["items"][0]["generated_at"],
|
||||
second["items"][0]["generated_at"])
|
||||
|
||||
def test_artifact_cursor_rejects_invalid_token(self):
|
||||
with self.assertRaises(ValueError):
|
||||
repo.list_report_artifacts(self.conn, cursor="invalid")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user