"""Regression coverage for uploaded-file deletion (project-owner follow-up request), with particular focus on the rollup-recompute correctness fix that shipped alongside it: request_stats_daily/hourly and the derived per-day tables must correctly shrink (or disappear entirely) when the file(s) that contributed to a date are deleted — not just grow, which is all the aggregator was ever previously asked to do. """ from __future__ import annotations import io import time from datetime import date, datetime from app.extensions import db from app.models.bot_hit import BotHit from app.models.log_entry import LogEntry from app.models.log_file import LogFile from app.models.request_stats import RequestStatsDaily, RequestStatsHourly from app.models.suspicious_event import SuspiciousEvent LOG_DATE = date(2026, 7, 15) LINE_A = ( b'203.0.113.10 - - [15/Jul/2026:10:00:00 -0700] "GET /a.html HTTP/1.1" 200 500 ' b'"-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"\n' ) LINE_B = ( b'203.0.113.11 - - [15/Jul/2026:11:00:00 -0700] "GET /b.html HTTP/1.1" 200 700 ' b'"-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"\n' ) SCANNER_LINE = ( b'203.0.113.12 - - [15/Jul/2026:12:00:00 -0700] "GET /.env HTTP/1.1" 404 0 ' b'"-" "sqlmap/1.7"\n' ) def _upload(client, content: bytes, filename: str) -> LogFile: resp = client.post( "/uploads", data={"logfile": (io.BytesIO(content), filename), "server_type": "apache", "format_string": ""}, content_type="multipart/form-data", ) assert resp.status_code == 200 log_file = LogFile.query.filter_by(filename=filename).first() assert log_file is not None _wait_until_done(log_file) return log_file def _wait_until_done(log_file: LogFile) -> None: deadline = time.time() + 5 while time.time() < deadline: db.session.refresh(log_file) if log_file.status in ("done", "error"): return time.sleep(0.05) raise AssertionError(f"file {log_file.id} never finished processing (status={log_file.status})") def _hourly_rows_for_log_date(): start = datetime.combine(LOG_DATE, datetime.min.time()) end = start.replace(hour=23, minute=59) return RequestStatsHourly.query.filter(RequestStatsHourly.date_hour.between(start, end)) def test_deleting_a_file_recomputes_shared_date_rollup_correctly(logged_in_client): """Two files both touch 2026-07-15. Deleting one must leave the day's rollup reflecting only the file that remains — not zero, and not double-counted. """ file_a = _upload(logged_in_client, LINE_A, "file-a.log") file_b = _upload(logged_in_client, LINE_B, "file-b.log") daily = db.session.get(RequestStatsDaily, LOG_DATE) assert daily is not None assert daily.count == 2 # both lines contributed resp = logged_in_client.delete("/api/uploads", json={"ids": [file_a.id]}) assert resp.status_code == 200 body = resp.get_json()["data"] assert body["deleted"] == [file_a.id] assert body["skipped"] == [] # file_a's own rows are gone... assert db.session.get(LogFile, file_a.id) is None assert LogEntry.query.filter_by(log_file_id=file_a.id).count() == 0 # ...but file_b's contribution to the same date survives, and the # rollup now reflects ONLY file_b — this is exactly the bug that # would have shipped without the aggregator fix (either stuck at 2, # or wiped to nothing even though file_b's data is still there). daily = db.session.get(RequestStatsDaily, LOG_DATE) assert daily is not None assert daily.count == 1 assert LogEntry.query.filter_by(log_file_id=file_b.id).count() == 1 def test_deleting_the_only_file_for_a_date_clears_the_rollup_entirely(logged_in_client): """When NO file covers a date anymore, the stale rollup row must be removed, not left behind with yesterday's numbers. """ file_a = _upload(logged_in_client, LINE_A, "solo-file.log") assert db.session.get(RequestStatsDaily, LOG_DATE) is not None assert _hourly_rows_for_log_date().count() > 0 resp = logged_in_client.delete("/api/uploads", json={"ids": [file_a.id]}) assert resp.status_code == 200 assert resp.get_json()["data"]["deleted"] == [file_a.id] assert db.session.get(RequestStatsDaily, LOG_DATE) is None assert _hourly_rows_for_log_date().count() == 0 def test_deleting_a_file_removes_bot_hits_and_suspicious_events(logged_in_client): file_a = _upload(logged_in_client, LINE_B + SCANNER_LINE, "mixed.log") assert BotHit.query.filter_by(log_file_id=file_a.id).count() == 0 # LINE_B has no bot UA assert SuspiciousEvent.query.filter_by(log_file_id=file_a.id).count() == 1 # sqlmap resp = logged_in_client.delete("/api/uploads", json={"ids": [file_a.id]}) assert resp.status_code == 200 assert SuspiciousEvent.query.filter_by(log_file_id=file_a.id).count() == 0 def test_delete_skips_unknown_id(logged_in_client): resp = logged_in_client.delete("/api/uploads", json={"ids": [999999]}) assert resp.status_code == 200 body = resp.get_json()["data"] assert body["deleted"] == [] assert body["skipped"] == [{"id": 999999, "reason": "not found"}] def test_delete_rejects_malformed_body(logged_in_client): resp = logged_in_client.delete("/api/uploads", json={"ids": "not-a-list"}) assert resp.status_code == 400 def test_list_uploads_returns_paginated_rows(logged_in_client): _upload(logged_in_client, LINE_A, "list-test.log") resp = logged_in_client.get("/api/uploads?page=1&per_page=20") assert resp.status_code == 200 data = resp.get_json()["data"] assert data["total"] >= 1 row = next(r for r in data["rows"] if r[1] == "list-test.log") assert row[3] == "done" # status column assert row[6] == "done" # raw status (hidden column) def test_delete_endpoint_requires_authentication(client): resp = client.delete("/api/uploads", json={"ids": [1]}) assert resp.status_code == 401 assert resp.get_json()["error"]["code"] == "unauthorized"