Files
shopdb-flask/tests/test_plugin_migrations.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

1114 lines
53 KiB
Python

"""Per-plugin Alembic migration-chain guard tests.
Ownership cutover (ADR-008): the core Alembic chain created every table that
exists through its head (`7d16_directoryemployees`), including the plugin
tables. From that point on, each bundled plugin that owns tables carries its
own chain under `plugins/<name>/migrations/`. The `0001` migration in each
chain is a stamp-only no-op anchor: the core chain already built the tables, so
there is nothing to create; the anchor just gives the plugin chain a base that
`flask plugin upgrade-all` stamps into `alembic_version_<plugin>`.
These tests pin that contract:
* PLUGIN_TABLE_OWNERS stays in sync with what the models declare.
* Every plugin that owns tables has a valid single-head chain.
* The anchor migrations are genuine no-ops.
* `flask plugin upgrade-all` runs clean on a fresh DB and is idempotent.
"""
import ast
import re
from pathlib import Path
import pytest
from shopdb.plugins.alembic_template import (
PLUGIN_TABLE_OWNERS,
_get_plugin_metadata,
)
PLUGINS_DIR = Path(__file__).resolve().parent.parent / 'plugins'
# Plugins that own tables carry a migration chain; blueprint-only plugins do
# not. Today every bundled plugin owns tables, so this is the full set.
TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS))
# The ADR-008 cutover froze the set of plugins whose tables the core chain had
# already created. Their 0001 revision is a stamp-only no-op anchor. Plugins
# built AFTER the cutover are NOT in this list: their 0001 is a real baseline
# that genuinely creates their tables, so the no-op assertion must not apply to
# them. This is a frozen list on purpose - a newly discovered plugin does not
# silently get treated as a cutover no-op.
#
# Two cutover plugins do not ship on this line (employees, usb). Their anchors
# went with them; the CORE migrations that created their tables did not, and
# must not - every anchor here stamps against that same core head. The tables
# they leave behind are listed in ORPHAN_TABLE_OWNERS so prune-schema can drop
# them.
CUTOVER_PLUGINS = (
'computers', 'knowledgebase', 'machines', 'network',
'notifications', 'printers', 'slides', 'warranty',
)
# Expected head revision id per table-owning plugin, so the upgrade-all test can
# check both the cutover anchors and post-cutover baselines. Cutover plugins
# stamp '<plugin>0001anchor'; a post-cutover plugin stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
# computers adds the Entra/Azure device identity columns (azuredeviceid, then
# azureobjectid + azuredevicecategory), then the Configuration/Business policy
# tables, then the break-glass policyactionholds table, on top of its anchor, so
# its head is not the f-string default. 0006 widens policyactionholds.expiresat
# to nullable, where null means an indefinite hold that never self-clears.
EXPECTED_HEAD_REVISION['computers'] = 'computers0006holdindefinite'
# backups is also post-cutover: its 0001 really creates backuprevisions, and
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
# otherwise throws away.
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
# guides is a NEW plugin, so its 0001 builds tables rather than stamping an
# anchor over core-owned ones.
EXPECTED_HEAD_REVISION['guides'] = 'guides0001baseline'
# printers adds the printersupplyalerts crossing-state table on top of its
# anchor, then the exact INF driver name Add-PrinterDriver needs, a vendor on
# the driver, and finally printerobservedqueues - what a bay reported it HAS,
# kept apart from what it was assigned.
EXPECTED_HEAD_REVISION['printers'] = 'printers0005observedqueues'
# machines (renamed from equipment) keeps its original anchor id and adds the
# rename revision on top, so its head is not the f-string default.
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
# network links a device to a catalog model, which is where its photo comes
# from - machines, PCs and printers already had that link.
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
# Built after the ADR-008 cutover, so its 0001 genuinely creates the table
# rather than stamping an anchor. Tech Tools owns a table now because secure
# send needs one; every other tool in that plugin is browser-side.
EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend'
# notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category, then the fleet-alert columns plus the per-asset
# acknowledgement table the endpoint agent writes to.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0006fleetalerts'
# warranty adds the proof-of-cover document columns on top of its anchor.
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
# endpointstates + endpointevents tables the core chain never owned, 0002
# adds the endpointcommands queue plus the MTConnect state columns, 0003
# adds the generic command target/targetkind columns, 0004 adds the
# endpointapplications SBOM snapshot table, 0005 pins a command sha256, 0006
# seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy
# bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds
# the operatorreports table for tray issue reports.
# 0010 renames the shipped business policy off the originating site's
# designator on installs that already stamped 0007 (ADR-015).
# 0011 adds the trayversion cache column to endpointstates.
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
# replayed Windows event record.
# 0013 adds the inventory columns.
# 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry
# columns (boot info, reboot reasons, Defender health, NIC link state).
# 0016 adds the Dell update tables.
# 0017 adds the health rollup: endpointhealthstates, endpointhealthtransitions
# and endpointmonitorimpacts.
# 0018 adds endpointlocks: the admin quarantine refusing actions from non-admins
# on one endpoint, a caller-authority axis separate from the policy hold.
# 0019 splits the 0007 bundle into one shipped Configuration Policy per monitor
# and drops the shipped Business Policy, so sites compose their own.
# 0020 adds endpointapplications.appfingerprint for delta app updates. 0021 and
# 0022 are retired no-ops (client certificates, removed). 0023 drops their
# leftover asset columns, renames app_fingerprint, and adds per-PC credentials:
# endpointcredentials, endpointenrollrequests and endpointmigrationwaves.
# 0024 adds endpointbootstraptokens: the short-lived, admin-minted tokens behind
# the one-line bootstrap installer.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0024bootstraptokens'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.
POST_CUTOVER_PLUGINS = tuple(p for p in PLUGIN_TABLE_OWNERS if p not in CUTOVER_PLUGINS)
def _declared_tablenames(plugin: str) -> set:
"""Scan a plugin's models package for every __tablename__ literal.
Static parse (no import) so the test can compare what the code declares
against PLUGIN_TABLE_OWNERS without side effects.
"""
names = set()
models_dir = PLUGINS_DIR / plugin / 'models'
if not models_dir.exists():
return names
for source in models_dir.glob('*.py'):
tree = ast.parse(source.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
if '__tablename__' in targets and isinstance(node.value, ast.Constant):
names.add(node.value.value)
return names
def test_table_owners_match_declared_models():
"""Every plugin that declares a __tablename__ is registered in
PLUGIN_TABLE_OWNERS, and vice versa. Catches a new plugin table that
forgot to update the ownership map."""
declared = {p.name for p in PLUGINS_DIR.iterdir()
if p.is_dir() and _declared_tablenames(p.name)}
assert declared == set(PLUGIN_TABLE_OWNERS), (
f"PLUGIN_TABLE_OWNERS keys {set(PLUGIN_TABLE_OWNERS)} do not match "
f"plugins declaring tables {declared}"
)
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_bundled_plugin_has_table_owner_entry(plugin):
"""Every table-owning plugin appears in PLUGIN_TABLE_OWNERS with at least
one table, documenting which tables it contributes to the schema."""
assert plugin in PLUGIN_TABLE_OWNERS
assert len(PLUGIN_TABLE_OWNERS[plugin]) > 0
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_owned_tables_match_declared_models(plugin):
"""The tables named in PLUGIN_TABLE_OWNERS are exactly the ones the
plugin's models declare. Catches drift in either direction."""
assert set(PLUGIN_TABLE_OWNERS[plugin]) == _declared_tablenames(plugin)
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_plugin_metadata_has_all_owned_tables(plugin, app):
"""The MetaData filtered to a plugin's owned tables actually contains
every table named in PLUGIN_TABLE_OWNERS."""
with app.app_context():
md = _get_plugin_metadata(plugin)
owned = set(PLUGIN_TABLE_OWNERS[plugin])
missing = owned - set(md.tables.keys())
assert not missing, f"Plugin {plugin}: owned tables not in metadata: {missing}"
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
def test_plugin_has_migration_chain(plugin):
"""Every table-owning plugin has a migrations dir with env.py and exactly
one anchor revision whose down_revision is None (a valid single-root
chain)."""
mig = PLUGINS_DIR / plugin / 'migrations'
assert (mig / 'env.py').exists(), f"{plugin}: missing migrations/env.py"
versions = sorted((mig / 'versions').glob('*.py'))
assert versions, f"{plugin}: no version scripts"
roots = []
heads = set()
down_revisions = set()
revisions = set()
for script in versions:
tree = ast.parse(script.read_text())
rev = down = None
found_down = False
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
if 'revision' in names and isinstance(node.value, ast.Constant):
rev = node.value.value
if 'down_revision' in names:
found_down = True
if isinstance(node.value, ast.Constant):
down = node.value.value
assert rev, f"{plugin}: {script.name} has no revision id"
# alembic_version_<plugin>.version_num is VARCHAR(32). A longer id
# passes every SQLite test and then fails the real deploy on strict
# MySQL with "Data too long for column 'version_num'", after the
# migration body has already run. Caught
# endpoints0019splitmonitorpolicies (33 chars) during a container roll.
# The CORE chain's alembic_version is VARCHAR(128), so core ids are not
# bound by this and are not checked here.
assert len(rev) <= 32, (
f"{plugin}: {script.name} revision id {rev!r} is {len(rev)} chars; "
'alembic_version.version_num holds 32'
)
assert found_down, f"{plugin}: {script.name} has no down_revision"
revisions.add(rev)
if down is None:
roots.append(rev)
else:
down_revisions.add(down)
heads = revisions - down_revisions
assert len(roots) == 1, f"{plugin}: expected 1 root revision, got {roots}"
assert len(heads) == 1, f"{plugin}: chain must have a single head, got {heads}"
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS)
def test_anchor_migration_is_noop(plugin):
"""The 0001 anchor's upgrade() and downgrade() are pure no-ops: no DDL
operations, just `pass`. The core chain owns the tables at cutover.
Scoped to CUTOVER_PLUGINS only. A plugin built after the cutover ships a
real baseline, which is deliberately NOT a no-op."""
anchor = PLUGINS_DIR / plugin / 'migrations' / 'versions' / f'0001_{plugin}_anchor.py'
assert anchor.exists(), f"{plugin}: missing 0001 anchor migration"
tree = ast.parse(anchor.read_text())
funcs = {n.name: n for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name in ('upgrade', 'downgrade')}
assert set(funcs) == {'upgrade', 'downgrade'}, f"{plugin}: anchor missing up/downgrade"
for name, fn in funcs.items():
# Body may only be a docstring/comment plus a bare `pass`. No calls.
calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call)]
assert not calls, f"{plugin}: anchor {name}() is not a no-op (found calls)"
def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
"""`flask plugin upgrade-all` on a fresh SQLite DB (after the core schema
is created) stamps every plugin anchor without error, and a second run is
a no-op. Mirrors the deploy sequence: `flask db upgrade` then
`flask plugin upgrade-all`."""
from sqlalchemy import inspect, text
from shopdb.config import TestingConfig
from shopdb import create_app
from shopdb.extensions import db
from shopdb.plugins import plugin_manager
db_file = tmp_path / 'fresh.db'
url = f'sqlite:///{db_file}'
# Point the whole app (db engine + migration manager) at one file DB so the
# anchor stamps land where the app can read them back.
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI', url)
# create_app repoints the process-wide plugin_manager singleton; snapshot
# its wiring and restore it so later tests see the session app unchanged.
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
try:
app = create_app('testing')
with app.app_context():
db.create_all() # stand in for the core `flask db upgrade`
# db.create_all() over-creates: it builds EVERY table registered on
# the metadata, including post-cutover plugin tables the core chain
# would never own. Drop those so each post-cutover baseline creates
# its own tables exactly as it does after a real core upgrade (where
# the tables are simply absent). The cutover anchors are no-ops, so
# their create_all-built tables stay put.
insp0 = inspect(db.engine)
for plugin in POST_CUTOVER_PLUGINS:
# Drop by owned name (SQLite tolerates any order with no rows);
# avoids resolving cross-metadata FKs via sorted_tables.
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
if insp0.has_table(tablename):
db.session.execute(text(f'DROP TABLE {tablename}'))
db.session.commit()
first = app.extensions['plugin_manager'].upgrade_all_plugins()
second = app.extensions['plugin_manager'].upgrade_all_plugins()
# A plugin that owns no tables (Tech Tools: every tool is
# client-side) carries no chain, so upgrade-all reports it
# 'no-migrations'. Assert that explicitly rather than letting it
# widen the 'ok' check, which would also swallow a table-owning
# plugin whose chain silently went missing.
schemaless = set(first) - set(PLUGIN_TABLE_OWNERS)
for name in schemaless:
assert first[name] == 'no-migrations', (name, first[name])
assert second[name] == 'no-migrations', (name, second[name])
migrated = {name: status for name, status in first.items()
if name in PLUGIN_TABLE_OWNERS}
assert set(migrated) == set(PLUGIN_TABLE_OWNERS)
assert all(status == 'ok' for status in migrated.values()), migrated
assert all(second[name] == 'ok' for name in PLUGIN_TABLE_OWNERS), second
insp = inspect(db.engine)
for plugin in PLUGIN_TABLE_OWNERS:
version_table = f'alembic_version_{plugin}'
assert insp.has_table(version_table), f"missing {version_table}"
row = db.session.execute(
text(f'SELECT version_num FROM {version_table}')
).fetchone()
assert row and row[0] == EXPECTED_HEAD_REVISION[plugin]
finally:
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
# EVERY post-cutover chain, not just the single-baseline ones. This was once
# restricted to plugins whose whole chain is one baseline, on the reasoning that
# a longer chain meets a second problem - an ALTER whose column is already
# present - that guarding a create_table does not fix. True, but the conclusion
# was wrong: the answer is to guard the ALTERs too, not to stop testing the
# chains that have them. While this was narrowed, one plugin's 0003 sat
# unguarded for a month and would fail upgrade-all on any create-all-built
# database, taking 0004 down with it, with no test able to see it.
#
# The invariant is now the whole one: a post-cutover chain must run to its head
# against a database whose tables were built from the models and stamped
# nowhere, and must do it twice.
@pytest.mark.parametrize('plugin', POST_CUTOVER_PLUGINS)
def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch):
"""A baseline must stamp, not fail, when its table is already there.
Reported: `flask plugin upgrade-all` failed the tools plugin with "table
securesendsecrets already exists". Every post-cutover baseline was a plain
op.create_table, correct only while a table's existence and its plugin's
Alembic stamp can never disagree. They can - anything that builds tables
from the models (db.create_all) stamps nothing, and a run that creates a
table then fails before its stamp commits leaves the same split.
The other upgrade-all test DROPS these tables before running, so it only
ever covered the state where they are absent. This is the other state.
"""
from sqlalchemy import inspect, text
from shopdb.config import TestingConfig
from shopdb import create_app
from shopdb.extensions import db
from shopdb.plugins import plugin_manager
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI',
f'sqlite:///{tmp_path / "preexisting.db"}')
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
try:
app = create_app('testing')
with app.app_context():
db.create_all()
# Leave THIS plugin's tables in place - that is the whole point -
# and drop the other post-cutover ones so their own (longer) chains
# run the way the sibling test already covers.
insp0 = inspect(db.engine)
for other in POST_CUTOVER_PLUGINS:
if other == plugin:
continue
for tablename in PLUGIN_TABLE_OWNERS[other]:
if insp0.has_table(tablename):
db.session.execute(text(f'DROP TABLE {tablename}'))
db.session.commit()
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
assert inspect(db.engine).has_table(tablename), (
f'{tablename} should have been built by create_all; '
f'this test is meaningless without it')
first = app.extensions['plugin_manager'].upgrade_all_plugins()
assert first[plugin] == 'ok', first[plugin]
second = app.extensions['plugin_manager'].upgrade_all_plugins()
assert second[plugin] == 'ok', second[plugin]
insp = inspect(db.engine)
row = db.session.execute(
text(f'SELECT version_num FROM alembic_version_{plugin}')
).fetchone()
assert row and row[0] == EXPECTED_HEAD_REVISION[plugin]
# No second index over the same columns: create_all names indexes by
# SQLAlchemy's convention and the baseline by this repo's, so a
# name-keyed guard would have added a duplicate rather than skipped.
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
seen = [tuple(ix.get('column_names') or ())
for ix in insp.get_indexes(tablename)]
assert len(seen) == len(set(seen)), (
f'{tablename} has two indexes over the same columns: {seen}')
finally:
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
def test_upgrade_all_seeds_permissions_and_links_them_to_scopes(
tmp_path, monkeypatch):
"""`flask plugin upgrade-all` seeds plugin permissions AND relinks scopes.
on_install/on_enable fire only on a state transition, so a permission
declared by a LATER plugin version never reaches an already-installed site
through them. Upgrade is the only remaining moment. Before this was wired,
the name existed only after a manual `flask seed permissions`.
The scope relink is the half that actually bites. Creating the Permission
row only makes the name exist - the prebuilt scopes derive their grants from
the catalog, so a permission seeded without a following seedscopes() belongs
to nobody but admin. The plugin installs clean and its UI is invisible to
every non-admin, which reads as a broken plugin rather than a seeding gap.
"""
from shopdb.config import TestingConfig
from shopdb import create_app
from shopdb.extensions import db
from shopdb.core.models import Permission, Role
from shopdb.plugins import plugin_manager
db_file = tmp_path / 'perms.db'
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI',
f'sqlite:///{db_file}')
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
try:
app = create_app('testing')
with app.app_context():
db.create_all()
Permission.seed()
Role.seedscopes()
db.session.commit()
pm = app.extensions['plugin_manager']
# Stand in for a permission that a later plugin version declares:
# delete a plugin-owned view permission so upgrade-all has to put it
# back the way it would for a genuinely new one.
target = Permission.query.filter_by(name='endpoints.view').first()
assert target is not None, 'fixture expects endpoints.view to exist'
for role in list(target.roles):
role.permissions.remove(target)
db.session.delete(target)
db.session.commit()
assert Permission.query.filter_by(name='endpoints.view').first() is None
pm.upgrade_all_plugins()
# The row is back...
restored = Permission.query.filter_by(name='endpoints.view').first()
assert restored is not None, (
'upgrade-all did not re-seed a missing plugin permission')
# ...and it is actually granted to the read-capable scopes, not
# merely present. This is the assertion that fails if seedscopes()
# is dropped from the upgrade path.
holders = {role.rolename for role in restored.roles}
assert 'technician' in holders, (
f'endpoints.view reseeded but not linked to any scope: {holders}')
finally:
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
# ---------------------------------------------------------------------------
# ADR-008 lane guard: a plugin migration may only do DDL on its OWN tables.
#
# The endpoints chain broke this and nothing noticed. 0021 (shipped, since
# rewritten to a no-op stub) added five client-certificate columns plus
# idx_assets_cert_fingerprint to the CORE assets table from inside a plugin
# chain, and 0023 drops them again. Both directions are guarded and idempotent
# now, so the breach self-heals on every database - but self-healing is not
# prevention, and the only thing that stopped the next one was that somebody
# happened to read the file. That is what this guard replaces.
#
# The rule, stated as ADR-008 states it: the core Alembic chain owns core
# tables, each plugin chain owns the tables listed for it in
# PLUGIN_TABLE_OWNERS, and no chain reaches across. `assets` is not in
# PLUGIN_TABLE_OWNERS['endpoints'] and cannot be, so the per-plugin MetaData
# filter never saw the columns either: the ownership map was right and the
# migration simply ignored it.
# ---------------------------------------------------------------------------
CORE_MODELS_DIR = Path(__file__).resolve().parent.parent / 'shopdb' / 'core' / 'models'
# Alembic op name -> position of the table name in its positional arguments.
# create_index/drop_index take the INDEX name first and the table second, and
# every constraint op takes the CONSTRAINT name first, which is the whole
# reason this is a map and not "args[0]".
#
# The constraint ops are here because they are DDL on somebody's table just as
# much as add_column is: create_foreign_key / drop_constraint /
# create_unique_constraint / create_check_constraint / create_primary_key can
# all add or drop a constraint on core `assets` from inside a plugin chain.
# Leaving them out let that pass silently.
#
# create_foreign_key's REFERENT table is deliberately not recorded. The DDL
# runs against the source table only; naming another lane's table as a foreign
# key target is a read of its shape, not a write to it, and plugins legitimately
# point at core parents (network 0002 references `models`).
_TABLE_ARG_POSITION = {
'create_table': 0,
'drop_table': 0,
'add_column': 0,
'drop_column': 0,
'alter_column': 0,
'rename_table': 0,
'create_index': 1,
'drop_index': 1,
'create_foreign_key': 1,
'drop_constraint': 1,
'create_unique_constraint': 1,
'create_check_constraint': 1,
'create_primary_key': 1,
}
# Keywords that carry the table when it is not passed positionally. 0023 uses
# `table_name='assets'`, which an args-only scan walks straight past. `source`
# and `source_table` are the two spellings Alembic has used for
# create_foreign_key's own table; `referent`/`referent_table` are absent on
# purpose, for the reason given above.
_TABLE_KEYWORDS = ('table_name', 'source', 'source_table', 'old_table_name')
# Op name -> position of the COLUMN name in its positional arguments, for the
# ops that name exactly one column. The allowlist keys on this as well as the
# table, so an entry excusing a shipped drop_column cannot also excuse a NEW
# drop_column on the same table in the same file.
_COLUMN_ARG_POSITION = {
'add_column': 1,
'drop_column': 1,
'alter_column': 1,
}
# Same, for the batch_alter_table forms, where the table moved onto the context
# manager and every positional argument shifted left by one.
_BATCH_COLUMN_ARG_POSITION = {
'add_column': 0,
'drop_column': 0,
'alter_column': 0,
}
# --- raw SQL -----------------------------------------------------------------
# op.execute() and Connection.exec_driver_sql() hand a string straight to the
# database, so an AST scan that only knows the op.* helpers sees zero ops for
# `op.execute('ALTER TABLE assets DROP COLUMN assetnumber')`. That is the
# cheapest possible way around every other guard in this file, so the statement
# text is parsed too.
#
# Only DDL against a named table counts. UPDATE/DELETE/INSERT/SELECT are data,
# not schema: three plugin revisions already run cross-checked DML
# (backups 0002, backups 0003, computers 0006) and none of them is a lane
# crossing.
#
# Unresolvable text is rendered as a sentinel character rather than dropped, so
# the difference between "interpolates the constraint name" and "interpolates
# the table name" survives into the regex. machines 0002 does the first
# (`f"ALTER TABLE equipment DROP FOREIGN KEY {name}"` - the table is literal
# text, the FK name is not) and stays resolvable; the dodge this guards against
# is the second (`f'ALTER TABLE {t} DROP COLUMN x'`), where the sentinel lands
# inside the captured table name and the op reports UNRESOLVED, which
# test_plugin_migration_ops_all_resolve_to_a_table treats as a failure.
_SQL_HOLE = '\x00'
# Identifier characters plus the sentinel, so an interpolated name is captured
# rather than silently truncated to the literal prefix beside it.
_SQL_IDENT = r'[A-Za-z0-9_$`"\[\]\.' + _SQL_HOLE + r']+'
# (op name, compiled pattern, number of table groups). Ordered only for
# readability; every pattern is tried against every statement.
_SQL_DDL_PATTERNS = (
('execute:alter_table',
re.compile(r'\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(' + _SQL_IDENT + r')', re.I)),
('execute:drop_table',
re.compile(r'\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(' + _SQL_IDENT + r')', re.I)),
('execute:create_table',
re.compile(r'\bCREATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?('
+ _SQL_IDENT + r')', re.I)),
('execute:rename_table',
re.compile(r'\bRENAME\s+TABLE\s+(' + _SQL_IDENT + r')\s+TO\s+('
+ _SQL_IDENT + r')', re.I)),
('execute:create_index',
re.compile(r'\bCREATE\s+(?:UNIQUE\s+|FULLTEXT\s+|SPATIAL\s+)?INDEX\s+'
+ _SQL_IDENT + r'\s+ON\s+(' + _SQL_IDENT + r')', re.I)),
('execute:drop_index',
re.compile(r'\bDROP\s+INDEX\s+' + _SQL_IDENT + r'\s+ON\s+('
+ _SQL_IDENT + r')', re.I)),
('execute:truncate_table',
re.compile(r'\bTRUNCATE\s+(?:TABLE\s+)?(' + _SQL_IDENT + r')', re.I)),
)
# Statement verbs that are data, not schema. A statement starting with one of
# these is skipped outright, even if its body happens to contain a word like
# "table"; anything starting with something unreadable is recorded UNRESOLVED,
# because `op.execute(sql)` with sql built elsewhere is the same dodge as an
# f-string.
_SQL_DML_VERBS = ('select', 'insert', 'update', 'delete', 'replace', 'with',
'set', 'commit', 'rollback', 'analyze', 'optimize', 'call',
'pragma', 'flush', 'use', 'show', 'start')
# Historical lane crossings that have already shipped to production. Every entry
# is closed - listed so the guard is green today and red for anything new, NOT
# as a precedent to extend. Adding a line here means a plugin chain wrote to
# somebody else's table, which needs an ADR amendment, not an allowlist edit.
#
# The key is (plugin, filename, op, table, column). The column is part of it on
# purpose: keyed on the first four alone, the endpoints 0023 drop_column entry
# would also excuse a brand new `op.drop_column('assets', 'assetnumber')`
# appended to that same shipped file, which is exactly the breach this guard
# exists to stop. `None` means the op names no single column (drop_index) or
# names one the scan cannot resolve.
#
# endpoints 0023 is the CLEANUP of the 0021/0022 client-certificate breach: it
# drops the five cert columns and any index over them from the core assets
# table. It is applied on WJ production, so it cannot be edited - the fix is
# that no 0025 may do the same thing.
#
# 0021 and 0022 themselves are NOT listed, and must not be: they were rewritten
# to stamp-only no-ops, so they present no DDL to scan. Their damage lives on
# only as the 0023 cleanup below.
#
# The drop_index half of that cleanup (0023:71-73) reads alarmingly: it drops
# ANY index on assets whose column set INTERSECTS the cert columns, and never
# recreates one. An index over (assetid, client_cert_fingerprint) is dead once
# the column goes, but an index that merely happened to include a cert column
# alongside columns that still matter would be silently lost. Settled here so
# the next reader does not re-derive it - only these things ever create an index
# on assets:
# * core 68b3947ae14f baseline: idx_asset_active, idx_asset_location,
# idx_asset_status, idx_asset_type_bu, ix_assets_assetnumber,
# ix_assets_serialnumber
# * core 7b02: ix_assets_gaugelabreference, ix_assets_maintenancereference
# * core 7d33: idx_assets_levelid
# * the shipped endpoints 0021: idx_assets_cert_fingerprint over
# (client_cert_fingerprint) alone. The shipped 0022 added two more cert
# columns and no index at all.
# * db.create_all off shopdb/core/models/asset.py. Between 18e5fdc00
# (2026-09-14) and 7480e4fe3 (2026-09-15) that model DID carry the five cert
# columns, with client_cert_fingerprint declared index=True, so create_all
# built ix_assets_client_cert_fingerprint on every database created in that
# window. That is a second cert index, not just the one 0021 named, and the
# inventory above is wrong without it.
# It changes nothing for this loop. ix_assets_client_cert_fingerprint is
# single-column over client_cert_fingerprint, which 0023 drops in the very next
# statement, so the index carries no column that outlives it and there is
# nothing to recreate. The conclusion holds: the only indexes the loop can drop
# are idx_assets_cert_fingerprint and ix_assets_client_cert_fingerprint, both
# dead by construction. No index was lost and no repair migration is owed.
#
# The filter is also unreachable by accident: every name in _CERT_COLUMNS is
# underscore-spelled, which this repo's column convention forbids, so no real
# assets column can collide with it.
ALLOWED_CROSS_LANE_OPS = {
('endpoints', '0023_endpoint_credentials.py', 'drop_index', 'assets', None),
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
'client_cert_fingerprint'),
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
'cert_issued_at'),
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
'cert_expires_at'),
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
'cert_revoked_at'),
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
'cert_revocation_reason'),
}
def _core_tablenames() -> set:
"""Every table the CORE chain owns, read statically off shopdb/core/models.
Picks up both `__tablename__` on a model and the bare `db.Table(...)`
association tables (userroles, rolepermissions, userpermissions), which have
no model class and would otherwise read as unowned.
"""
names = set()
for source in CORE_MODELS_DIR.glob('*.py'):
tree = ast.parse(source.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
if '__tablename__' in targets and isinstance(node.value, ast.Constant):
names.add(node.value.value)
if isinstance(node, ast.Call):
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, 'id', None)
if name == 'Table' and node.args:
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
names.add(first.value)
return names
def _table_owner_map() -> dict:
"""table name -> owning lane ('core' or a plugin name)."""
owners = {}
for plugin, tables in PLUGIN_TABLE_OWNERS.items():
for tablename in tables:
owners[tablename] = plugin
for tablename in _core_tablenames():
# A plugin entry wins: pre-cutover the core chain CREATED the plugin
# tables, so a name can look core-shaped and still belong to a plugin.
owners.setdefault(tablename, 'core')
return owners
def _module_constants(tree) -> dict:
"""Module-level `NAME = 'sometable'` bindings.
Half the chains name their tables through constants (CREDENTIALS,
REQUESTS, _APPS), so a scan that only understands string literals sees
almost nothing.
"""
consts = {}
for node in tree.body:
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) \
and isinstance(node.value.value, str):
for target in node.targets:
if isinstance(target, ast.Name):
consts[target.id] = node.value.value
return consts
def _module_sequences(tree) -> dict:
"""Module-level `NAME = ('a', 'b')` bindings of all-string sequences.
Needed for the column half of the allowlist key: endpoints 0023 drops its
five columns with `for column in _CERT_COLUMNS`, and without this the column
resolves to nothing and the allowlist entry degrades back to a blanket
"drop_column on assets from this file is fine".
"""
seqs = {}
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
if not isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
continue
values = set()
for element in node.value.elts:
if not (isinstance(element, ast.Constant) and isinstance(element.value, str)):
values = set()
break
values.add(element.value)
if not values:
continue
for target in node.targets:
if isinstance(target, ast.Name):
seqs[target.id] = frozenset(values)
return seqs
def _literal(node, consts):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return consts.get(node.id)
return None
def _loop_bindings(tree, consts, seqs) -> dict:
"""Map each Call node to the loop variables in scope over a literal sequence.
`for table in (IMPACTS, TRANSITIONS, HEALTHSTATES): op.drop_table(table)`
(endpoints 0017) and the same shape in guides 0001 are real DDL over real
tables. Without this they resolve to nothing, and "resolves to nothing" has
to be a failure or the guard is dodgeable by writing a loop.
A bare module constant as the iterable (`for column in _CERT_COLUMNS`) is
resolved through `seqs` for the same reason.
"""
bindings = {}
for node in ast.walk(tree):
if not isinstance(node, ast.For) or not isinstance(node.target, ast.Name):
continue
values = set()
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
for element in node.iter.elts:
value = _literal(element, consts)
if value is None:
values = set()
break
values.add(value)
elif isinstance(node.iter, ast.Name):
values = set(seqs.get(node.iter.id, ()))
if not values:
continue
# ast.walk is breadth-first from the top, so an inner loop rebinding the
# same name is visited after the outer one and correctly shadows it.
for sub in ast.walk(node):
if isinstance(sub, ast.Call):
bindings.setdefault(id(sub), {})[node.target.id] = frozenset(values)
return bindings
def _resolve_tables(node, consts, loopvars) -> frozenset:
"""Every table name an argument could be. Empty means unresolvable."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return frozenset({node.value})
if isinstance(node, ast.Name):
if node.id in loopvars:
return loopvars[node.id]
if node.id in consts:
return frozenset({consts[node.id]})
return frozenset()
def _resolve_columns(opname, node, args, consts, loopvars, positions) -> frozenset:
"""The column name(s) an op names, or empty when it names none.
Empty is NOT a failure here the way it is for a table: most ops name no
column at all. It only widens the allowlist key to None.
"""
position = positions.get(opname)
if position is None or len(args) <= position:
return frozenset()
argument = args[position]
if opname == 'add_column':
# add_column takes sa.Column('name', ...), not a bare string.
if isinstance(argument, ast.Call) and argument.args:
argument = argument.args[0]
else:
return frozenset()
return _resolve_tables(argument, consts, loopvars)
def _flatten_sql(node, consts) -> str:
"""Statement text with every unresolvable fragment replaced by a sentinel.
Keeping the hole in place (instead of giving up on the whole string) is what
lets `f"ALTER TABLE equipment DROP FOREIGN KEY {name}"` resolve to
`equipment` while `f'ALTER TABLE {t} DROP COLUMN x'` resolves to nothing.
"""
if isinstance(node, ast.Constant):
return node.value if isinstance(node.value, str) else _SQL_HOLE
if isinstance(node, ast.JoinedStr):
return ''.join(_flatten_sql(value, consts) for value in node.values)
if isinstance(node, ast.FormattedValue):
return _SQL_HOLE
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
return _flatten_sql(node.left, consts) + _flatten_sql(node.right, consts)
if isinstance(node, ast.Name):
return consts.get(node.id, _SQL_HOLE)
if isinstance(node, ast.Call):
# sa.text('...') / text('...') is a wrapper, not a hole: computers 0006
# passes its statement that way.
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, 'id', None)
if name == 'text' and len(node.args) == 1:
return _flatten_sql(node.args[0], consts)
return _SQL_HOLE
def _clean_sql_identifier(raw: str) -> str:
"""`db`.`assets` -> assets. Keeps the sentinel if one landed in the name."""
name = raw.strip().rstrip('(;,')
if '.' in name:
name = name.rsplit('.', 1)[-1]
return name.strip('`"[]')
def _scan_raw_sql(node, consts) -> list:
"""(opname, frozenset-of-tables) for the DDL in one execute-style call.
An empty table set is UNRESOLVED and the caller fails on it. A statement
that is plainly DML returns nothing at all.
"""
statement = _flatten_sql(node, consts)
found = []
# Classify PER STATEMENT, not per call. Reading the first verb and bailing
# let a leading DML verb hide trailing DDL: op.execute("SET @x = 1; ALTER
# TABLE assets DROP COLUMN serialnumber") was skipped whole, because the
# scan saw SET, called it DML and returned nothing.
for part in statement.split(';'):
stripped = part.lstrip().lstrip('(')
verb = stripped.split(None, 1)[0].lower() if stripped.split() else ''
if not verb:
continue
if verb.startswith(_SQL_HOLE):
# The verb itself is interpolated, so nothing about this statement
# can be checked. Unresolved op rather than no op.
found.append(('execute:unknown', frozenset()))
continue
if verb in _SQL_DML_VERBS:
continue
for opname, pattern in _SQL_DDL_PATTERNS:
for match in pattern.finditer(part):
for group in match.groups():
name = _clean_sql_identifier(group)
if not name or _SQL_HOLE in name:
found.append((opname, frozenset()))
else:
found.append((opname, frozenset({name})))
return found
def _scan_plugin_migration_ops() -> list:
"""Every schema op in every plugin chain, as
(plugin, filename, lineno, opname, frozenset-of-tables,
frozenset-of-columns).
An empty table set means the argument could not be resolved statically;
the caller treats that as a failure rather than a pass. An empty column set
only means the op names no resolvable single column.
"""
found = []
for script in sorted(PLUGINS_DIR.glob('*/migrations/versions/*.py')):
plugin = script.parts[-4]
tree = ast.parse(script.read_text())
consts = _module_constants(tree)
seqs = _module_sequences(tree)
loops = _loop_bindings(tree, consts, seqs)
# batch_alter_table('assets') moves the table onto the context manager,
# so batch_op.drop_column('x') names a column where op.drop_column names
# a table. Resolve those against the `with` instead.
batched = {}
for node in ast.walk(tree):
if not isinstance(node, ast.With):
continue
for item in node.items:
call = item.context_expr
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)
and call.func.attr == 'batch_alter_table' and call.args):
continue
tables = _resolve_tables(call.args[0], consts, loops.get(id(call), {}))
alias = item.optional_vars.id if isinstance(item.optional_vars, ast.Name) else None
for sub in ast.walk(node):
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) \
and sub.func.attr in _TABLE_ARG_POSITION \
and isinstance(sub.func.value, ast.Name) \
and sub.func.value.id == alias:
batched[id(sub)] = tables
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
continue
opname = node.func.attr
base = node.func.value
# Raw SQL: op.execute(...), bind.execute(...) and any
# <connection>.exec_driver_sql(...).
#
# Both names are matched on the method name ALONE, without looking at
# what they are called on. Requiring the receiver to be literally `op`
# missed the repo's DOMINANT idiom: 24 call sites across endpoints
# 0006/0007/0010/0019, network 0003 and notifications 0005 all do
# `bind = op.get_bind()` and then `bind.execute(...)`, so a plugin
# could drop a column off the core assets table through a bind and
# the scan recorded nothing at all. Every shipped bind.execute passes
# a literal SELECT or UPDATE, which _SQL_DML_VERBS skips, so widening
# this costs no false positives.
if opname in ('exec_driver_sql', 'execute'):
if not node.args:
continue
for sqlop, tables in _scan_raw_sql(node.args[0], consts):
found.append((plugin, script.name, node.lineno, sqlop,
tables, frozenset()))
continue
if opname not in _TABLE_ARG_POSITION:
continue
loopvars = loops.get(id(node), {})
if id(node) in batched:
columns = _resolve_columns(opname, node, node.args, consts, loopvars,
_BATCH_COLUMN_ARG_POSITION)
found.append((plugin, script.name, node.lineno,
'batch_' + opname, batched[id(node)], columns))
continue
if not (isinstance(base, ast.Name) and base.id == 'op'):
continue
position = _TABLE_ARG_POSITION[opname]
tables = frozenset()
if len(node.args) > position:
tables = _resolve_tables(node.args[position], consts, loopvars)
for keyword in node.keywords:
if keyword.arg in _TABLE_KEYWORDS:
tables = tables | _resolve_tables(keyword.value, consts, loopvars)
columns = _resolve_columns(opname, node, node.args, consts, loopvars,
_COLUMN_ARG_POSITION)
found.append((plugin, script.name, node.lineno, opname, tables, columns))
if opname == 'rename_table' and len(node.args) > 1:
# Both ends of a rename are DDL on a table.
found.append((plugin, script.name, node.lineno, 'rename_table',
_resolve_tables(node.args[1], consts, loopvars),
frozenset()))
return found
def _cross_lane_ops() -> list:
"""Scanned ops whose table belongs to core or to a different plugin.
A table that is in neither the core models nor PLUGIN_TABLE_OWNERS is NOT a
crossing: that is a pre-rename name (machines 0002 renames equipment ->
machines) or a table since dropped, and neither reaches into another lane.
Ownership drift of a table that DOES still exist is already covered by
test_owned_tables_match_declared_models.
"""
owners = _table_owner_map()
crossings = []
for plugin, filename, lineno, opname, tables, columns in _scan_plugin_migration_ops():
for tablename in sorted(tables):
owner = owners.get(tablename)
if owner is None or owner == plugin:
continue
for column in (sorted(columns) or [None]):
crossings.append((plugin, filename, lineno, opname, tablename,
owner, column))
return crossings
def _crossing_keys() -> set:
return {(plugin, filename, opname, tablename, column)
for plugin, filename, _, opname, tablename, _, column in _cross_lane_ops()}
def test_plugin_migration_ops_all_resolve_to_a_table():
"""Every scanned op names a table the scan can actually resolve.
Without this the guard is dodgeable by accident: a table name built at
runtime resolves to nothing, and "nothing" would otherwise read as "touches
no foreign table" and pass. It is also what makes the raw-SQL scan real -
`op.execute(f'ALTER TABLE {t} ...')` lands here rather than going quietly
green.
"""
unresolved = [(plugin, filename, lineno, opname)
for plugin, filename, lineno, opname, tables, _
in _scan_plugin_migration_ops() if not tables]
assert not unresolved, (
'plugin migration ops whose table name could not be resolved '
f'statically: {unresolved}')
def test_scan_sees_the_known_lane_crossing():
"""The scanner is not vacuous: it still finds the 0023 assets cleanup.
If a refactor breaks the AST walk, every other assertion here goes green on
an empty result set. This one goes red.
"""
scanned = _scan_plugin_migration_ops()
assert len(scanned) > 100, f'scan found only {len(scanned)} ops; it is broken'
keys = _crossing_keys()
assert ALLOWED_CROSS_LANE_OPS <= keys, (
'the allowlisted historical crossings are no longer detected: '
f'{ALLOWED_CROSS_LANE_OPS - keys}')
def test_scan_reads_raw_sql_ddl():
"""The raw-SQL half is not vacuous either.
machines 0002 does its whole MySQL rename through exec_driver_sql. If the
statement parser stops working, those ops vanish and nothing else in this
file notices.
"""
raw = [(plugin, filename, opname, tuple(sorted(tables)))
for plugin, filename, _, opname, tables, _
in _scan_plugin_migration_ops() if opname.startswith('execute:')]
assert ('machines', '0002_rename_from_equipment.py', 'execute:rename_table',
('equipmenttypes',)) in raw, raw
assert ('machines', '0002_rename_from_equipment.py', 'execute:alter_table',
('machines',)) in raw, raw
# DML stays out of it, or the three shipped UPDATE statements would read as
# schema changes on tables their own plugin owns.
assert not [entry for entry in raw
if entry[0] == 'backups' or entry[1].startswith('0006_policy')], raw
def test_no_plugin_migration_touches_another_lanes_table():
"""ADR-008: a plugin chain does DDL only on the tables it owns.
Fails on create_table/drop_table/add_column/drop_column/alter_column/
create_index/drop_index/rename_table, the five constraint ops, the
batch_alter_table forms, and DDL inside op.execute/exec_driver_sql, against
a core table or another plugin's table. The allowlist above holds exactly
the entries that already shipped.
"""
offenders = [crossing for crossing in _cross_lane_ops()
if (crossing[0], crossing[1], crossing[3], crossing[4], crossing[6])
not in ALLOWED_CROSS_LANE_OPS]
assert not offenders, '\n'.join(
f'{plugin} chain: {filename}:{lineno} {opname} on {tablename!r}'
+ (f' column {column!r}' if column else '')
+ f', owned by {owner} (ADR-008)'
for plugin, filename, lineno, opname, tablename, owner, column in offenders)
def test_cross_lane_allowlist_has_no_dead_entries():
"""The allowlist does not outlive what it excuses.
An entry left behind after its migration is squashed or rewritten is a
standing licence to write to a core table from that file again.
"""
keys = _crossing_keys()
assert not (ALLOWED_CROSS_LANE_OPS - keys), (
'allowlist entries that match nothing in the tree: '
f'{ALLOWED_CROSS_LANE_OPS - keys}')