Files
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

211 lines
7.1 KiB
Python

"""Tests for API-key scope enforcement and sensitive-data masking."""
import pytest
from flask import Blueprint, g, jsonify
from app.middleware.api_scope_middleware import require_scope, SCOPES, SCOPE_KEYS
from app.utils.sensitive_data_filter import (
mask_sensitive,
is_sensitive_key,
scopes_for_request,
MASK,
SENSITIVE_KEY_PATTERNS,
)
from app.models.api_key import ApiKey
# Registers blueprints / url rules on the app fixture. Flask cannot unregister
# those, so these tests need a private app rather than the session-wide one
# (plan 64 Phase 1).
pytestmark = pytest.mark.fresh_app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _key_with_scopes(scopes):
"""Build an unsaved ApiKey carrying the given scopes."""
k = ApiKey(user_id=1, name='test', key_prefix='sk_test', key_hash='x')
k.set_scopes(scopes)
return k
@pytest.fixture
def scoped_app(app):
"""Register a throwaway blueprint whose route requires 'apps:read'."""
bp = Blueprint('scope_probe', __name__)
@bp.route('/probe')
@require_scope('apps:read')
def probe():
return jsonify({'ok': True})
app.register_blueprint(bp, url_prefix='/__test')
return app
# ---------------------------------------------------------------------------
# require_scope: API-key requests
# ---------------------------------------------------------------------------
def test_require_scope_blocks_key_missing_scope(scoped_app):
@scoped_app.route('/__probe_block')
@require_scope('apps:read')
def _block(): # pragma: no cover - exercised via client
return jsonify({'ok': True})
with scoped_app.test_request_context('/__probe_block'):
g.api_key = _key_with_scopes(['databases:read'])
rv = _block()
body, status = rv
assert status == 403
assert 'Insufficient API key scope' in body.get_json()['error']
def test_require_scope_allows_key_with_exact_scope(scoped_app):
@scoped_app.route('/__probe_exact')
@require_scope('apps:read')
def _exact():
return jsonify({'ok': True})
with scoped_app.test_request_context('/__probe_exact'):
g.api_key = _key_with_scopes(['apps:read'])
rv = _exact()
assert rv.get_json() == {'ok': True}
def test_require_scope_allows_full_access_star(scoped_app):
@scoped_app.route('/__probe_star')
@require_scope('apps:read', 'databases:write')
def _star():
return jsonify({'ok': True})
with scoped_app.test_request_context('/__probe_star'):
g.api_key = _key_with_scopes(['*'])
rv = _star()
assert rv.get_json() == {'ok': True}
def test_require_scope_allows_resource_wildcard(scoped_app):
@scoped_app.route('/__probe_wild')
@require_scope('apps:read')
def _wild():
return jsonify({'ok': True})
with scoped_app.test_request_context('/__probe_wild'):
g.api_key = _key_with_scopes(['apps:*'])
rv = _wild()
assert rv.get_json() == {'ok': True}
def test_require_scope_requires_all_listed_scopes(scoped_app):
@scoped_app.route('/__probe_all')
@require_scope('apps:read', 'apps:deploy')
def _all():
return jsonify({'ok': True})
# Has apps:read but not apps:deploy -> blocked.
with scoped_app.test_request_context('/__probe_all'):
g.api_key = _key_with_scopes(['apps:read'])
rv = _all()
_, status = rv
assert status == 403
# ---------------------------------------------------------------------------
# require_scope: JWT/session requests pass through
# ---------------------------------------------------------------------------
def test_require_scope_passthrough_for_jwt(scoped_app):
@scoped_app.route('/__probe_jwt')
@require_scope('apps:read')
def _jwt():
return jsonify({'ok': True})
# No g.api_key set -> treated as JWT/session -> pass through.
with scoped_app.test_request_context('/__probe_jwt'):
assert getattr(g, 'api_key', None) is None
rv = _jwt()
assert rv.get_json() == {'ok': True}
# ---------------------------------------------------------------------------
# Scope catalog sanity
# ---------------------------------------------------------------------------
def test_scope_catalog_covers_required_scopes():
required = {
'read', 'write', 'apps:read', 'apps:write', 'apps:deploy',
'databases:read', 'databases:write', 'domains:read', 'domains:write',
'dns:read', 'dns:write', 'backups:read', 'backups:write',
'servers:read', 'servers:admin', 'secrets:read',
}
assert required.issubset(SCOPE_KEYS)
# Every entry is well-formed.
for entry in SCOPES:
assert {'key', 'label', 'group', 'description'} <= set(entry)
# ---------------------------------------------------------------------------
# mask_sensitive
# ---------------------------------------------------------------------------
def test_mask_sensitive_redacts_known_keys():
data = {
'name': 'db1',
'password': 'hunter2',
'api_key': 'sk_abc',
'token': 'tok',
'private_key': '-----BEGIN-----',
'nested': {'db_secret': 'shh', 'host': 'localhost'},
'list': [{'credential': 'c'}, {'plain': 'ok'}],
}
out = mask_sensitive(data, allowed_scopes=['apps:read'])
assert out['name'] == 'db1'
assert out['password'] == MASK
assert out['api_key'] == MASK
assert out['token'] == MASK
assert out['private_key'] == MASK
assert out['nested']['db_secret'] == MASK
assert out['nested']['host'] == 'localhost'
assert out['list'][0]['credential'] == MASK
assert out['list'][1]['plain'] == 'ok'
# Original is not mutated.
assert data['password'] == 'hunter2'
def test_mask_sensitive_respects_secrets_read_scope():
data = {'password': 'hunter2', 'key_hash': 'deadbeef'}
out = mask_sensitive(data, allowed_scopes=['secrets:read'])
assert out['password'] == 'hunter2'
assert out['key_hash'] == 'deadbeef'
def test_mask_sensitive_full_access_reveals():
data = {'secret': 's'}
assert mask_sensitive(data, allowed_scopes=['*'])['secret'] == 's'
def test_mask_sensitive_leaves_none_values():
data = {'password': None}
assert mask_sensitive(data, allowed_scopes=['read'])['password'] is None
def test_is_sensitive_key_false_positive_exceptions():
assert is_sensitive_key('password') is True
assert is_sensitive_key('is_secret') is False
assert is_sensitive_key('token_expires_at') is False
assert is_sensitive_key('name') is False
def test_sensitive_key_patterns_exported():
assert 'password' in SENSITIVE_KEY_PATTERNS
assert 'token' in SENSITIVE_KEY_PATTERNS
def test_scopes_for_request_defaults_to_full_access_without_key(app):
with app.test_request_context('/'):
# No g.api_key -> JWT/session caller -> full access.
assert scopes_for_request() == ['*']
def test_scopes_for_request_uses_api_key_scopes(app):
with app.test_request_context('/'):
g.api_key = _key_with_scopes(['apps:read', 'dns:read'])
assert scopes_for_request() == ['apps:read', 'dns:read']