start project

This commit is contained in:
Hemmat
2026-08-07 21:17:17 +03:30
commit ea1e1eead6
121 changed files with 8108 additions and 0 deletions
+151
View File
@@ -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"