Files
shopdb-flask/tests/test_plugin_migrations.py
cproudlock 98458672eb
Some checks failed
CI / backend (push) Failing after 7m16s
CI / naming (push) Failing after 7m5s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m10s
Hand somebody a password without leaving it in a chat window
The habit this replaces is pasting a credential into Teams, where it stays until
someone deletes the conversation. Tabled on 2026-08-21 pending a decision about
scope; the decision was text only, so this is the half that survives it.

BACKEND ONLY. There is no page yet, so nothing here is reachable by a person.

TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the
export-control question entirely - a password is not technical data - along with
blob storage, size and MIME handling, and the need to authenticate the recipient
so a document fetch could be pinned to a name.

TWO DOORS. Creating needs a login AND securesend.create: anonymous creation
would be an untraceable way to move data out of a system that otherwise records
who did what, and the creator is the only person these rows can ever name.
Reading needs neither, because the recipient usually has no account here and the
link is handed over on a call - the unguessable token IS the authorisation,
which is why it is 128 bits of randomness and why the row is spent on first read.

REDEEMING IS A POST, and that is the detail that would have broken this in
practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and
scan for malware, so a GET that redeemed would let a preview window consume a
secret before its recipient ever saw it. The GET route that remains reports only
whether a link is still live, so the read page can say "opening this will
destroy it" without spending the only view to find out.

THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment,
which browsers never transmit, so the server holds ciphertext it cannot read and
cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not
a tuning knob: raising it quietly turns this into the document transfer it was
scoped not to be. The refusal says what the limit is FOR, because somebody
hitting it is usually trying to send a file and the answer is a different tool.
Line counts, file signatures and refusing a pasted file belong in the browser,
guide an honest person, and are bypassable by anyone willing to POST directly.

NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them
apart would confirm to anyone holding a guessed token whether it ever named
anything. A spent or expired row is deleted on the way past rather than waiting
for a sweeper, because it has no reason to keep its ciphertext.

Its own plugin rather than a Tech Tool: that plugin is browser-only by design,
and this needs storage, expiry and a view counter. Not an asset extension
either, so no assetid and no asset type - the scaffold assumes one and it was
removed.

FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats
an explicit zero as absent, so "expire immediately" silently became "expire in a
day". A caller who sends a number is now told when it is not allowed rather than
quietly given a different one.

BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL
fragments: create a secret, send the link to yourself in Teams, click it from the
message, and check the key is still after the #. If Safe Links strips it the
secret is unrecoverable and the server cannot help, which is the point of the
design. The fallback is passphrase mode, worth building only if that test says so.

Three product gates caught what a new plugin has to declare, and each was right
to: the route-parity gate wanted the endpoints described in api-inventory.json,
the lean-build guard wanted the plugin listed in the universal profile the
released installer builds from, and the authz sweep wanted the public redeem
route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a
mutating route that answers an unauthenticated caller is a decision somebody has
to write down, not a default, and reading a secret is a mutation because it
spends it.
2026-08-21 13:44:13 -04:00

278 lines
14 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
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 this exact set of ten plugins whose tables the core
# chain had already created. Their 0001 revision is a stamp-only no-op anchor.
# Plugins built AFTER the cutover (e.g. measuringtools) 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.
CUTOVER_PLUGINS = (
'computers', 'employees', 'knowledgebase', 'machines', 'network',
'notifications', 'printers', 'slides', 'usb', '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'; measuringtools stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# 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'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
# 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'
# employees adds the photofilename column on top of its cutover anchor.
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# 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'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# Built after the ADR-008 cutover, so its 0001 genuinely creates the table
# rather than stamping an anchor.
EXPECTED_HEAD_REVISION['securesend'] = 'securesend0001baseline'
# notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
# warranty adds the proof-of-cover document columns on top of its anchor.
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# 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"
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 (measuringtools), 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