Channel, Subscription, Video, SearchFind, ExploredChannel and BlockedChannel (and LIVE_OR_UPCOMING) leave app/models.py for app/catalog/models.py; app.tables registers them. The User↔Subscription relationship names its other side as a type only. No schema change.
207 lines
8.4 KiB
Python
207 lines
8.4 KiB
Python
"""The activity page's reads: the audit log and the background runs, filtered on the server and
|
|
paged by a keyset, and the quota breakdown over a time range and one account.
|
|
|
|
Each list is asked for the rows a filter must keep AND must drop, and a page boundary is crossed
|
|
with rows that share a timestamp — the case an offset or a timestamp-only cursor gets wrong.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app.auth import admin_user
|
|
from app.main import app
|
|
from app.models import AuditLog, JobRun, LogEvent, QuotaEvent
|
|
from app.catalog.models import Channel
|
|
from tests.test_body_limit import _route_objects
|
|
|
|
T0 = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _audit(db, action, actor=None, at=T0, summary=None, request_id=None) -> int:
|
|
row = AuditLog(
|
|
actor_id=actor, action=action, summary=summary, created_at=at, request_id=request_id
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
return row.id
|
|
|
|
|
|
def _run(db, job, status="ok", actor=None, at=T0, target=None, trigger="scheduled") -> int:
|
|
row = JobRun(
|
|
job=job,
|
|
trigger=trigger,
|
|
actor_id=actor,
|
|
target=target,
|
|
status=status,
|
|
started_at=at,
|
|
finished_at=None if status == "running" else at + timedelta(seconds=4),
|
|
quota_units=0,
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
return row.id
|
|
|
|
|
|
def _ids(body) -> list[int]:
|
|
return [r["id"] for r in body["items"]]
|
|
|
|
|
|
def test_the_audit_list_filters_on_the_server_and_pages_across_a_shared_timestamp(
|
|
db, make_user, as_user
|
|
):
|
|
admin = make_user(role="admin")
|
|
a = _audit(db, "config_set", admin.id, T0, "quota_daily_budget = 9000")
|
|
b = _audit(db, "config_set", admin.id, T0, "log_level = DEBUG")
|
|
c = _audit(db, "config_set", admin.id, T0, "smtp_host = mail")
|
|
bg = _audit(db, "user_purged", None, T0 - timedelta(days=3))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
|
|
first = client.get("/api/admin/audit", params={"limit": 2, "action": "config_set"}).json()
|
|
assert _ids(first) == [c, b]
|
|
second = client.get(
|
|
"/api/admin/audit",
|
|
params={"limit": 2, "action": "config_set", "before": first["next_cursor"]},
|
|
).json()
|
|
assert (_ids(second), second["next_cursor"]) == ([a], None)
|
|
# A page that ends exactly at the last row says there is nothing older.
|
|
whole = client.get("/api/admin/audit", params={"limit": 3, "action": "config_set"}).json()
|
|
assert (_ids(whole), whole["next_cursor"]) == ([c, b, a], None)
|
|
assert first["accounts"] == {str(admin.id): admin.email}
|
|
|
|
def ids(**params) -> list[int]:
|
|
return _ids(client.get("/api/admin/audit", params=params).json())
|
|
|
|
assert ids(user="none") == [bg]
|
|
assert ids(user=str(admin.id)) == [c, b, a]
|
|
assert ids(q="DEBUG") == [b]
|
|
assert ids(since=(T0 - timedelta(days=1)).isoformat()) == [c, b, a]
|
|
assert ids(until=(T0 - timedelta(days=1)).isoformat()) == [bg]
|
|
# The action menu counts every action under the other filters, its own pick included.
|
|
facets = client.get("/api/admin/audit/facets", params={"action": "config_set"}).json()
|
|
assert facets["actions"] == [
|
|
{"value": "config_set", "count": 3},
|
|
{"value": "user_purged", "count": 1},
|
|
]
|
|
|
|
|
|
def test_an_admin_action_keeps_the_request_it_was_made_in(db, make_user, as_user):
|
|
client = as_user(make_user(role="admin"))
|
|
r = client.patch("/api/admin/config/quota_daily_budget", json={"value": 9500})
|
|
assert r.status_code == 200, r.text
|
|
(row,) = client.get("/api/admin/audit", params={"action": "config_set"}).json()["items"]
|
|
assert row["request_id"] == r.headers["x-request-id"]
|
|
|
|
|
|
def test_an_admin_action_says_how_many_of_its_requests_lines_are_still_kept(db, make_user, as_user):
|
|
"""A request's lines are kept days, its audit row months: a link to lines gone would open an
|
|
empty list, so the row says how many are left, as a run does."""
|
|
admin = make_user(role="admin")
|
|
kept = _audit(db, "config_set", actor=admin.id, request_id="req-kept-0001")
|
|
gone = _audit(db, "config_set", actor=admin.id, request_id="req-gone-0001")
|
|
db.add_all(
|
|
[
|
|
LogEvent(
|
|
ts=T0,
|
|
level="INFO",
|
|
sev=20,
|
|
source="api",
|
|
logger="t",
|
|
msg="m",
|
|
request_id="req-kept-0001",
|
|
)
|
|
for _ in range(3)
|
|
]
|
|
)
|
|
db.commit()
|
|
items = as_user(admin).get("/api/admin/audit").json()["items"]
|
|
lines = {r["id"]: r["log_lines"] for r in items}
|
|
assert (lines[kept], lines[gone]) == (3, 0)
|
|
|
|
|
|
def test_the_run_list_filters_pages_and_names_the_channel_and_the_log_lines(db, make_user, as_user):
|
|
admin = make_user(role="admin")
|
|
db.add(Channel(id="UCchan", title="Aileen Wang"))
|
|
ok1 = _run(db, "rss_poll", at=T0)
|
|
ok2 = _run(db, "rss_poll", at=T0)
|
|
bad = _run(db, "backfill", "error", admin.id, T0, trigger="manual")
|
|
adhoc = _run(
|
|
db, "channel_backfill", "ok", admin.id, T0 - timedelta(hours=1), "UCchan", "ad_hoc"
|
|
)
|
|
for _ in range(2):
|
|
db.add(
|
|
LogEvent(ts=T0, level="WARNING", sev=30, source="api", logger="t", msg="m", run_id=bad)
|
|
)
|
|
db.commit()
|
|
client = as_user(admin)
|
|
|
|
first = client.get("/api/admin/runs", params={"limit": 2}).json()
|
|
rest = client.get("/api/admin/runs", params={"limit": 2, "before": first["next_cursor"]}).json()
|
|
assert _ids(first) + _ids(rest) == [bad, ok2, ok1, adhoc]
|
|
assert rest["next_cursor"] is None
|
|
rows = {r["id"]: r for r in first["items"] + rest["items"]}
|
|
assert (rows[bad]["log_lines"], rows[ok2]["log_lines"]) == (2, 0)
|
|
assert (rows[adhoc]["target_title"], rows[ok1]["target_title"]) == ("Aileen Wang", None)
|
|
|
|
def ids(**params) -> list[int]:
|
|
return _ids(client.get("/api/admin/runs", params=params).json())
|
|
|
|
assert ids(status="error") == [bad]
|
|
assert ids(job="rss_poll") == [ok2, ok1]
|
|
assert ids(user="none") == [ok2, ok1]
|
|
assert ids(user=str(admin.id)) == [bad, adhoc]
|
|
assert ids(q="UCchan") == [adhoc]
|
|
assert ids(since=(T0 - timedelta(minutes=30)).isoformat()) == [bad, ok2, ok1]
|
|
assert client.get("/api/admin/runs", params={"status": "finished"}).status_code == 422
|
|
|
|
facets = client.get("/api/admin/runs/facets", params={"status": "error"}).json()
|
|
assert facets["statuses"] == [{"value": "ok", "count": 3}, {"value": "error", "count": 1}]
|
|
assert facets["jobs"] == [{"value": "backfill", "count": 1}]
|
|
assert facets["total"] == 4
|
|
|
|
|
|
def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_user):
|
|
admin = make_user(role="admin")
|
|
now = datetime.now(timezone.utc)
|
|
db.add_all(
|
|
[
|
|
QuotaEvent(user_id=admin.id, action="search", units=100, created_at=now),
|
|
QuotaEvent(user_id=None, action="backfill", units=7, created_at=now),
|
|
QuotaEvent(
|
|
user_id=admin.id, action="search", units=50, created_at=now - timedelta(days=10)
|
|
),
|
|
]
|
|
)
|
|
db.commit()
|
|
client = as_user(admin)
|
|
|
|
def totals(**params) -> dict:
|
|
body = client.get("/api/quota/admin", params=params).json()
|
|
# The chart is the same filter's: its days add up to the table's total.
|
|
assert sum(d["total"] for d in body["daily"]) == sum(r["total"] for r in body["rows"])
|
|
return {r["user_id"]: r["total"] for r in body["rows"]}
|
|
|
|
assert totals() == {admin.id: 150, None: 7} # the last 30 days by default
|
|
assert totals(since=(now - timedelta(days=1)).isoformat()) == {admin.id: 100, None: 7}
|
|
assert totals(until=(now - timedelta(days=1)).isoformat()) == {admin.id: 50}
|
|
assert totals(user="none") == {None: 7}
|
|
assert totals(user=str(admin.id)) == {admin.id: 150}
|
|
|
|
|
|
def test_every_admin_read_and_write_is_admin_only():
|
|
"""The route table, not a list kept by hand: every /api/admin route and the quota breakdown
|
|
resolve `admin_user`, so a dropped dependency cannot hand an account's runs, errors and e-mail
|
|
addresses to any signed-in user."""
|
|
routes = _route_objects(app)
|
|
admin = {k: r for k, r in routes.items() if k[1].startswith(("/api/admin", "/api/quota/admin"))}
|
|
assert ("GET", "/api/admin/runs") in admin and ("GET", "/api/quota/admin") in admin
|
|
open_ = []
|
|
for key, route in admin.items():
|
|
stack, calls = [route.dependant], set()
|
|
while stack:
|
|
d = stack.pop()
|
|
calls.add(d.call)
|
|
stack.extend(d.dependencies)
|
|
if admin_user not in calls:
|
|
open_.append(key)
|
|
assert open_ == []
|