fix(migrations): dropping the demo also takes its reset job's interval-change audit rows

0098 deleted the reset job's run rows but not the scheduler_interval rows that changing its interval
wrote; with the job's label gone they rendered as a raw id. The delete now takes every job-targeted
row for it, and a test seeds both kinds below 0098 and upgrades (red on the old filter). The test
has its own file so the removed-features guard can name it.
This commit is contained in:
2026-10-05 00:19:50 +02:00
parent 2f901ad643
commit eecc195b7e
3 changed files with 42 additions and 5 deletions
@@ -5,9 +5,9 @@ schedule — goes with its code. Prod held the demo user and 10 whitelist rows.
The upgrade deletes the demo user first (its playlists, states and subscriptions cascade; audit and The upgrade deletes the demo user first (its playlists, states and subscriptions cascade; audit and
quota rows keep their history with the actor nulled, as for any deleted account), then the traces quota rows keep their history with the actor nulled, as for any deleted account), then the traces
that only the demo code could render: its audit rows (whitelist edits, manual resets, the scheduled that only the demo code could render: its audit rows (whitelist edits, manual resets, and every row
reset's job runs), the reset job's interval override and its completion notices. Nothing is about the reset job — its runs and its interval changes), the reset job's interval override and its
migrated anywhere; the nightly backup holds the rows. completion notices. Nothing is migrated anywhere; the nightly backup holds the rows.
The downgrade restores the schema as it stood (0014), empty: the demo user is created lazily on its The downgrade restores the schema as it stood (0014), empty: the demo user is created lazily on its
first sign-in, so an empty `demo_whitelist` is the whole of its state. first sign-in, so an empty `demo_whitelist` is the whole of its state.
@@ -26,7 +26,7 @@ def upgrade() -> None:
op.execute("DELETE FROM users WHERE is_demo") op.execute("DELETE FROM users WHERE is_demo")
op.execute( op.execute(
"DELETE FROM audit_log WHERE action IN ('demo_add', 'demo_remove', 'demo_reset')" "DELETE FROM audit_log WHERE action IN ('demo_add', 'demo_remove', 'demo_reset')"
" OR (action = 'job_run' AND target_id = 'demo_reset')" " OR (target_type = 'job' AND target_id = 'demo_reset')"
) )
op.execute("DELETE FROM scheduler_settings WHERE job_id = 'demo_reset'") op.execute("DELETE FROM scheduler_settings WHERE job_id = 'demo_reset'")
op.execute("DELETE FROM notifications WHERE data->>'job_id' = 'demo_reset'") op.execute("DELETE FROM notifications WHERE data->>'job_id' = 'demo_reset'")
+35
View File
@@ -0,0 +1,35 @@
"""Migration 0098's data cleanup, DB-lane.
The schema half of every migration is checked by test_migrations.py; this one also deletes rows, and
a delete that misses a row is invisible there. Its own file so the removed-features guard can name it.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def test_dropping_the_demo_takes_every_audit_row_about_its_reset_job(db):
# The reset job's interval could be changed from the scheduler page like any job's; once its
# name is gone from the UI, a left-behind row would render as a raw job id.
cfg = Config("alembic.ini")
command.downgrade(cfg, "0097_drop_messages")
try:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO audit_log (action, target_type, target_id, created_at) VALUES"
" ('job_run', 'job', 'demo_reset', now()),"
" ('scheduler_interval', 'job', 'demo_reset', now()),"
" ('scheduler_interval', 'job', 'sync_feed', now())"
)
)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
left = conn.execute(
text("SELECT action, target_id FROM audit_log WHERE target_type = 'job' ORDER BY id")
).all()
assert [tuple(r) for r in left] == [("scheduler_interval", "sync_feed")]
@@ -9,7 +9,8 @@ import { describe, expect, it } from "vitest";
// //
// Not scanned: the Alembic migrations (0014 added the account and 0098 removed it, and both stay true // Not scanned: the Alembic migrations (0014 added the account and 0098 removed it, and both stay true
// as history) and the release notes, which record what shipped when. `audit_metrics.py` counts the // as history) and the release notes, which record what shipped when. `audit_metrics.py` counts the
// footprint by this same word, so it names it on purpose. // footprint by this same word, and test_migration_0098.py tests the removal itself, so both name it
// on purpose.
const REPO = new URL("../../../", import.meta.url); const REPO = new URL("../../../", import.meta.url);
const ROOTS = ["backend/app", "backend/tests", "frontend/src", "frontend/e2e", "scripts"]; const ROOTS = ["backend/app", "backend/tests", "frontend/src", "frontend/e2e", "scripts"];
@@ -18,6 +19,7 @@ const EXTENSIONS = /\.(py|ts|tsx|js|mjs|json|css|md|sh)$/;
const ALLOWED = new Set([ const ALLOWED = new Set([
"frontend/src/lib/removedFeatures.contract.test.ts", "frontend/src/lib/removedFeatures.contract.test.ts",
"scripts/audit_metrics.py", "scripts/audit_metrics.py",
"backend/tests/test_migration_0098.py",
]); ]);
// "demo" as a word or an identifier part (`is_demo`, `DemoBanner`, `tryDemo`) — not "demote" (a role // "demo" as a word or an identifier part (`is_demo`, `DemoBanner`, `tryDemo`) — not "demote" (a role
// change) and not "Demokratikus" (a country's Hungarian name in the vocab). // change) and not "Demokratikus" (a country's Hungarian name in the vocab).