Files
ServerKit/backend/tests/test_extension_platform.py
Juan DenisandClaude Opus 5 9d570834aa perf(tests): build the Flask app once per session, not once per test
The `app` fixture called create_app('testing') for every one of 3173 tests,
registering 90+ blueprints each time. Measured on a fixed 199-test sample
(pytest --splits 16 --group 5 --durations=0):

    setup     144.9s   89.5%
    call        8.8s    5.4%
    teardown    8.2s    5.0%

95% of the suite's time was preparing to test; 5% was testing. The app is now
built once per process and shared.

Same 794-test slice, before and after — identical results, 2.66x faster:

    before   4 failed, 773 passed, 17 skipped   593.30s
    after    4 failed, 773 passed, 17 skipped   223.11s

(the 4 are pre-existing locally: serverkit-wordpress isn't checked out here.)

Sharing one app surfaces three things a per-test app hid, all handled in the
fixture rather than patched test by test:

1. create_app() is not a pure constructor — it SEEDS the bundled flagship
   extension rows as a boot side effect (test_cloudflare_extraction asserts
   they are "seeded on boot"). The per-test drop_all wipes them, and a shared
   app never re-boots, so every test after the first saw no flagship and the
   extension routes answered 503. seed_flagship_extensions() is now re-run
   after each create_all. The session fixture also boots twice on purpose:
   once to create the schema, once to seed into a schema that exists.

2. Config mutation now leaks where it used to die with the app (e.g.
   test_demo_deploys sets DEMO_DEPLOYS_ENABLED=False and never restores it),
   so config is snapshotted and restored around every test.

3. Structure mutation cannot be undone at all — Flask has no
   unregister_blueprint, and refuses setup methods once an app has served a
   request. Those tests opt out via `pytestmark = pytest.mark.fresh_app` and
   get a private app. Three groups needed it: 13 modules calling
   register_blueprint/add_url_rule directly, 11 that install plugins (
   plugin_service hot-loads their blueprints onto the live app), and
   test_audit_log which adds routes with @app.route.

Tests using the `wp_extension` fixture are detected from the fixture graph
instead of being marked, since that fixture mounts blueprints on whatever app
it is handed — miss one marker and the failure lands in an unrelated test much
later in the run.

Plan 64 Phase 1. Phase 2 (savepoint rollback instead of create_all/drop_all)
is NOT included: it currently takes the slice from 4 failures to 100 failures
and 201 errors, and needs its own round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 02:59:14 -04:00

246 lines
9.0 KiB
Python

"""Phase 3 platform primitives: version gates, permissions, data models,
declarative jobs, socket extension point, and data-purge on uninstall.
Backend plugin code normally lives at app/plugins/<slug>/ on disk and is imported
by name. To exercise the importlib-driven hooks without writing packages into the
repo, we inject synthetic modules into sys.modules under the expected names.
"""
import io
import json
import sys
import types
import zipfile
import pytest
from app import db
from app.models.plugin import InstalledPlugin
from app.services import plugin_service, extension_lifecycle
from app.plugins_sdk import permissions, sockets as sdk_sockets
import app.plugins_sdk as sdk
# Installs plugins, and plugin_service hot-loads their blueprints onto the
# live app. Flask refuses register_blueprint once an app has served a
# request, so these need a private app (plan 64 Phase 1).
pytestmark = pytest.mark.fresh_app
SLUG = 'testext'
# A plugin-owned model, defined once against the real db so it registers on the
# shared metadata a single time (extend_existing keeps re-imports harmless).
class _ExtThing(db.Model):
__tablename__ = 'ext_testext_thing'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
label = db.Column(db.String(40))
_job_calls = []
def _inject_plugin_modules():
"""Register synthetic app.plugins.testext.{models,jobs,sockets} modules."""
pkg = types.ModuleType(f'app.plugins.{SLUG}')
pkg.__path__ = []
sys.modules[f'app.plugins.{SLUG}'] = pkg
models_mod = types.ModuleType(f'app.plugins.{SLUG}.models')
models_mod.register = lambda _db: [_ExtThing]
sys.modules[f'app.plugins.{SLUG}.models'] = models_mod
jobs_mod = types.ModuleType(f'app.plugins.{SLUG}.jobs')
jobs_mod.reindex = lambda job: _job_calls.append(job)
sys.modules[f'app.plugins.{SLUG}.jobs'] = jobs_mod
sockets_mod = types.ModuleType(f'app.plugins.{SLUG}.sockets')
def _register():
def on_connect():
return 'connected'
def on_ping(data):
return {'pong': data}
return {'connect': on_connect, 'ping': on_ping}
sockets_mod.register = _register
sys.modules[f'app.plugins.{SLUG}.sockets'] = sockets_mod
@pytest.fixture
def injected_plugin():
_inject_plugin_modules()
yield
for name in list(sys.modules):
if name == f'app.plugins.{SLUG}' or name.startswith(f'app.plugins.{SLUG}.'):
del sys.modules[name]
_job_calls.clear()
def _plugin_row(manifest):
p = InstalledPlugin(
name=SLUG, display_name='Test Ext', slug=SLUG, version='1.0.0',
status=InstalledPlugin.STATUS_ACTIVE, has_backend=True,
)
p.manifest = manifest
db.session.add(p)
db.session.commit()
return p
# --------------------------------------------------------------------------- #
# #28 — version compat gate at install (all sources)
# --------------------------------------------------------------------------- #
def test_min_panel_version_blocks_install(app):
manifest = {'name': 'toonew', 'display_name': 'Too New', 'version': '1.0.0',
'min_panel_version': '999.0.0'}
with pytest.raises(ValueError, match='needs panel'):
plugin_service._assert_manifest_panel_compatible(manifest)
def test_no_bounds_installs_anywhere(app):
# Missing bounds → no exception.
plugin_service._assert_manifest_panel_compatible(
{'name': 'x', 'display_name': 'X', 'version': '1.0.0'})
def test_sdk_exposes_panel_version(app):
v = sdk.panel_version()
assert isinstance(v, str) and v
# --------------------------------------------------------------------------- #
# #25 — permission enforcement
# --------------------------------------------------------------------------- #
def test_permission_gate(app):
_plugin_row({'permissions': ['docker', 'network']})
assert permissions.has(SLUG, 'docker') is True
assert permissions.has(SLUG, 'shell') is False
assert permissions.require(SLUG, 'docker') is True
with pytest.raises(permissions.PermissionDenied):
permissions.require(SLUG, 'shell')
# SDK re-export points at the same gate.
with pytest.raises(permissions.PermissionDenied):
sdk.require_permission(SLUG, 'filesystem')
def test_unknown_permissions_flagged():
assert permissions.unknown_permissions(['docker', 'bogus', 'agent.command:x']) == ['bogus']
# --------------------------------------------------------------------------- #
# #24 — plugin data models + purge
# --------------------------------------------------------------------------- #
def test_register_and_purge_models(app, injected_plugin):
from sqlalchemy import inspect
p = _plugin_row({'models': 'models:register'})
extension_lifecycle.register_models(p, p.manifest)
assert 'ext_testext_thing' in inspect(db.engine).get_table_names()
dropped = extension_lifecycle.purge_models(p)
assert dropped >= 1
assert 'ext_testext_thing' not in inspect(db.engine).get_table_names()
# --------------------------------------------------------------------------- #
# #29 — declarative jobs (register / pause / resume / remove)
# --------------------------------------------------------------------------- #
def test_declarative_jobs_lifecycle(app, injected_plugin):
from app.jobs import registry
from app.jobs.models import ScheduledJob
manifest = {
'jobs': [{'kind': 'testext.reindex', 'handler': 'jobs:reindex'}],
'schedules': [{'name': 'testext-nightly', 'kind': 'testext.reindex',
'interval_seconds': 3600}],
}
p = _plugin_row(manifest)
extension_lifecycle.register_jobs(p, manifest)
assert registry.get('testext.reindex') is not None
sched = ScheduledJob.query.filter_by(name='testext-nightly').first()
assert sched is not None and sched.enabled is True
extension_lifecycle.pause_jobs(p, manifest)
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is False
extension_lifecycle.resume_jobs(p, manifest)
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is True
extension_lifecycle.remove_jobs(p, manifest)
assert ScheduledJob.query.filter_by(name='testext-nightly').first() is None
def test_disable_enable_pauses_jobs(app, injected_plugin):
from app.jobs.models import ScheduledJob
manifest = {
'schedules': [{'name': 'testext-nightly', 'kind': 'testext.reindex',
'interval_seconds': 3600}],
}
p = _plugin_row(manifest)
extension_lifecycle.register_jobs(p, manifest)
plugin_service.disable_plugin(p.id)
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is False
plugin_service.enable_plugin(p.id)
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is True
# --------------------------------------------------------------------------- #
# #26 — Socket.IO extension point
# --------------------------------------------------------------------------- #
class _FakeIO:
def __init__(self):
self.registered = []
def on_event(self, event, handler, namespace=None):
self.registered.append((event, namespace, handler))
def test_socket_namespace_registration(app, injected_plugin, monkeypatch):
fake = _FakeIO()
monkeypatch.setattr('app.get_socketio', lambda: fake, raising=False)
import app as app_pkg
monkeypatch.setattr(app_pkg, 'get_socketio', lambda: fake)
p = _plugin_row({'socket_entry': 'sockets:register'})
ns = sdk_sockets.register_from_manifest(p, p.manifest)
assert ns == f'/ext/{SLUG}'
events = {e for e, _ns, _h in fake.registered}
assert events == {'connect', 'ping'}
assert all(_ns == f'/ext/{SLUG}' for _e, _ns, _h in fake.registered)
# --------------------------------------------------------------------------- #
# #30 — uninstall purge vs keep-data
# --------------------------------------------------------------------------- #
def test_uninstall_purge_drops_tables(app, injected_plugin, tmp_path, monkeypatch):
from sqlalchemy import inspect
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(tmp_path / 'b'))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(tmp_path / 'f'))
p = _plugin_row({'models': 'models:register'})
extension_lifecycle.register_models(p, p.manifest)
assert 'ext_testext_thing' in inspect(db.engine).get_table_names()
plugin_service.uninstall_plugin(p.id, purge=True)
assert 'ext_testext_thing' not in inspect(db.engine).get_table_names()
def test_uninstall_keep_data_leaves_tables(app, injected_plugin, tmp_path, monkeypatch):
from sqlalchemy import inspect
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(tmp_path / 'b'))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(tmp_path / 'f'))
p = _plugin_row({'models': 'models:register'})
extension_lifecycle.register_models(p, p.manifest)
plugin_service.uninstall_plugin(p.id, purge=False)
assert 'ext_testext_thing' in inspect(db.engine).get_table_names()