start project
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user