Add the get_permissions plugin hook (contract 0.10.0)
Plugins declare their own RBAC permissions instead of core accumulating them: 36 permissions moved out of the core catalog into the 9 owning plugins (core keeps the 19 its own blueprints enforce). The catalog is resolved dynamically (core + enabled plugins) and feeds the roles grid, the token scope picker and ceiling, and flask seed permissions; installing or enabling a plugin seeds its permissions automatically. A disabled plugin drops out of the assignable catalog while existing role links keep working. New plugins - bundled or external - now bring their permissions with zero core edits. 781 tests pass; live-verified with a machines.edit-scoped token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
90
tests/test_core/test_permissions_catalog.py
Normal file
90
tests/test_core/test_permissions_catalog.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Tests for the get_permissions hook consumer chain (contract 0.10.0).
|
||||
|
||||
Pins full_permission_catalog() and its consumers: the core catalog holds no
|
||||
plugin permissions, an enabled plugin's permissions merge in and drop out when
|
||||
disabled, seeding is idempotent, API-token scope validation follows the enabled
|
||||
set, and the role grid groups plugin categories.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Permission, full_permission_catalog
|
||||
from shopdb.core.models.apitoken import ApiToken
|
||||
|
||||
# Permission-owning plugin domains that must NOT live in the core catalog.
|
||||
PLUGIN_PREFIXES = ('machines.', 'computers.', 'printers.', 'network.', 'kb.',
|
||||
'notifications.', 'usb.', 'warranty.', 'measuringtools.')
|
||||
|
||||
|
||||
def test_core_catalog_excludes_plugin_permissions():
|
||||
"""Permission.CORE_PERMISSIONS holds only genuinely core permissions."""
|
||||
names = {n for n, _d, _c in Permission.CORE_PERMISSIONS}
|
||||
for name in names:
|
||||
assert not name.startswith(PLUGIN_PREFIXES), (
|
||||
f'{name} is a plugin permission and must move to its plugin hook')
|
||||
# Core sets stay put.
|
||||
assert 'assets.edit' in names
|
||||
assert 'settings.edit' in names
|
||||
assert 'collector.ingest' in names
|
||||
|
||||
|
||||
def test_enabled_plugin_permissions_merge(app, monkeypatch):
|
||||
"""An enabled plugin's permissions appear in full_permission_catalog()."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
with app.app_context():
|
||||
names = {n for n, _d, _c in full_permission_catalog()}
|
||||
assert 'machines.edit' in names
|
||||
assert 'printers.edit' in names
|
||||
# core still present alongside plugin permissions
|
||||
assert 'assets.edit' in names
|
||||
|
||||
|
||||
def test_disabled_plugin_permissions_drop_out(app, monkeypatch):
|
||||
"""A disabled plugin's permissions disappear from the catalog."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled',
|
||||
lambda name: name != 'machines')
|
||||
with app.app_context():
|
||||
names = {n for n, _d, _c in full_permission_catalog()}
|
||||
assert 'machines.edit' not in names
|
||||
assert 'printers.edit' in names # other plugins still enabled
|
||||
|
||||
|
||||
def test_seed_is_idempotent(app, db, monkeypatch):
|
||||
"""Seeding the full catalog twice creates each permission once."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
with app.app_context():
|
||||
first = Permission.seed()
|
||||
db.session.commit()
|
||||
assert first > len(Permission.CORE_PERMISSIONS) # plugins contributed
|
||||
second = Permission.seed()
|
||||
db.session.commit()
|
||||
assert second == 0
|
||||
|
||||
|
||||
def test_token_scope_validation_follows_enabled_set(app, monkeypatch):
|
||||
"""A plugin permission is an accepted scope only while the plugin is enabled."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
with app.app_context():
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
assert ApiToken.unknown_scope_names(['machines.edit']) == []
|
||||
assert ApiToken.unknown_scope_names(['assets.edit']) == []
|
||||
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled',
|
||||
lambda name: name != 'machines')
|
||||
assert ApiToken.unknown_scope_names(['machines.edit']) == ['machines.edit']
|
||||
|
||||
|
||||
def test_list_permissions_endpoint_groups_plugin_category(
|
||||
app, client, auth_headers, monkeypatch):
|
||||
"""GET /api/users/permissions surfaces enabled plugins' categories."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
response = client.get('/api/users/permissions', headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
grouped = response.get_json()['data']['grouped']
|
||||
assert 'assets' in grouped # core category always present
|
||||
assert 'machines' in grouped # enabled plugin category merged in
|
||||
@@ -156,6 +156,28 @@ def test_plugin_get_reports_is_iterable(plugin_instances, name):
|
||||
)
|
||||
|
||||
|
||||
def test_baseplugin_has_permissions_hook():
|
||||
"""The permissions hook is on the contract surface (contract 0.10.0)."""
|
||||
assert hasattr(BasePlugin, 'get_permissions')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_permissions_shape(plugin_instances, name):
|
||||
"""get_permissions returns a list of (name, description, category) entries."""
|
||||
plugin = plugin_instances[name]
|
||||
perms = plugin.get_permissions()
|
||||
assert isinstance(perms, list)
|
||||
for entry in perms:
|
||||
if isinstance(entry, dict):
|
||||
pname, category = entry['name'], entry.get('category')
|
||||
else:
|
||||
assert len(entry) == 3, f'{name}: entry must be a 3-tuple'
|
||||
pname, _desc, category = entry
|
||||
assert isinstance(pname, str) and '.' in pname, (
|
||||
f'{name}: permission name {pname!r} must be dotted')
|
||||
assert category, f'{name}: permission {pname} needs a category'
|
||||
|
||||
|
||||
def test_baseplugin_has_frontend_contribution_hooks():
|
||||
"""The four ADR-010 frontend-contribution hooks are on the contract (0.7.0)."""
|
||||
for hook in ('get_settings_cards', 'get_asset_panels',
|
||||
|
||||
Reference in New Issue
Block a user