Review round on the whole E1 stack (main..HEAD). 6 findings, all low/medium, none blocking; fixed for a clean gate-green handoff. - plex/sync (correctness): the un-enriched cast guard compared the stored cast against THIS listing's length, so a trimmed Plex cast on a not-yet-enriched row was never applied. Compare against the listing CAP (_LISTING_CAST_CAP=20) instead: >cap = enriched (keep, survives a watermark reset), <=cap = listing-sourced (track edits in both directions). +2 regression tests. - main (security): add X-Frame-Options: DENY as the legacy-browser fallback for the CSP frame-ancestors clickjacking guard. - downloads/service (altitude): finalize_if_running now uses a SQLAlchemy 2.0 update() construct instead of a hand-built text() UPDATE string — same atomic WHERE status='running', idiomatic, no identifier interpolation, no manual StrEnum coercion. Drops the now-unused text import. - auth/admin (reuse): extract the SEC-10 "drop untrusted preset password + bump epoch" invariant into one helper (revoke_preset_password_if_unverified) so the Google-callback and admin-activation paths can't drift. - tests/migrations (robustness): guarantee the shared schema is restored to head in a finally, so a downgrade/re-upgrade failure can't cascade into later tests. - worker (simplification): align _finish_from_cache's display_name fill with the _download path's guard (no behavior change). Gate green: ruff clean, 334 pytest (0 skip), frontend unchanged.
69 lines
3.3 KiB
Python
69 lines
3.3 KiB
Python
"""Migration hygiene (TEST-3), DB-lane.
|
|
|
|
Two things the prod boot never checks:
|
|
- **model <-> schema drift:** `entrypoint.sh` only ever runs `alembic upgrade head`; nothing verifies the
|
|
migrated schema still matches `Base.metadata`. A model column added without a migration (or a migration
|
|
that drifted from the model) ships silently.
|
|
- **the downgrade path:** every migration defines `downgrade()`, but it never runs, so a broken downgrade
|
|
is only discovered during an emergency rollback.
|
|
|
|
Both use the `db` fixture, which has already brought the schema to head via `_schema`.
|
|
|
|
Scope note: we gate on COLUMN/TABLE drift — a table or column present on one side and not the other, or
|
|
a column whose type/nullability disagrees. `compare_metadata` also reports index/constraint/sequence
|
|
differences, but those are dominated by migration-only objects (partial/expression/GIN indexes, CHECK
|
|
constraints, SERIAL sequences) that can't be expressed on `Base.metadata`, so comparing them wholesale is
|
|
noise; they're filtered out. The column/table signal is the "someone changed a model without a migration"
|
|
case worth failing on.
|
|
"""
|
|
from alembic import command
|
|
from alembic.autogenerate import compare_metadata
|
|
from alembic.config import Config
|
|
from alembic.migration import MigrationContext
|
|
|
|
from app.db import Base, engine
|
|
|
|
# The diff ops that mean a real table/column disagreement (presence AND shape) — not the index/
|
|
# constraint/sequence noise from migration-only objects.
|
|
_DRIFT_OPS = {
|
|
"add_table", "remove_table", "add_column", "remove_column", "modify_type", "modify_nullable",
|
|
}
|
|
|
|
|
|
def _structural_drift():
|
|
"""Table/column presence diffs between the ORM models and the migrated schema (`compare_type=True`
|
|
mirrors alembic/env.py). compare_metadata nests per-column ops in a sub-list per table and returns
|
|
table-level ops as bare tuples; flatten both and keep only the presence ops."""
|
|
with engine.connect() as conn:
|
|
ctx = MigrationContext.configure(conn, opts={"compare_type": True})
|
|
diffs = compare_metadata(ctx, Base.metadata)
|
|
drift = []
|
|
for d in diffs:
|
|
for entry in (d if isinstance(d, list) else [d]):
|
|
if isinstance(entry, tuple) and entry and entry[0] in _DRIFT_OPS:
|
|
drift.append(entry)
|
|
return drift
|
|
|
|
|
|
def test_models_match_the_migrated_schema(db):
|
|
# Non-empty == the ORM models and the alembic-built schema disagree on a table/column — almost
|
|
# always a model change that forgot its migration.
|
|
drift = _structural_drift()
|
|
assert drift == [], "model/schema drift — add or fix a migration:\n" + "\n".join(
|
|
str(d) for d in drift
|
|
)
|
|
|
|
|
|
def test_head_migration_downgrades_and_reupgrades(db):
|
|
# Exercise the newest migration's downgrade + re-upgrade so a broken downgrade is caught the sprint
|
|
# it lands, not during a rollback. Leaves the schema back at head for the rest of the session.
|
|
cfg = Config("alembic.ini")
|
|
command.downgrade(cfg, "-1")
|
|
try:
|
|
command.upgrade(cfg, "head")
|
|
assert _structural_drift() == []
|
|
finally:
|
|
# Always leave the SHARED schema at head, whatever the upgrade/assert above did — otherwise a
|
|
# failure here strands the DB one revision behind and cascades into every later DB-lane test.
|
|
command.upgrade(cfg, "head")
|