152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""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"
|