Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
148
tests/conftest.py
Normal file
148
tests/conftest.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Pytest configuration and fixtures for shopdb-flask.
|
||||
|
||||
Strategy: in-memory SQLite via StaticPool (configured in TestingConfig)
|
||||
so the database is shared across the connection. Each test drops and
|
||||
recreates the schema. Simple, totally isolated, fast enough for a small
|
||||
schema. Switch to savepoint-per-test if test count grows past a few
|
||||
hundred.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
# Force testing config before any shopdb import touches the env.
|
||||
os.environ['FLASK_ENV'] = 'testing'
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
def _bootstrap_plugin_registry():
|
||||
# Fresh clones (CI) have no instance/plugins.json - the registry is
|
||||
# gitignored dev state - so no plugins would load and every plugin-backed
|
||||
# test fails. Seed a registry enabling all bundled plugins. An existing
|
||||
# dev registry is left untouched.
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
state_file = repo / 'instance' / 'plugins.json'
|
||||
if state_file.exists():
|
||||
return
|
||||
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||
plugins = {}
|
||||
for manifest_path in sorted((repo / 'plugins').glob('*/manifest.json')):
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
plugins[manifest['name']] = {
|
||||
'name': manifest['name'],
|
||||
'version': manifest.get('version', '1.0.0'),
|
||||
'installed_at': stamp,
|
||||
'enabled': True,
|
||||
'migrations_applied': [],
|
||||
'config': {},
|
||||
}
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_file.write_text(json.dumps({'plugins': plugins}, indent=2))
|
||||
|
||||
|
||||
_bootstrap_plugin_registry()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""Create the Flask application for the test session."""
|
||||
application = create_app('testing')
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def db(app):
|
||||
"""Provide a fresh database per test. Drops and recreates schema each run."""
|
||||
with app.app_context():
|
||||
_db.create_all()
|
||||
yield _db
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Flask test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""Flask CLI test runner."""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
"""Create an admin user for authenticated tests.
|
||||
|
||||
The user has username 'testadmin' and password 'testpass'.
|
||||
"""
|
||||
from shopdb.core.models import User, Role
|
||||
|
||||
role = Role(rolename='admin', description='Administrator')
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
|
||||
user = User(
|
||||
username='testadmin',
|
||||
email='admin@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client, admin_user):
|
||||
"""Log in as admin_user and return Authorization headers."""
|
||||
response = client.post(
|
||||
'/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'},
|
||||
)
|
||||
assert response.status_code == 200, f'Login failed: {response.get_json()}'
|
||||
payload = response.get_json()
|
||||
token = payload['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_user(db):
|
||||
"""Create an authenticated user with NO roles/permissions.
|
||||
|
||||
Username 'testmember', password 'testpass'. Used to prove that
|
||||
authentication alone does not grant write access (authorization gating).
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
user = User(
|
||||
username='testmember',
|
||||
email='member@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_headers(client, member_user):
|
||||
"""Log in as member_user (no roles) and return Authorization headers."""
|
||||
response = client.post(
|
||||
'/api/auth/login',
|
||||
json={'username': 'testmember', 'password': 'testpass'},
|
||||
)
|
||||
assert response.status_code == 200, f'Login failed: {response.get_json()}'
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
270
tests/test_api_namespace.py
Normal file
270
tests/test_api_namespace.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Tests for the public shopdb.api namespace exposed to plugins.
|
||||
|
||||
Pins audit_log, resolve_asset_position, and the BasePlugin
|
||||
get_setting/set_setting helpers as part of the contract surface.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.api import audit_log, resolve_asset_position
|
||||
from shopdb.core.models import AuditLog, Setting
|
||||
|
||||
|
||||
def test_audit_log_creates_row(db, client):
|
||||
"""audit_log writes an AuditLog row with the standard fields."""
|
||||
with client.application.test_request_context('/'):
|
||||
entry = audit_log(
|
||||
action='created',
|
||||
entitytype='Computer',
|
||||
entityid=42,
|
||||
entityname='PC-1234',
|
||||
changes={'before': {}, 'after': {'hostname': 'PC-1234'}},
|
||||
)
|
||||
|
||||
assert entry is not None
|
||||
assert entry.action == 'created'
|
||||
assert entry.entitytype == 'Computer'
|
||||
assert entry.entityid == 42
|
||||
assert entry.entityname == 'PC-1234'
|
||||
|
||||
saved = AuditLog.query.filter_by(entityid=42, entitytype='Computer').first()
|
||||
assert saved is not None
|
||||
|
||||
|
||||
def test_resolve_asset_position_returns_none_when_no_data():
|
||||
"""An asset with no coords and no location returns None."""
|
||||
class FakeAsset:
|
||||
mapx = None
|
||||
mapy = None
|
||||
location = None
|
||||
|
||||
assert resolve_asset_position(FakeAsset()) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_uses_self_when_set():
|
||||
"""Asset-specific coords win over everything else."""
|
||||
class FakeLocation:
|
||||
mapx = 10
|
||||
mapy = 20
|
||||
|
||||
class FakeAsset:
|
||||
mapx = 100
|
||||
mapy = 200
|
||||
location = FakeLocation()
|
||||
|
||||
result = resolve_asset_position(FakeAsset())
|
||||
assert result == {'mapx': 100, 'mapy': 200, 'positionsource': 'self'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_falls_back_to_location():
|
||||
"""When asset has no coords, falls back to location coords."""
|
||||
class FakeLocation:
|
||||
mapx = 50
|
||||
mapy = 75
|
||||
|
||||
class FakeAsset:
|
||||
mapx = None
|
||||
mapy = None
|
||||
location = FakeLocation()
|
||||
|
||||
result = resolve_asset_position(FakeAsset())
|
||||
assert result == {'mapx': 50, 'mapy': 75, 'positionsource': 'location'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_handles_asset_without_mapx_attr():
|
||||
"""Assets that don't yet have mapx/mapy columns degrade gracefully."""
|
||||
class FakeLocation:
|
||||
mapx = 1
|
||||
mapy = 2
|
||||
|
||||
class FakeAsset:
|
||||
location = FakeLocation()
|
||||
|
||||
result = resolve_asset_position(FakeAsset())
|
||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'}
|
||||
|
||||
|
||||
# --- Relationship-walk path (priority 2 in the chain) ----------------------
|
||||
|
||||
def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
|
||||
"""Build a fake AssetRelationship-shaped object pointing at neighbor.
|
||||
`neighbor` is wired as both source and target so the walk helper finds
|
||||
it regardless of direction; tests pick which list to attach it to."""
|
||||
class FakeRelType:
|
||||
relationshiptype = rtype_name
|
||||
|
||||
class FakeRel:
|
||||
pass
|
||||
|
||||
r = FakeRel()
|
||||
r.relationshiptype = FakeRelType()
|
||||
r.inheritsposition = inheritsposition
|
||||
r.isactive = isactive
|
||||
r.targetasset = neighbor
|
||||
r.sourceasset = neighbor
|
||||
return r
|
||||
|
||||
|
||||
def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None):
|
||||
class FakeAsset:
|
||||
pass
|
||||
|
||||
a = FakeAsset()
|
||||
a.assetid = assetid
|
||||
a.mapx = mapx
|
||||
a.mapy = mapy
|
||||
a.outgoing_relationships = outgoing or []
|
||||
a.incoming_relationships = incoming or []
|
||||
a.location = location
|
||||
return a
|
||||
|
||||
|
||||
def test_resolve_asset_position_walks_partof_edge():
|
||||
"""Priority 2: inheritsposition=true partof edge resolves from neighbor."""
|
||||
parent = _make_asset(assetid=2, mapx=300, mapy=400)
|
||||
rel = _make_rel('partof', parent)
|
||||
child = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
result = resolve_asset_position(child)
|
||||
assert result == {'mapx': 300, 'mapy': 400, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_walks_controls_after_partof():
|
||||
"""Priority 2 ordering: partof beats controls when both have coords."""
|
||||
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12)
|
||||
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99)
|
||||
rel_partof = _make_rel('partof', partof_neighbor)
|
||||
rel_controls = _make_rel('controls', controls_neighbor)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
|
||||
|
||||
result = resolve_asset_position(asset)
|
||||
assert result == {'mapx': 11, 'mapy': 12, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_skips_non_inheritable_type():
|
||||
"""connectedto edges are never walked even if inheritsposition is true."""
|
||||
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
||||
rel = _make_rel('connectedto', neighbor, inheritsposition=True)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
assert resolve_asset_position(asset) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_skips_when_inheritsposition_false():
|
||||
"""An edge with inheritsposition=false is not walked."""
|
||||
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
||||
rel = _make_rel('partof', neighbor, inheritsposition=False)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
assert resolve_asset_position(asset) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_walks_recursively():
|
||||
"""The walk recurses: child -> middle -> root, where only root has coords."""
|
||||
root = _make_asset(assetid=3, mapx=1, mapy=2)
|
||||
middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)])
|
||||
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
|
||||
|
||||
result = resolve_asset_position(child)
|
||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_breaks_cycles():
|
||||
"""A cycle A<->B with no coords anywhere returns None without recursing
|
||||
forever."""
|
||||
a = _make_asset(assetid=1)
|
||||
b = _make_asset(assetid=2)
|
||||
a.outgoing_relationships = [_make_rel('partof', b)]
|
||||
b.outgoing_relationships = [_make_rel('partof', a)]
|
||||
|
||||
assert resolve_asset_position(a) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_depth_cap_is_three():
|
||||
"""Past depth 3 the walk gives up. Build a chain of 5 nodes where only
|
||||
the last has coords; expect None."""
|
||||
coords_node = _make_asset(assetid=5, mapx=99, mapy=99)
|
||||
n4 = _make_asset(assetid=4, outgoing=[_make_rel('partof', coords_node)])
|
||||
n3 = _make_asset(assetid=3, outgoing=[_make_rel('partof', n4)])
|
||||
n2 = _make_asset(assetid=2, outgoing=[_make_rel('partof', n3)])
|
||||
root = _make_asset(assetid=1, outgoing=[_make_rel('partof', n2)])
|
||||
|
||||
assert resolve_asset_position(root) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_self_beats_related():
|
||||
"""Priority 1 beats priority 2: asset's own coords win even when a
|
||||
related neighbor would also resolve."""
|
||||
neighbor = _make_asset(assetid=2, mapx=99, mapy=99)
|
||||
rel = _make_rel('partof', neighbor)
|
||||
asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel])
|
||||
|
||||
result = resolve_asset_position(asset)
|
||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_related_beats_location():
|
||||
"""Priority 2 beats priority 3: a related neighbor's coords win over
|
||||
the asset's location coords."""
|
||||
class FakeLocation:
|
||||
mapx = 500
|
||||
mapy = 600
|
||||
|
||||
neighbor = _make_asset(assetid=2, mapx=10, mapy=20)
|
||||
rel = _make_rel('partof', neighbor)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation())
|
||||
|
||||
result = resolve_asset_position(asset)
|
||||
assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_inactive_edge_skipped():
|
||||
"""Soft-deleted (isactive=false) relationships are not walked."""
|
||||
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
||||
rel = _make_rel('partof', neighbor, isactive=False)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
assert resolve_asset_position(asset) is None
|
||||
|
||||
|
||||
def test_plugin_get_setting_returns_default_when_unset(db, app):
|
||||
"""A plugin reading an unset setting gets the default."""
|
||||
from shopdb.plugins import plugin_manager
|
||||
with app.app_context():
|
||||
printers = plugin_manager.get_plugin('printers')
|
||||
if printers is None:
|
||||
pytest.skip('printers plugin not loaded')
|
||||
assert printers.get_setting('nonexistentkey', default='fallback') == 'fallback'
|
||||
|
||||
|
||||
def test_plugin_set_and_get_setting_roundtrip(db, app):
|
||||
"""A plugin can set and read its own setting; key is namespaced."""
|
||||
from shopdb.plugins import plugin_manager
|
||||
with app.app_context():
|
||||
printers = plugin_manager.get_plugin('printers')
|
||||
if printers is None:
|
||||
pytest.skip('printers plugin not loaded')
|
||||
|
||||
printers.set_setting('zabbix_url', 'http://zabbix.example.com')
|
||||
|
||||
assert printers.get_setting('zabbix_url') == 'http://zabbix.example.com'
|
||||
|
||||
raw = Setting.query.filter_by(key='plugin.printers.zabbix_url').first()
|
||||
assert raw is not None
|
||||
assert raw.value == 'http://zabbix.example.com'
|
||||
|
||||
|
||||
def test_plugin_setting_is_namespaced_per_plugin(db, app):
|
||||
"""Two plugins using the same key do not collide."""
|
||||
from shopdb.plugins import plugin_manager
|
||||
with app.app_context():
|
||||
printers = plugin_manager.get_plugin('printers')
|
||||
computers = plugin_manager.get_plugin('computers')
|
||||
if printers is None or computers is None:
|
||||
pytest.skip('required plugins not loaded')
|
||||
|
||||
printers.set_setting('shared_key', 'printers_value')
|
||||
computers.set_setting('shared_key', 'computers_value')
|
||||
|
||||
assert printers.get_setting('shared_key') == 'printers_value'
|
||||
assert computers.get_setting('shared_key') == 'computers_value'
|
||||
0
tests/test_core/__init__.py
Normal file
0
tests/test_core/__init__.py
Normal file
419
tests/test_core/test_apitokens.py
Normal file
419
tests/test_core/test_apitokens.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""Personal API token tests.
|
||||
|
||||
Covers: create returns the secret once and stores only a hash; a PAT
|
||||
authenticates a permission-gated write as its owner; a PAT is rejected when its
|
||||
owner lacks the permission; expired and revoked tokens are rejected; lastusedat
|
||||
updates on use; a non-owner member cannot revoke someone else's token; an admin
|
||||
lists everyone's tokens with ?all=true; and import mode works over a PAT for an
|
||||
admin.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import ApiToken, Application, Vendor
|
||||
from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _create_token(client, headers, name='test token', expiresat=None, scopes=None):
|
||||
body = {'name': name}
|
||||
if expiresat is not None:
|
||||
body['expiresat'] = expiresat
|
||||
if scopes is not None:
|
||||
body['scopes'] = scopes
|
||||
response = client.post('/api/apitokens', json=body, headers=headers)
|
||||
return response
|
||||
|
||||
|
||||
def _pat_headers(secret):
|
||||
return {'Authorization': f'Bearer {secret}'}
|
||||
|
||||
|
||||
def _user_with_perms(client, db, username, permnames):
|
||||
"""Create a non-admin user holding permnames, return (user, login headers).
|
||||
|
||||
Seeds the catalog so the named Permission rows exist, links them to a fresh
|
||||
role, and logs the user in. Used to prove the apitokens.create gate and the
|
||||
owner scope ceiling for non-admin owners.
|
||||
"""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
Permission.seed()
|
||||
role = Role(rolename=username + 'role')
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
role.permissions = Permission.query.filter(
|
||||
Permission.name.in_(permnames)).all()
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
headers = {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
return user, headers
|
||||
|
||||
|
||||
def test_create_returns_secret_once_and_stores_hash(client, db, auth_headers):
|
||||
response = _create_token(client, auth_headers, name='import runner')
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()['data']
|
||||
|
||||
secret = data['secret']
|
||||
assert secret.startswith('shopdb_pat_')
|
||||
assert 'warning' in data
|
||||
# The stored row must not carry the raw secret; only its hash.
|
||||
token = ApiToken.query.filter_by(tokenid=data['tokenid']).first()
|
||||
assert token is not None
|
||||
assert token.tokenhash == ApiToken.hash_secret(secret)
|
||||
assert secret not in (token.tokenhash, token.tokenprefix or '')
|
||||
assert token.tokenprefix and token.tokenprefix in secret
|
||||
|
||||
|
||||
def test_pat_authenticates_permission_write_as_owner(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""A PAT owned by an admin can create a vendor (admin-gated write)."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'PAT Vendor'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 201
|
||||
assert Vendor.query.filter_by(vendor='PAT Vendor').first() is not None
|
||||
|
||||
|
||||
def test_pat_403_when_owner_lacks_permission(client, db):
|
||||
"""A PAT owned by a non-admin (who can mint tokens but is not admin) is
|
||||
forbidden from an admin-role write."""
|
||||
_user, headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
secret = _create_token(client, headers).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Nope'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
assert Vendor.query.filter_by(vendor='Nope').first() is None
|
||||
|
||||
|
||||
def test_expired_token_rejected(client, db, admin_user, auth_headers):
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
token.expiresat = _naive_utcnow() - timedelta(days=1)
|
||||
_db.session.commit()
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Expired'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 401
|
||||
assert Vendor.query.filter_by(vendor='Expired').first() is None
|
||||
|
||||
|
||||
def test_revoked_token_rejected(client, db, admin_user, auth_headers):
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
tokenid = token.tokenid
|
||||
|
||||
revoke = client.delete(f'/api/apitokens/{tokenid}', headers=auth_headers)
|
||||
assert revoke.status_code == 200
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Revoked'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 401
|
||||
assert Vendor.query.filter_by(vendor='Revoked').first() is None
|
||||
|
||||
|
||||
def test_lastusedat_updates_on_use(client, db, admin_user, auth_headers):
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.lastusedat is None
|
||||
|
||||
client.get('/api/apitokens', headers=_pat_headers(secret))
|
||||
_db.session.expire_all()
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.lastusedat is not None
|
||||
|
||||
|
||||
def test_member_cannot_revoke_other_users_token(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""A token-capable non-admin cannot revoke a token owned by someone else."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
tokenid = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first().tokenid
|
||||
|
||||
_user, headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
response = client.delete(f'/api/apitokens/{tokenid}', headers=headers)
|
||||
assert response.status_code == 403
|
||||
# Still active.
|
||||
assert _db.session.get(ApiToken, tokenid).isactive is True
|
||||
|
||||
|
||||
def test_roleless_member_cannot_create_token(client, db, member_user,
|
||||
member_headers):
|
||||
"""Creating a token now requires apitokens.create; a role-less member is
|
||||
denied with 403."""
|
||||
response = _create_token(client, member_headers, name='nope')
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_member_with_permission_can_manage_own_token(client, db):
|
||||
"""A non-admin granted apitokens.create manages their OWN tokens."""
|
||||
_user, headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
create = _create_token(client, headers, name='mine')
|
||||
assert create.status_code == 201
|
||||
tokenid = create.get_json()['data']['tokenid']
|
||||
|
||||
revoke = client.delete(f'/api/apitokens/{tokenid}', headers=headers)
|
||||
assert revoke.status_code == 200
|
||||
assert _db.session.get(ApiToken, tokenid).isactive is False
|
||||
|
||||
|
||||
def test_admin_all_true_lists_everyone(client, db, admin_user, auth_headers):
|
||||
_create_token(client, auth_headers, name='admin token')
|
||||
member, member_headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
_create_token(client, member_headers, name='member token')
|
||||
|
||||
# Own-only (default) for admin: just the admin's token.
|
||||
own = client.get('/api/apitokens', headers=auth_headers).get_json()['data']
|
||||
assert all(t['userid'] == admin_user.userid for t in own)
|
||||
|
||||
# all=true: both users' tokens, with owner usernames.
|
||||
everyone = client.get('/api/apitokens?all=true',
|
||||
headers=auth_headers).get_json()['data']
|
||||
userids = {t['userid'] for t in everyone}
|
||||
assert admin_user.userid in userids and member.userid in userids
|
||||
assert any(t.get('username') for t in everyone)
|
||||
|
||||
|
||||
def test_member_all_true_ignored(client, db, admin_user, auth_headers):
|
||||
"""A non-admin passing ?all=true still only sees their own tokens."""
|
||||
_create_token(client, auth_headers, name='admin token')
|
||||
member, member_headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
_create_token(client, member_headers, name='member token')
|
||||
|
||||
result = client.get('/api/apitokens?all=true',
|
||||
headers=member_headers).get_json()['data']
|
||||
assert all(t['userid'] == member.userid for t in result)
|
||||
|
||||
|
||||
def test_import_mode_works_over_pat(client, db, admin_user, auth_headers):
|
||||
"""An admin PAT plus X-Import-Mode backdates createddate on a write."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
|
||||
headers = _pat_headers(secret)
|
||||
headers['X-Import-Mode'] = 'true'
|
||||
response = client.post(
|
||||
'/api/vendors',
|
||||
json={'vendor': 'Legacy Vendor', 'createddate': '2019-01-02 03:04:05'},
|
||||
headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
vendor = Vendor.query.filter_by(vendor='Legacy Vendor').first()
|
||||
assert vendor is not None
|
||||
assert vendor.createddate == datetime(2019, 1, 2, 3, 4, 5)
|
||||
|
||||
|
||||
# --- Scoped tokens -------------------------------------------------------
|
||||
|
||||
def test_scoped_token_passes_listed_permission(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""A token scoped to applications.create can create an application."""
|
||||
secret = _create_token(client, auth_headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/applications', json={'appname': 'Scoped App'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 201
|
||||
assert Application.query.filter_by(appname='Scoped App').first() is not None
|
||||
|
||||
|
||||
def test_scoped_token_403_on_unlisted_permission(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""A token scoped to only applications.create cannot delete (needs
|
||||
applications.delete, which is not in scope)."""
|
||||
app = Application(appname='Target App')
|
||||
_db.session.add(app)
|
||||
_db.session.commit()
|
||||
appid = app.appid
|
||||
|
||||
secret = _create_token(client, auth_headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
response = client.delete(f'/api/applications/{appid}',
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
assert _db.session.get(Application, appid) is not None
|
||||
|
||||
|
||||
def test_scoped_admin_token_does_not_bypass(client, db, admin_user, auth_headers):
|
||||
"""An admin owner's scoped token is genuinely limited: the admin permission
|
||||
bypass is suspended, so an unlisted action is 403 even for an admin."""
|
||||
secret = _create_token(client, auth_headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
|
||||
# settings.edit is not scoped -> 403 despite the owner being admin.
|
||||
response = client.put('/api/settings/site_name',
|
||||
json={'value': 'Hacked'}, headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_scoped_token_denied_on_require_role_route(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""Scoped tokens cannot pass a role gate. Vendor writes are require_role
|
||||
admin, so even an admin-owned scoped token is denied."""
|
||||
secret = _create_token(client, auth_headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'RoleGated'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
assert Vendor.query.filter_by(vendor='RoleGated').first() is None
|
||||
|
||||
|
||||
def test_unscoped_token_unchanged(client, db, admin_user, auth_headers):
|
||||
"""Regression: an unscoped admin token still acts with full authority,
|
||||
including role-gated writes."""
|
||||
secret = _create_token(client, auth_headers).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/vendors', json={'vendor': 'Unscoped Vendor'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 201
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.scopelist is None
|
||||
|
||||
|
||||
def test_unknown_scope_name_rejected_at_create(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""An unknown permission name in scopes is rejected 400, listing it."""
|
||||
response = _create_token(client, auth_headers, scopes=['not.a.permission'])
|
||||
assert response.status_code == 400
|
||||
assert 'not.a.permission' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_scope_ceiling_rejects_permission_owner_lacks(client, db):
|
||||
"""The scope ceiling: a non-admin cannot mint a token scoped to a
|
||||
permission they do not hold."""
|
||||
_user, headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
response = _create_token(client, headers, scopes=['applications.create'])
|
||||
assert response.status_code == 400
|
||||
assert 'applications.create' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_non_admin_scoped_token_capped_at_own_permissions(client, db):
|
||||
"""A non-admin holding applications.create + apitokens.create may mint a
|
||||
token scoped to applications.create and use it."""
|
||||
_user, headers = _user_with_perms(
|
||||
client, db, 'tokenmaker', ['apitokens.create', 'applications.create'])
|
||||
secret = _create_token(client, headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/applications', json={'appname': 'Capped App'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
def test_scoped_token_intersects_at_use_time(client, db):
|
||||
"""Defense in depth: if the owner loses a scoped permission after the token
|
||||
is minted, the token can no longer use it (use-time intersection)."""
|
||||
from shopdb.core.models import Role
|
||||
_user, headers = _user_with_perms(
|
||||
client, db, 'tokenmaker', ['apitokens.create', 'applications.create'])
|
||||
secret = _create_token(client, headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
|
||||
# Strip applications.create from the owner's role.
|
||||
role = Role.query.filter_by(rolename='tokenmakerrole').first()
|
||||
role.permissions = [p for p in role.permissions
|
||||
if p.name != 'applications.create']
|
||||
_db.session.commit()
|
||||
|
||||
response = client.post('/api/applications', json={'appname': 'Gone'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
assert Application.query.filter_by(appname='Gone').first() is None
|
||||
|
||||
|
||||
def test_import_mode_denied_over_scoped_admin_token(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""A scoped admin token does NOT get import mode (an admin-role capability):
|
||||
the createddate in the body is ignored and 'now' is stamped instead."""
|
||||
secret = _create_token(
|
||||
client, auth_headers,
|
||||
scopes=['applications.create']).get_json()['data']['secret']
|
||||
|
||||
headers = _pat_headers(secret)
|
||||
headers['X-Import-Mode'] = 'true'
|
||||
before = _naive_utcnow()
|
||||
response = client.post(
|
||||
'/api/applications',
|
||||
json={'appname': 'No Backdate', 'createddate': '2019-01-02 03:04:05'},
|
||||
headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
app = Application.query.filter_by(appname='No Backdate').first()
|
||||
assert app is not None
|
||||
# Import mode was denied, so createddate is ~now, not the 2019 value.
|
||||
assert app.createddate >= before
|
||||
|
||||
|
||||
def test_scopes_update_round_trip(client, db, admin_user, auth_headers):
|
||||
"""Scopes can be set, changed, and cleared via PUT, and surface in GET."""
|
||||
create = _create_token(client, auth_headers, scopes=['applications.create'])
|
||||
tokenid = create.get_json()['data']['tokenid']
|
||||
|
||||
got = client.get('/api/apitokens', headers=auth_headers).get_json()['data']
|
||||
row = next(t for t in got if t['tokenid'] == tokenid)
|
||||
assert row['scopes'] == ['applications.create']
|
||||
|
||||
# Change scopes.
|
||||
updated = client.put(f'/api/apitokens/{tokenid}',
|
||||
json={'scopes': ['applications.edit']},
|
||||
headers=auth_headers)
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['scopes'] == ['applications.edit']
|
||||
|
||||
# Clear scopes (back to unscoped/full).
|
||||
cleared = client.put(f'/api/apitokens/{tokenid}', json={'scopes': None},
|
||||
headers=auth_headers)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.get_json()['data']['scopes'] is None
|
||||
|
||||
|
||||
def test_collector_scoped_token_denied_on_normal_endpoints(client, db,
|
||||
admin_user,
|
||||
auth_headers):
|
||||
"""A collector-scoped token (collector.ingest only) is contained: the shim
|
||||
still swaps it into a JWT, but collector.ingest gates no normal route, so it
|
||||
is denied on a permission-gated create just like any other unlisted scope."""
|
||||
secret = _create_token(
|
||||
client, auth_headers,
|
||||
scopes=['collector.ingest']).get_json()['data']['secret']
|
||||
|
||||
response = client.post('/api/applications', json={'appname': 'Nope'},
|
||||
headers=_pat_headers(secret))
|
||||
assert response.status_code == 403
|
||||
assert Application.query.filter_by(appname='Nope').first() is None
|
||||
|
||||
|
||||
def test_update_scopes_ceiling_enforced(client, db):
|
||||
"""PUT scopes is also capped at the owner's permissions."""
|
||||
_user, headers = _user_with_perms(client, db, 'tokenmaker',
|
||||
['apitokens.create'])
|
||||
tokenid = _create_token(client, headers).get_json()['data']['tokenid']
|
||||
|
||||
response = client.put(f'/api/apitokens/{tokenid}',
|
||||
json={'scopes': ['applications.delete']},
|
||||
headers=headers)
|
||||
assert response.status_code == 400
|
||||
assert 'applications.delete' in response.get_json()['data']['error']['message']
|
||||
70
tests/test_core/test_auth_ratelimit.py
Normal file
70
tests/test_core/test_auth_ratelimit.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for IP-based login rate limiting.
|
||||
|
||||
The limiter is disabled in TestingConfig so login-heavy fixtures do not trip
|
||||
it. These tests flip it on via app.config overrides and clear the shared cache
|
||||
so counters do not bleed between cases.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.extensions import cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ratelimit_on(app):
|
||||
"""Enable a tight login rate limit for the duration of one test."""
|
||||
saved = {
|
||||
'enabled': app.config.get('AUTH_RATELIMIT_ENABLED'),
|
||||
'max': app.config.get('AUTH_RATELIMIT_MAX'),
|
||||
'window': app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS'),
|
||||
}
|
||||
app.config['AUTH_RATELIMIT_ENABLED'] = True
|
||||
app.config['AUTH_RATELIMIT_MAX'] = 3
|
||||
app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = 300
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
yield
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
app.config['AUTH_RATELIMIT_ENABLED'] = saved['enabled']
|
||||
app.config['AUTH_RATELIMIT_MAX'] = saved['max']
|
||||
app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = saved['window']
|
||||
|
||||
|
||||
def _bad_login(client, ip):
|
||||
"""Attempt a login for a non-existent user from a given source IP."""
|
||||
return client.post(
|
||||
'/api/auth/login',
|
||||
json={'username': 'ghost', 'password': 'wrong'},
|
||||
headers={'X-Forwarded-For': ip},
|
||||
)
|
||||
|
||||
|
||||
def test_login_flood_from_one_ip_is_rate_limited(client, db, ratelimit_on):
|
||||
"""After the per-window budget is spent, further attempts get 429."""
|
||||
ip = '203.0.113.10'
|
||||
for _ in range(3):
|
||||
assert _bad_login(client, ip).status_code == 401
|
||||
# Fourth attempt in the same window is over budget.
|
||||
blocked = _bad_login(client, ip)
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.get_json()['data']['error']['code'] == 'RATE_LIMITED'
|
||||
|
||||
|
||||
def test_different_ip_not_penalized(client, db, ratelimit_on):
|
||||
"""One flooding IP does not lock out a different caller's IP."""
|
||||
flooder = '203.0.113.20'
|
||||
for _ in range(4):
|
||||
_bad_login(client, flooder)
|
||||
assert _bad_login(client, flooder).status_code == 429
|
||||
|
||||
# A separate IP still gets the normal 401, not a 429.
|
||||
other = _bad_login(client, '203.0.113.21')
|
||||
assert other.status_code == 401
|
||||
|
||||
|
||||
def test_limiter_disabled_by_default_in_testing(client, db):
|
||||
"""With TestingConfig defaults the limiter is off; repeated logins stay 401."""
|
||||
for _ in range(10):
|
||||
resp = _bad_login(client, '203.0.113.30')
|
||||
assert resp.status_code == 401
|
||||
342
tests/test_core/test_authz.py
Normal file
342
tests/test_core/test_authz.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""Authorization gating and login-lockout tests.
|
||||
|
||||
Covers the security fixes:
|
||||
- write routes require a permission/role, not just authentication
|
||||
- the admin role bypasses permission checks
|
||||
- repeated bad logins lock the account
|
||||
|
||||
The parametrized sweep at the bottom is the standing CI guard: it walks the
|
||||
whole URL map and proves every mutating route (POST/PUT/PATCH/DELETE) either
|
||||
rejects a role-less member with 403 or is on the deliberate exemption list.
|
||||
A future unguarded mutation therefore fails CI the moment it is added.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb import create_app
|
||||
|
||||
|
||||
# HTTP verbs that change state. GETs are intentionally left alone (kiosk pages
|
||||
# rely on optional-jwt reads).
|
||||
MUTATING_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}
|
||||
|
||||
# Blueprints exempt by design (see task/ADR-006):
|
||||
# auth - login/refresh/logout are the auth surface itself
|
||||
# collector - agent ingest, authenticated by per-plugin API key (ADR-006)
|
||||
# setup - first-run wizard, self-gated by the needs-admin state
|
||||
EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
|
||||
|
||||
# Individual endpoints exempt by design.
|
||||
# knowledgebase.track_click - kiosk click-through counter. Optional-jwt
|
||||
# telemetry: increments a click count on an already-active article and
|
||||
# returns its link URL. Takes no caller-supplied data; gating it would
|
||||
# break the unauthenticated kiosk redirect it exists to serve.
|
||||
# users.update_user - admin-or-self (inline check, not a flat deny): a
|
||||
# role-less member MAY edit their own record, so it does not fit the
|
||||
# 403-for-every-member contract this sweep asserts. The other-user 403 is
|
||||
# covered by test_member_cannot_update_other_user below.
|
||||
# The apitokens create/update/revoke endpoints are NOT exempt: they now require
|
||||
# the apitokens.create permission, so a role-less member gets the 403 this sweep
|
||||
# asserts (ownership is still enforced inside the handler for non-admins).
|
||||
# geenforce.post_report - GE-Enforce client ingest, authenticated by a
|
||||
# geenforce.report managed service token (X-API-Key/Bearer), not JWT. Same
|
||||
# shape as the exempt collector blueprint; the geenforce admin endpoints in
|
||||
# the same blueprint are JWT+permission gated and ARE swept.
|
||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
'geenforce.post_report'}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _selfhosted_directory_modes(db):
|
||||
# The usb/employees plugins default to EXTERNAL directory mode when no
|
||||
# setting row exists, so exercising their handlers reaches for a MySQL
|
||||
# server that exists on the dev box but not in CI. Pin selfhosted mode
|
||||
# so the sweep is deterministic everywhere.
|
||||
from shopdb.core.models import Setting
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='employee_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _fill_url(rule):
|
||||
"""Turn a Werkzeug rule into a concrete path (int/float params -> 1, else x)."""
|
||||
def repl(match):
|
||||
converter = match.group(1) or ''
|
||||
return '1' if converter.startswith(('int', 'float')) else 'x'
|
||||
# matches <name>, <int:name>, <path:name>, ...
|
||||
return re.sub(r'<(?:([^:>]+):)?[^>]+>', repl, rule)
|
||||
|
||||
|
||||
def _mutating_routes():
|
||||
"""Every non-exempt mutating (endpoint, method, url) in the URL map.
|
||||
|
||||
Enumerated from a throwaway app: route strings are stable across app
|
||||
instances, so the requests below run against the fixture client's app.
|
||||
"""
|
||||
app = create_app('testing')
|
||||
routes = []
|
||||
for rule in app.url_map.iter_rules():
|
||||
methods = (rule.methods or set()) & MUTATING_METHODS
|
||||
if not methods:
|
||||
continue
|
||||
blueprint = rule.endpoint.split('.')[0]
|
||||
if blueprint in EXEMPT_BLUEPRINTS or rule.endpoint in EXEMPT_ENDPOINTS:
|
||||
continue
|
||||
for method in sorted(methods):
|
||||
routes.append((rule.endpoint, method, _fill_url(rule.rule)))
|
||||
return routes
|
||||
|
||||
|
||||
# Collected once at import so each route is its own parametrized case (a
|
||||
# newly-unguarded mutation shows up as a single named CI failure).
|
||||
MUTATING_ROUTES = _mutating_routes()
|
||||
ROUTE_IDS = [f'{ep}[{method}]' for ep, method, _ in MUTATING_ROUTES]
|
||||
|
||||
|
||||
def test_member_cannot_write_settings(client, db, member_headers):
|
||||
"""An authenticated user with no roles is forbidden from editing settings."""
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean',
|
||||
category='integrations')
|
||||
db.session.add(setting)
|
||||
db.session.commit()
|
||||
|
||||
response = client.put('/api/settings/zabbix_enabled',
|
||||
json={'value': True}, headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_admin_can_write_settings(client, db, auth_headers):
|
||||
"""The admin role bypasses the permission check and may edit settings."""
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
setting = Setting(key='zabbix_enabled', value='false', valuetype='boolean',
|
||||
category='integrations')
|
||||
db.session.add(setting)
|
||||
db.session.commit()
|
||||
|
||||
response = client.put('/api/settings/zabbix_enabled',
|
||||
json={'value': True}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_member_cannot_delete_asset(client, db, member_headers):
|
||||
"""A role-less user cannot delete assets (was previously allowed)."""
|
||||
response = client.delete('/api/assets/1', headers=member_headers)
|
||||
# Forbidden by authz, not a 404 - the gate runs before the lookup.
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_unauthenticated_write_is_rejected(client, db):
|
||||
"""No token at all still cannot reach a write route."""
|
||||
response = client.delete('/api/assets/1')
|
||||
assert response.status_code in (401, 422) # missing/!invalid JWT
|
||||
|
||||
|
||||
def test_member_cannot_create_business_unit(client, db, member_headers):
|
||||
"""Reference-data writes are admin-only."""
|
||||
response = client.post('/api/businessunits',
|
||||
json={'businessunit': 'Test BU'}, headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_member_cannot_list_users(client, db, member_headers):
|
||||
"""User listing is admin-only (converted from inline check to decorator)."""
|
||||
response = client.get('/api/users', headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_member_cannot_create_user(client, db, member_headers):
|
||||
"""User creation is admin-only."""
|
||||
response = client.post('/api/users',
|
||||
json={'username': 'x', 'email': 'x@test.local',
|
||||
'password': 'secret'},
|
||||
headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_member_cannot_delete_role(client, db, member_headers):
|
||||
"""Role deletion is admin-only."""
|
||||
response = client.delete('/api/users/roles/1', headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_list_users(client, db, auth_headers):
|
||||
"""The admin role passes the require_role gate on user listing."""
|
||||
response = client.get('/api/users', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_member_can_read_own_user(client, db, member_user, member_headers):
|
||||
"""Admin-or-self: a role-less user may read their OWN record."""
|
||||
response = client.get(f'/api/users/{member_user.userid}',
|
||||
headers=member_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['username'] == 'testmember'
|
||||
|
||||
|
||||
def test_member_cannot_read_other_user(client, db, member_user, member_headers):
|
||||
"""Admin-or-self: a role-less user may not read a DIFFERENT record."""
|
||||
response = client.get(f'/api/users/{member_user.userid + 999}',
|
||||
headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_account_locks_after_repeated_bad_logins(client, db, admin_user):
|
||||
"""Five bad passwords lock the account; a correct password is then refused."""
|
||||
for _ in range(5):
|
||||
bad = client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'wrong'})
|
||||
assert bad.status_code == 401
|
||||
|
||||
# Account is now locked - even the correct password is refused with 403.
|
||||
locked = client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'})
|
||||
assert locked.status_code == 403
|
||||
assert 'locked' in locked.get_json()['data']['error']['message'].lower()
|
||||
|
||||
|
||||
def test_lockout_auto_unlocks_after_expiry(client, db, admin_user):
|
||||
"""Once lockeduntil is in the past the account unlocks itself: a correct
|
||||
password logs in and clears the lock state."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
# Simulate an expired lock (naive-UTC to match the column comparisons).
|
||||
admin_user.lockeduntil = (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(minutes=1))
|
||||
admin_user.failedlogins = 5
|
||||
db.session.commit()
|
||||
|
||||
ok = client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'})
|
||||
assert ok.status_code == 200, ok.get_json()
|
||||
|
||||
db.session.refresh(admin_user)
|
||||
assert admin_user.lockeduntil is None
|
||||
assert admin_user.failedlogins == 0
|
||||
|
||||
|
||||
def test_successful_login_resets_failed_counter(client, db, admin_user):
|
||||
"""A good login before the threshold clears the failure count."""
|
||||
for _ in range(3):
|
||||
client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'wrong'})
|
||||
|
||||
ok = client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'})
|
||||
assert ok.status_code == 200
|
||||
|
||||
from shopdb.core.models import User
|
||||
user = User.query.filter_by(username='testadmin').first()
|
||||
assert user.failedlogins == 0
|
||||
assert user.lockeduntil is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standing CI guard: sweep every mutating route.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_member_cannot_update_other_user(client, db, member_user, member_headers):
|
||||
"""Admin-or-self write gate: a role-less user cannot edit a DIFFERENT user."""
|
||||
response = client.put(f'/api/users/{member_user.userid + 999}',
|
||||
json={'firstname': 'x'}, headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_route_map_has_mutating_routes():
|
||||
"""Sanity: the sweep actually found routes (catches a broken collector)."""
|
||||
assert len(MUTATING_ROUTES) > 50, 'route enumeration looks empty/broken'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS)
|
||||
def test_mutation_rejects_roleless_member(client, db, member_headers,
|
||||
endpoint, method, url):
|
||||
"""Every non-exempt mutating route must 403 a logged-in, role-less user.
|
||||
|
||||
A 200/201/400/404/409 here means the route ran its handler for an
|
||||
unprivileged caller - i.e. it is not permission-gated. That is the exact
|
||||
regression this guard exists to catch.
|
||||
"""
|
||||
response = client.open(url, method=method, headers=member_headers, json={})
|
||||
assert response.status_code == 403, (
|
||||
f'{method} {endpoint} ({url}) returned {response.status_code}, '
|
||||
f'expected 403 - route is not permission-gated. If it is intended to '
|
||||
f'be public/kiosk, add it to EXEMPT_ENDPOINTS with a reason.'
|
||||
)
|
||||
assert response.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('endpoint,method,url', MUTATING_ROUTES, ids=ROUTE_IDS)
|
||||
def test_mutation_admin_passes_authz(client, db, auth_headers,
|
||||
endpoint, method, url):
|
||||
"""Admin must clear the gate on every mutating route (never 401/403).
|
||||
|
||||
Downstream the handler may 400/404/409/422 on the empty body - that is
|
||||
validation, not authorization, and is fine. We only assert the authz gate
|
||||
let the admin through.
|
||||
"""
|
||||
response = client.open(url, method=method, headers=auth_headers, json={})
|
||||
assert response.status_code not in (401, 403), (
|
||||
f'{method} {endpoint} ({url}) returned {response.status_code} for '
|
||||
f'admin - the admin role should bypass every permission check.'
|
||||
)
|
||||
|
||||
|
||||
# --- Settings exposure allowlist (unauthenticated recon guard) ---------------
|
||||
|
||||
def _seed_settings(db):
|
||||
from shopdb.core.models import Setting
|
||||
db.session.add_all([
|
||||
Setting(key='facility_name', value='WJ', valuetype='string', category='site'),
|
||||
Setting(key='brand_primary_color', value='#123', valuetype='string', category='branding'),
|
||||
Setting(key='smtp_host', value='mail.internal', valuetype='string', category='communication'),
|
||||
Setting(key='employee_db_host', value='hr.internal', valuetype='string', category='integrations'),
|
||||
Setting(key='zabbix_token', value='supersecret', valuetype='string', category='integrations'),
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_unauthenticated_settings_list_is_allowlisted(client, db):
|
||||
"""No token: only the public allowlist (branding + bootstrap keys) is
|
||||
visible; internal infra config is not enumerable."""
|
||||
_seed_settings(db)
|
||||
keys = {s['key'] for s in client.get('/api/settings').get_json()['data']}
|
||||
assert 'facility_name' in keys and 'brand_primary_color' in keys
|
||||
assert 'smtp_host' not in keys
|
||||
assert 'employee_db_host' not in keys
|
||||
assert 'zabbix_token' not in keys
|
||||
|
||||
|
||||
def test_authenticated_settings_list_full_but_masked(client, db, member_headers):
|
||||
"""Any authed principal sees the full table; secret values stay masked."""
|
||||
_seed_settings(db)
|
||||
rows = {s['key']: s['value']
|
||||
for s in client.get('/api/settings', headers=member_headers).get_json()['data']}
|
||||
assert 'smtp_host' in rows and 'employee_db_host' in rows
|
||||
assert rows['zabbix_token'] == '********' # secret masked even when visible
|
||||
|
||||
|
||||
def test_unauthenticated_private_setting_get_is_404(client, db):
|
||||
"""A single private key is not confirmable without auth; a public one is."""
|
||||
_seed_settings(db)
|
||||
assert client.get('/api/settings/smtp_host').status_code == 404
|
||||
assert client.get('/api/settings/facility_name').status_code == 200
|
||||
|
||||
|
||||
# --- audit.view gating -------------------------------------------------------
|
||||
|
||||
def test_member_cannot_read_auditlogs(client, db, member_headers):
|
||||
"""Audit-read routes now require audit.view; a role-less member is 403."""
|
||||
for url in ('/api/auditlogs', '/api/auditlogs/entity/Setting/1', '/api/auditlogs/stats'):
|
||||
assert client.get(url, headers=member_headers).status_code == 403, url
|
||||
|
||||
|
||||
def test_admin_can_read_auditlogs(client, db, auth_headers):
|
||||
"""Admin holds audit.view and reads the audit trail."""
|
||||
assert client.get('/api/auditlogs', headers=auth_headers).status_code == 200
|
||||
434
tests/test_core/test_collector_contract.py
Normal file
434
tests/test_core/test_collector_contract.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""Tests for the generic plugin collector contract (ADR-006).
|
||||
|
||||
Covers the auto-dispatched /api/collector/<plugin> endpoint, per-plugin API
|
||||
key auth, idempotent upsert by identity field, and the JWT-protected
|
||||
/api/collector/_schemas listing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
"""Set the shared collector API key for the test."""
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def computer_assettype(db):
|
||||
"""Seed the computer asset type needed for create-on-ingest."""
|
||||
from shopdb.core.models import AssetType
|
||||
|
||||
at = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers', description='PCs')
|
||||
db.session.add(at)
|
||||
db.session.commit()
|
||||
return at
|
||||
|
||||
|
||||
def test_schemas_requires_jwt(client, db):
|
||||
"""The schema listing rejects unauthenticated callers."""
|
||||
response = client.get('/api/collector/_schemas')
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_schemas_lists_computers(client, db, auth_headers):
|
||||
"""Computers plugin exposes a collector schema keyed by hostname."""
|
||||
response = client.get('/api/collector/_schemas', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
schemas = response.get_json()['data']['schemas']
|
||||
assert 'computers' in schemas
|
||||
assert schemas['computers']['identityfield'] == 'hostname'
|
||||
|
||||
|
||||
def test_missing_key_rejected(client, db, collector_key, computer_assettype):
|
||||
"""No API key -> 401."""
|
||||
response = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC001'})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_unknown_plugin_404(client, db, collector_key):
|
||||
"""A plugin with no collector schema returns 404."""
|
||||
response = client.post('/api/collector/nosuchplugin',
|
||||
json={'hostname': 'x'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_missing_identity_rejected(client, db, collector_key, computer_assettype):
|
||||
"""Missing the identity field is a validation error."""
|
||||
response = client.post('/api/collector/computers',
|
||||
json={'currentuser': 'someone'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_create_then_idempotent_update(client, db, collector_key,
|
||||
computer_assettype):
|
||||
"""First post creates; second post with same hostname updates (no dup)."""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
payload = {'hostname': 'WJPC100', 'currentuser': 'alice',
|
||||
'serialnumber': 'SN-100'}
|
||||
|
||||
first = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert first.status_code == 200, first.get_json()
|
||||
data = first.get_json()['data']
|
||||
assert data['action'] == 'created'
|
||||
assert data['assetid'] is not None
|
||||
assert data['identityvalue'] == 'WJPC100'
|
||||
|
||||
payload['currentuser'] = 'bob'
|
||||
second = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert second.status_code == 200
|
||||
assert second.get_json()['data']['action'] == 'updated'
|
||||
|
||||
with client.application.app_context():
|
||||
comps = Computer.query.filter(Computer.hostname.ilike('WJPC100')).all()
|
||||
assert len(comps) == 1
|
||||
assert comps[0].loggedinuser == 'bob'
|
||||
|
||||
|
||||
def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype):
|
||||
"""COLLECTOR_API_KEY_<PLUGIN> takes precedence over the shared key."""
|
||||
app.config['COLLECTOR_API_KEY'] = 'sharedkey'
|
||||
app.config['COLLECTOR_API_KEY_COMPUTERS'] = 'computerskey'
|
||||
try:
|
||||
# Shared key now rejected for this plugin.
|
||||
rejected = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC200'},
|
||||
headers={'X-API-Key': 'sharedkey'})
|
||||
assert rejected.status_code == 401
|
||||
|
||||
accepted = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC200'},
|
||||
headers={'X-API-Key': 'computerskey'})
|
||||
assert accepted.status_code == 200
|
||||
finally:
|
||||
app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None)
|
||||
app.config['COLLECTOR_API_KEY'] = None
|
||||
|
||||
|
||||
def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key,
|
||||
computer_assettype):
|
||||
"""A GE-Enforce-shaped payload maps machinenumber/pctype/vendor/model/os."""
|
||||
from shopdb.core.models import OperatingSystem
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
|
||||
# Seed the controlled-vocab rows the collector looks up.
|
||||
db.session.add(ComputerType(computertype='Shopfloor PC'))
|
||||
db.session.add(OperatingSystem(osname='Windows 11'))
|
||||
db.session.commit()
|
||||
|
||||
payload = {
|
||||
'hostname': 'WJSF1234',
|
||||
'machinenumber': '0615',
|
||||
'pctype': 'gea-shopfloor-collections',
|
||||
'serialnumber': 'SN-ENROLL',
|
||||
'vendorname': 'Dell',
|
||||
'modelnumber': 'OptiPlex 7090',
|
||||
'osname': 'Windows 11',
|
||||
}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['action'] == 'created'
|
||||
|
||||
with client.application.app_context():
|
||||
comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first()
|
||||
assert comp is not None
|
||||
assert comp.asset.assetnumber == '0615' # machinenumber -> assetnumber
|
||||
assert comp.asset.serialnumber == 'SN-ENROLL'
|
||||
assert comp.computertype.computertype == 'Shopfloor PC' # pctype mapped
|
||||
assert comp.vendor.vendor == 'Dell' # created
|
||||
assert comp.model.modelnumber == 'OptiPlex 7090' # created
|
||||
assert comp.operatingsystem.osname == 'Windows 11'
|
||||
|
||||
|
||||
def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db,
|
||||
collector_key,
|
||||
computer_assettype):
|
||||
"""The 9999 imaging placeholder is ignored; assetnumber falls back to host."""
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJSF9999', 'machinenumber': '9999'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
from plugins.computers.models import Computer
|
||||
with client.application.app_context():
|
||||
comp = Computer.query.filter(Computer.hostname.ilike('WJSF9999')).first()
|
||||
assert comp.asset.assetnumber == 'WJSF9999'
|
||||
|
||||
|
||||
def test_internal_error_message_is_generic(client, app, db, collector_key):
|
||||
"""An unexpected upsert failure returns a generic 500, not the exception text."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
plugin = pm.get_all_plugins()['computers']
|
||||
original = plugin.apply_collector_payload
|
||||
|
||||
def boom(payload):
|
||||
raise RuntimeError('secret internal dsn leaked here')
|
||||
|
||||
plugin.apply_collector_payload = boom
|
||||
try:
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC500'},
|
||||
headers={'X-API-Key': KEY})
|
||||
finally:
|
||||
plugin.apply_collector_payload = original
|
||||
|
||||
assert resp.status_code == 500
|
||||
message = resp.get_json()['data']['error']['message']
|
||||
assert message == 'Internal error processing collector payload'
|
||||
assert 'secret internal dsn' not in message
|
||||
|
||||
|
||||
def test_generic_querystring_api_key_rejected(client, db, collector_key,
|
||||
computer_assettype):
|
||||
"""The dropped ?api_key= querystring fallback no longer authenticates."""
|
||||
resp = client.post('/api/collector/computers?api_key=' + KEY,
|
||||
json={'hostname': 'WJPCQS'})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_legacy_querystring_api_key_rejected(client, db, collector_key):
|
||||
"""Header-only auth on the legacy endpoints too: querystring key is rejected."""
|
||||
resp = client.get('/api/collector/status?api_key=' + KEY)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_legacy_header_api_key_accepted(client, db, collector_key):
|
||||
"""The X-API-Key header still authenticates the legacy endpoints."""
|
||||
resp = client.get('/api/collector/status', headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# --- Managed collector service tokens ------------------------------------
|
||||
|
||||
def _mint_collector_token(client, auth_headers, scopes):
|
||||
"""Mint a managed token with the given scopes as the logged-in admin."""
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'collector svc', 'scopes': scopes},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_collector_token_via_x_api_key(client, db, auth_headers,
|
||||
computer_assettype):
|
||||
"""A collector-scoped managed token authorizes ingest via X-API-Key, with
|
||||
no env key set."""
|
||||
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJTOK01'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['action'] == 'created'
|
||||
|
||||
|
||||
def test_collector_token_via_bearer(client, db, auth_headers,
|
||||
computer_assettype):
|
||||
"""The same token also works on the standard Bearer PAT transport."""
|
||||
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJTOK02'},
|
||||
headers={'Authorization': f'Bearer {secret}'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['action'] == 'created'
|
||||
|
||||
|
||||
def test_collector_token_stamps_lastusedat(client, db, auth_headers,
|
||||
computer_assettype):
|
||||
"""Using a collector token stamps lastusedat."""
|
||||
from shopdb.core.models import ApiToken
|
||||
|
||||
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.lastusedat is None
|
||||
|
||||
client.post('/api/collector/computers', json={'hostname': 'WJTOK03'},
|
||||
headers={'X-API-Key': secret})
|
||||
db.session.expire_all()
|
||||
token = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first()
|
||||
assert token.lastusedat is not None
|
||||
|
||||
|
||||
def test_unscoped_token_rejected_by_collector(client, db, auth_headers,
|
||||
collector_key, computer_assettype):
|
||||
"""An unscoped PAT is NOT a collector token: it lacks the collector.ingest
|
||||
scope, so the collector API rejects it (env-key semantics unchanged)."""
|
||||
secret = client.post('/api/apitokens', json={'name': 'full'},
|
||||
headers=auth_headers).get_json()['data']['secret']
|
||||
resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK04'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_scoped_token_rejected_by_collector(client, db, auth_headers,
|
||||
collector_key,
|
||||
computer_assettype):
|
||||
"""A PAT scoped to some OTHER permission is not a collector token."""
|
||||
secret = _mint_collector_token(client, auth_headers, ['applications.create'])
|
||||
resp = client.post('/api/collector/computers', json={'hostname': 'WJTOK05'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_revoked_collector_token_rejected(client, db, auth_headers,
|
||||
collector_key, computer_assettype):
|
||||
"""A revoked collector token is rejected on both transports."""
|
||||
from shopdb.core.models import ApiToken
|
||||
|
||||
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
|
||||
tokenid = ApiToken.query.filter_by(
|
||||
tokenhash=ApiToken.hash_secret(secret)).first().tokenid
|
||||
assert client.delete(f'/api/apitokens/{tokenid}',
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
viakey = client.post('/api/collector/computers', json={'hostname': 'WJTOK06'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert viakey.status_code == 401
|
||||
viabearer = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJTOK06'},
|
||||
headers={'Authorization': f'Bearer {secret}'})
|
||||
assert viabearer.status_code == 401
|
||||
|
||||
|
||||
def test_collector_token_contained_to_collector_api(client, db, admin_user,
|
||||
auth_headers):
|
||||
"""Containment: a collector-scoped token is denied on a permission-gated
|
||||
write (applications.create) AND a role-gated write (vendors, require_role)."""
|
||||
from shopdb.core.models import Application, Vendor
|
||||
|
||||
secret = _mint_collector_token(client, auth_headers, ['collector.ingest'])
|
||||
|
||||
perm_gated = client.post('/api/applications', json={'appname': 'Nope'},
|
||||
headers={'Authorization': f'Bearer {secret}'})
|
||||
assert perm_gated.status_code == 403
|
||||
assert Application.query.filter_by(appname='Nope').first() is None
|
||||
|
||||
role_gated = client.post('/api/vendors', json={'vendor': 'Nope'},
|
||||
headers={'Authorization': f'Bearer {secret}'})
|
||||
assert role_gated.status_code == 403
|
||||
assert Vendor.query.filter_by(vendor='Nope').first() is None
|
||||
|
||||
|
||||
def test_env_key_still_works_alongside_tokens(client, db, collector_key,
|
||||
computer_assettype):
|
||||
"""Regression: the env key path is unchanged when tokens are in play."""
|
||||
resp = client.post('/api/collector/computers', json={'hostname': 'WJENV01'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['action'] == 'created'
|
||||
|
||||
|
||||
# -- metrology measuring-tool auto-link ------------------------------------
|
||||
# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector
|
||||
# creates the attached measuring instrument as a MeasuringTool asset and links
|
||||
# the PC to it with a directional 'controls' relationship.
|
||||
|
||||
@pytest.fixture
|
||||
def measuringtool_env(db):
|
||||
"""Seed the measuring_tool asset type + 'controls' relationship type the
|
||||
metrology collector path links through."""
|
||||
from shopdb.core.models import AssetType, RelationshipType
|
||||
db.session.add(AssetType(assettype='measuring_tool',
|
||||
pluginname='measuringtools',
|
||||
tablename='measuringtools',
|
||||
description='Metrology instruments'))
|
||||
db.session.add(RelationshipType(relationshiptype='controls',
|
||||
description='Operational authority',
|
||||
isdirectional=True))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_metrology_pctype_creates_and_links_measuringtool(
|
||||
client, db, collector_key, computer_assettype, measuringtool_env):
|
||||
"""A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type
|
||||
is auto-created (it is not in the measuringtools starter seed)."""
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
|
||||
from shopdb.core.models import AssetRelationship
|
||||
|
||||
payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['measuringtoollinkcount'] == 1
|
||||
link = data['measuringtoollinks'][0]
|
||||
assert link['relationshiptype'] == 'controls'
|
||||
assert link['measuringtooltype'] == 'CMM'
|
||||
|
||||
with client.application.app_context():
|
||||
tools = MeasuringTool.query.all()
|
||||
assert len(tools) == 1
|
||||
assert MeasuringToolType.query.filter_by(name='CMM').first() is not None
|
||||
pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first()
|
||||
rels = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, label='collector:measuringtool').all()
|
||||
assert len(rels) == 1
|
||||
assert rels[0].targetassetid == tools[0].assetid
|
||||
assert rels[0].isactive
|
||||
|
||||
|
||||
def test_metrology_link_idempotent(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""Re-pushing the same metrology PC does not duplicate the tool asset."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'}
|
||||
for _ in range(2):
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
with client.application.app_context():
|
||||
assert MeasuringTool.query.count() == 1
|
||||
|
||||
|
||||
def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""A plain shopfloor PC gets no measuring-tool asset."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
|
||||
with client.application.app_context():
|
||||
assert MeasuringTool.query.count() == 0
|
||||
|
||||
|
||||
def test_repurposed_pc_archives_tool_link(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""A metrology PC re-imaged to a non-metrology type has its collector tool
|
||||
link archived (tool asset kept, relationship deactivated)."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
from shopdb.core.models import AssetRelationship
|
||||
|
||||
host = {'hostname': 'WJCMM03'}
|
||||
client.post('/api/collector/computers',
|
||||
json={**host, 'pctype': 'gea-shopfloor-cmm'},
|
||||
headers={'X-API-Key': KEY})
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={**host, 'pctype': 'gea-shopfloor-collections'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
|
||||
with client.application.app_context():
|
||||
# asset preserved (may carry calibration history), link deactivated
|
||||
assert MeasuringTool.query.count() == 1
|
||||
rels = AssetRelationship.query.filter_by(
|
||||
label='collector:measuringtool').all()
|
||||
assert len(rels) == 1
|
||||
assert rels[0].isactive is False
|
||||
199
tests/test_core/test_collector_printers.py
Normal file
199
tests/test_core/test_collector_printers.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Tests for the computers collector PC->printer relationship sync.
|
||||
|
||||
A collector payload carrying defaultprinter/printers creates AssetRelationship
|
||||
rows linking the PC asset to the resolved printer assets ('defaultprinter' for
|
||||
the default, 'connectedto' for the rest). Stale collector links archive on
|
||||
re-report; manual links survive; unresolved identifiers warn without failing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import (
|
||||
Asset, AssetType, AssetRelationship, RelationshipType, CommunicationType,
|
||||
Communication)
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
"""Computer + printer asset types, the two relationship types, and three
|
||||
printer assets identified by windowsname / hostname / IP."""
|
||||
pc_type = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
pr_type = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers')
|
||||
dp_type = RelationshipType(relationshiptype='defaultprinter',
|
||||
description='PC to default printer',
|
||||
isdirectional=True)
|
||||
ct_type = RelationshipType(relationshiptype='connectedto',
|
||||
description='Network link', isdirectional=False)
|
||||
ip_comtype = CommunicationType(comtype='IP')
|
||||
db.session.add_all([pc_type, pr_type, dp_type, ct_type, ip_comtype])
|
||||
db.session.flush()
|
||||
|
||||
# Printer A: matched by windowsname. Printer B: by hostname. Printer C: by IP.
|
||||
a_asset = Asset(assetnumber='PRN-A', name='Materials HP',
|
||||
assettypeid=pr_type.assettypeid, isactive=True)
|
||||
b_asset = Asset(assetnumber='PRN-B', name='Front Office',
|
||||
assettypeid=pr_type.assettypeid, isactive=True)
|
||||
c_asset = Asset(assetnumber='PRN-C', name='Shipping Label',
|
||||
assettypeid=pr_type.assettypeid, isactive=True)
|
||||
db.session.add_all([a_asset, b_asset, c_asset])
|
||||
db.session.flush()
|
||||
|
||||
db.session.add_all([
|
||||
Printer(assetid=a_asset.assetid, windowsname='HP-Materials'),
|
||||
Printer(assetid=b_asset.assetid, hostname='prn-front.wjs.local'),
|
||||
Printer(assetid=c_asset.assetid, windowsname='Zebra-Ship'),
|
||||
])
|
||||
db.session.add(Communication(assetid=c_asset.assetid,
|
||||
comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress='10.1.2.3', isprimary=True))
|
||||
db.session.commit()
|
||||
|
||||
return {'pc_type': pc_type, 'dp_type': dp_type, 'ct_type': ct_type,
|
||||
'a': a_asset, 'b': b_asset, 'c': c_asset}
|
||||
|
||||
|
||||
def _post(client, payload):
|
||||
return client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
|
||||
|
||||
def _pc_asset(db):
|
||||
from plugins.computers.models import Computer
|
||||
comp = Computer.query.first()
|
||||
return comp.asset
|
||||
|
||||
|
||||
def _active_links(db, pcid):
|
||||
return AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcid, isactive=True).all()
|
||||
|
||||
|
||||
def test_creates_default_and_connected_links(client, db, collector_key, scene):
|
||||
"""Default -> defaultprinter, others -> connectedto, resolved by three id forms."""
|
||||
resp = _post(client, {
|
||||
'hostname': 'WJPC-P1',
|
||||
'defaultprinter': 'HP-Materials', # windowsname
|
||||
'printers': ['prn-front.wjs.local', '10.1.2.3'], # hostname, IP
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['printerlinkcount'] == 3
|
||||
assert not data['warnings']
|
||||
|
||||
pc = _pc_asset(db)
|
||||
links = {(r.targetassetid, r.relationshiptype.relationshiptype)
|
||||
for r in _active_links(db, pc.assetid)}
|
||||
assert (scene['a'].assetid, 'defaultprinter') in links
|
||||
assert (scene['b'].assetid, 'connectedto') in links
|
||||
assert (scene['c'].assetid, 'connectedto') in links
|
||||
|
||||
|
||||
def test_unresolved_identifier_warns_not_fails(client, db, collector_key, scene):
|
||||
"""An unknown printer identifier is a warning, the rest still link."""
|
||||
resp = _post(client, {
|
||||
'hostname': 'WJPC-P2',
|
||||
'defaultprinter': 'HP-Materials',
|
||||
'printers': ['does-not-exist'],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert any('unresolved printer: does-not-exist' in w
|
||||
for w in data['warnings'])
|
||||
pc = _pc_asset(db)
|
||||
assert data['printerlinkcount'] == 1
|
||||
assert len(_active_links(db, pc.assetid)) == 1
|
||||
|
||||
|
||||
def test_idempotent_no_duplicates(client, db, collector_key, scene):
|
||||
"""Re-report the same set: no new rows, still one link per printer."""
|
||||
payload = {'hostname': 'WJPC-P3', 'defaultprinter': 'HP-Materials',
|
||||
'printers': ['Zebra-Ship']}
|
||||
assert _post(client, payload).status_code == 200
|
||||
assert _post(client, payload).status_code == 200
|
||||
|
||||
pc = _pc_asset(db)
|
||||
all_rows = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).all()
|
||||
assert len(all_rows) == 2 # one default + one connected, no dupes
|
||||
assert all(r.isactive for r in all_rows)
|
||||
|
||||
|
||||
def test_dropped_printer_is_archived(client, db, collector_key, scene):
|
||||
"""A printer that stops being reported loses its collector link."""
|
||||
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials',
|
||||
'printers': ['Zebra-Ship']})
|
||||
pc = _pc_asset(db)
|
||||
assert len(_active_links(db, pc.assetid)) == 2
|
||||
|
||||
# Re-report without Zebra-Ship.
|
||||
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials',
|
||||
'printers': []})
|
||||
active = _active_links(db, pc.assetid)
|
||||
assert len(active) == 1
|
||||
assert active[0].targetassetid == scene['a'].assetid
|
||||
# the dropped row still exists but is inactive
|
||||
zebra = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
|
||||
assert zebra is not None and zebra.isactive is False
|
||||
|
||||
|
||||
def test_default_change_archives_old_default(client, db, collector_key, scene):
|
||||
"""Changing the default printer archives the previous defaultprinter link."""
|
||||
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'HP-Materials'})
|
||||
pc = _pc_asset(db)
|
||||
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'prn-front.wjs.local'})
|
||||
|
||||
active = _active_links(db, pc.assetid)
|
||||
assert len(active) == 1
|
||||
assert active[0].targetassetid == scene['b'].assetid
|
||||
assert active[0].relationshiptype.relationshiptype == 'defaultprinter'
|
||||
|
||||
|
||||
def test_manual_link_survives_collector_sync(client, db, collector_key, scene):
|
||||
"""A manually-created link (no collector label) is never archived."""
|
||||
# PC created first via a bare report.
|
||||
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials',
|
||||
'printers': ['Zebra-Ship']})
|
||||
pc = _pc_asset(db)
|
||||
|
||||
# Manual connectedto link PC -> printer B (label stays NULL).
|
||||
manual = AssetRelationship(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['b'].assetid,
|
||||
relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand')
|
||||
db.session.add(manual)
|
||||
db.session.commit()
|
||||
manual_id = manual.relationshipid
|
||||
|
||||
# Re-report dropping Zebra-Ship and never mentioning printer B.
|
||||
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials',
|
||||
'printers': []})
|
||||
|
||||
surviving = db.session.get(AssetRelationship, manual_id)
|
||||
assert surviving.isactive is True # manual link untouched
|
||||
# Zebra (collector) archived.
|
||||
zebra = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
|
||||
assert zebra.isactive is False
|
||||
|
||||
|
||||
def test_no_printer_keys_leaves_links_untouched(client, db, collector_key, scene):
|
||||
"""A report with no printer keys does not archive existing links."""
|
||||
_post(client, {'hostname': 'WJPC-P7', 'defaultprinter': 'HP-Materials'})
|
||||
pc = _pc_asset(db)
|
||||
assert len(_active_links(db, pc.assetid)) == 1
|
||||
|
||||
# A plain update (no defaultprinter, no printers key) must not archive.
|
||||
_post(client, {'hostname': 'WJPC-P7', 'currentuser': 'alice'})
|
||||
assert len(_active_links(db, pc.assetid)) == 1
|
||||
38
tests/test_core/test_dashboard_widgets.py
Normal file
38
tests/test_core/test_dashboard_widgets.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Tests for the dashboard-widgets hook consumer (/api/dashboard/widgets).
|
||||
|
||||
Pins the wiring added for the BasePlugin.get_dashboard_widgets hook: the
|
||||
endpoint aggregates enabled plugins' widgets and skips disabled ones.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
"""
|
||||
|
||||
|
||||
def _widget_plugins(client, headers):
|
||||
response = client.get('/api/dashboard/widgets', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
widgets = response.get_json()['data']
|
||||
assert isinstance(widgets, list)
|
||||
return widgets, {w.get('plugin') for w in widgets}
|
||||
|
||||
|
||||
def test_widgets_endpoint_aggregates_enabled_plugins(app, client, auth_headers,
|
||||
monkeypatch):
|
||||
"""An enabled plugin that implements the hook contributes a widget."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
widgets, plugins = _widget_plugins(client, auth_headers)
|
||||
assert 'computers' in plugins # computers implements get_dashboard_widgets
|
||||
positions = [w.get('position', 99) for w in widgets]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
def test_widgets_endpoint_skips_disabled_plugin(app, client, auth_headers,
|
||||
monkeypatch):
|
||||
"""A disabled plugin's widgets drop out of the aggregate."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers')
|
||||
|
||||
_, plugins = _widget_plugins(client, auth_headers)
|
||||
assert 'computers' not in plugins
|
||||
99
tests/test_core/test_dashboarddefaults.py
Normal file
99
tests/test_core/test_dashboarddefaults.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for dashboard defaults (visitor-IP -> business-unit resolution)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def businessunit(db):
|
||||
from shopdb.core.models import BusinessUnit
|
||||
bu = BusinessUnit(businessunit='Materials')
|
||||
db.session.add(bu)
|
||||
db.session.commit()
|
||||
return bu
|
||||
|
||||
|
||||
def test_create_and_resolve_visitor_location(client, db, auth_headers, businessunit):
|
||||
"""A mapped IP resolves to its business unit; explicit ipaddress param works."""
|
||||
created = client.post('/api/dashboarddefaults', json={
|
||||
'ipaddress': '10.20.30.40',
|
||||
'businessunitid': businessunit.businessunitid,
|
||||
'description': 'Materials lobby kiosk',
|
||||
}, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
|
||||
resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=10.20.30.40')
|
||||
assert resolved.status_code == 200
|
||||
data = resolved.get_json()['data']
|
||||
assert data['businessunitid'] == businessunit.businessunitid
|
||||
assert data['businessunit'] == 'Materials'
|
||||
|
||||
|
||||
def test_unmapped_ip_resolves_to_null(client, db):
|
||||
"""An unmapped IP returns a null business unit, not an error."""
|
||||
resolved = client.get('/api/dashboarddefaults/visitor-location?ipaddress=1.2.3.4')
|
||||
assert resolved.status_code == 200
|
||||
assert resolved.get_json()['data']['businessunitid'] is None
|
||||
|
||||
|
||||
def test_duplicate_ip_rejected(client, db, auth_headers, businessunit):
|
||||
"""Mapping the same IP twice is a conflict."""
|
||||
payload = {'ipaddress': '10.0.0.9', 'businessunitid': businessunit.businessunitid}
|
||||
first = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers)
|
||||
assert first.status_code == 201
|
||||
dup = client.post('/api/dashboarddefaults', json=payload, headers=auth_headers)
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_non_admin_cannot_create(client, db, member_headers, businessunit):
|
||||
"""A logged-in non-admin is forbidden from creating a mapping (RBAC)."""
|
||||
resp = client.post('/api/dashboarddefaults', json={
|
||||
'ipaddress': '10.20.30.50',
|
||||
'businessunitid': businessunit.businessunitid,
|
||||
}, headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_unauthenticated_cannot_create(client, db, businessunit):
|
||||
"""No token -> 401 on create."""
|
||||
resp = client.post('/api/dashboarddefaults', json={
|
||||
'ipaddress': '10.20.30.51',
|
||||
'businessunitid': businessunit.businessunitid,
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_admin_can_update_and_delete(client, db, auth_headers, businessunit):
|
||||
"""Admin can PUT and DELETE a mapping."""
|
||||
created = client.post('/api/dashboarddefaults', json={
|
||||
'ipaddress': '10.20.30.60',
|
||||
'businessunitid': businessunit.businessunitid,
|
||||
}, headers=auth_headers)
|
||||
assert created.status_code == 201
|
||||
default_id = created.get_json()['data']['dashboarddefaultid']
|
||||
|
||||
updated = client.put(f'/api/dashboarddefaults/{default_id}',
|
||||
json={'description': 'moved'}, headers=auth_headers)
|
||||
assert updated.status_code == 200
|
||||
|
||||
deleted = client.delete(f'/api/dashboarddefaults/{default_id}',
|
||||
headers=auth_headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
|
||||
def test_non_admin_cannot_update_or_delete(client, db, auth_headers,
|
||||
member_headers, businessunit):
|
||||
"""A non-admin is forbidden from PUT and DELETE."""
|
||||
created = client.post('/api/dashboarddefaults', json={
|
||||
'ipaddress': '10.20.30.70',
|
||||
'businessunitid': businessunit.businessunitid,
|
||||
}, headers=auth_headers)
|
||||
assert created.status_code == 201
|
||||
default_id = created.get_json()['data']['dashboarddefaultid']
|
||||
|
||||
put_resp = client.put(f'/api/dashboarddefaults/{default_id}',
|
||||
json={'description': 'nope'}, headers=member_headers)
|
||||
assert put_resp.status_code == 403
|
||||
|
||||
del_resp = client.delete(f'/api/dashboarddefaults/{default_id}',
|
||||
headers=member_headers)
|
||||
assert del_resp.status_code == 403
|
||||
270
tests/test_core/test_email.py
Normal file
270
tests/test_core/test_email.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Tests for the email service, forced password change, welcome email, and
|
||||
on-demand report/alert delivery.
|
||||
|
||||
smtplib is always patched so tests never touch the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_smtp_env(monkeypatch):
|
||||
"""Remove any SMTP_* env so env-fallback cannot enable email unexpectedly."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith('SMTP_'):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _enable_smtp(db):
|
||||
"""Seed the minimum settings to make SMTP 'configured'."""
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
rows = {
|
||||
'smtp_enabled': ('true', 'boolean'),
|
||||
'smtp_host': ('smtp.test.local', 'string'),
|
||||
'smtp_port': ('587', 'integer'),
|
||||
'smtp_use_tls': ('true', 'boolean'),
|
||||
'smtp_from_address': ('noreply@test.local', 'string'),
|
||||
'smtp_from_name': ('ShopDB', 'string'),
|
||||
'alert_recipients': ('ops@test.local', 'string'),
|
||||
}
|
||||
for key, (value, valuetype) in rows.items():
|
||||
db.session.add(Setting(key=key, value=value, valuetype=valuetype,
|
||||
category='email'))
|
||||
db.session.commit()
|
||||
invalidate_settings_cache()
|
||||
|
||||
|
||||
def _make_user(db, username, password='temppass1', mustchange=False,
|
||||
failedlogins=0):
|
||||
from shopdb.core.models import User
|
||||
user = User(
|
||||
username=username,
|
||||
email=f'{username}@test.local',
|
||||
passwordhash=generate_password_hash(password),
|
||||
mustchangepassword=mustchange,
|
||||
failedlogins=failedlogins,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _login(client, username, password):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': password})
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mailer no-op behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_mailer_noop_when_disabled(app, db):
|
||||
"""send_email is a no-op returning False when SMTP is disabled; no connect."""
|
||||
from shopdb.utils import mailer
|
||||
with app.app_context():
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
result = mailer.send_email('a@test.local', 'Hi', '<p>Hi</p>')
|
||||
assert result is False
|
||||
smtp.assert_not_called()
|
||||
|
||||
|
||||
def test_mailer_sends_when_configured(app, db):
|
||||
"""send_email connects and sends when SMTP is configured (patched)."""
|
||||
from shopdb.utils import mailer
|
||||
with app.app_context():
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
result = mailer.send_email('a@test.local', 'Hi', '<p>Hi</p>')
|
||||
assert result is True
|
||||
instance.sendmail.assert_called_once()
|
||||
|
||||
|
||||
def test_mailer_never_logs_password(app, db):
|
||||
"""A send failure carrying the password is scrubbed in the returned error."""
|
||||
from shopdb.utils import mailer
|
||||
with app.app_context():
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
_enable_smtp(db)
|
||||
db.session.add(Setting(key='smtp_password', value='s3cret',
|
||||
valuetype='string', category='email'))
|
||||
db.session.commit()
|
||||
invalidate_settings_cache()
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
instance.sendmail.side_effect = RuntimeError('auth failed s3cret')
|
||||
smtp.return_value = instance
|
||||
ok, error = mailer.try_send('a@test.local', 'Hi', '<p>Hi</p>')
|
||||
assert ok is False
|
||||
assert 's3cret' not in error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-email endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_test_email_requires_admin(client, db, member_headers):
|
||||
response = client.post('/api/settings/test-email',
|
||||
json={'to': 'x@test.local'}, headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_test_email_graceful_when_unconfigured(client, db, auth_headers):
|
||||
response = client.post('/api/settings/test-email',
|
||||
json={'to': 'x@test.local'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is False
|
||||
|
||||
|
||||
def test_test_email_sends_when_configured(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
smtp.return_value = MagicMock()
|
||||
response = client.post('/api/settings/test-email',
|
||||
json={'to': 'x@test.local'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forced password change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_login_surfaces_mustchangepassword(client, db):
|
||||
_make_user(db, 'forceduser', password='temppass1', mustchange=True)
|
||||
response = _login(client, 'forceduser', 'temppass1')
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['user']['mustchangepassword'] is True
|
||||
|
||||
|
||||
def test_forced_change_clears_flag_and_lockout(client, db):
|
||||
user = _make_user(db, 'forceduser', password='temppass1', mustchange=True,
|
||||
failedlogins=3)
|
||||
token = _login(client, 'forceduser', 'temppass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
# Forced case: new_password only, no current required.
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'new_password': 'brandnew123'}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
from shopdb.core.models import User
|
||||
refreshed = db.session.get(User, user.userid)
|
||||
assert refreshed.mustchangepassword is False
|
||||
assert refreshed.failedlogins == 0
|
||||
assert refreshed.lockeduntil is None
|
||||
# New password works.
|
||||
assert _login(client, 'forceduser', 'brandnew123').status_code == 200
|
||||
|
||||
|
||||
def test_selfservice_change_rejects_wrong_current(client, db):
|
||||
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
|
||||
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'current_password': 'wrongpass',
|
||||
'new_password': 'brandnew123'}, headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_selfservice_change_succeeds_with_correct_current(client, db):
|
||||
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
|
||||
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'current_password': 'rightpass1',
|
||||
'new_password': 'brandnew123'}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert _login(client, 'normaluser', 'brandnew123').status_code == 200
|
||||
|
||||
|
||||
def test_change_password_rejects_short_password(client, db):
|
||||
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
|
||||
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'current_password': 'rightpass1',
|
||||
'new_password': 'short'}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New-user welcome
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_user_sets_mustchangepassword_and_sends(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/users',
|
||||
json={'username': 'newhire',
|
||||
'email': 'newhire@test.local',
|
||||
'password': 'temppass1'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
assert response.get_json()['data']['mustchangepassword'] is True
|
||||
instance.sendmail.assert_called_once()
|
||||
|
||||
|
||||
def test_create_user_survives_mail_failure(client, db, auth_headers):
|
||||
"""User is still created (with a warning) when the welcome email fails."""
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
instance.sendmail.side_effect = RuntimeError('relay down')
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/users',
|
||||
json={'username': 'newhire2',
|
||||
'email': 'newhire2@test.local',
|
||||
'password': 'temppass1'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
assert 'warning' in response.get_json()['data']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-demand report/alert delivery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_email_report_requires_permission(client, db, member_headers):
|
||||
response = client.post('/api/reports/email',
|
||||
json={'subject': 'X', 'columns': [], 'rows': []},
|
||||
headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_email_report_renders_and_sends(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/reports/email',
|
||||
json={'subject': 'Warranty Report',
|
||||
'columns': [{'key': 'vendor', 'label': 'Vendor'}],
|
||||
'rows': [{'vendor': 'Dell'}],
|
||||
'to': 'boss@test.local'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is True
|
||||
instance.sendmail.assert_called_once()
|
||||
|
||||
|
||||
def test_email_report_defaults_to_alert_recipients(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/reports/email',
|
||||
json={'subject': 'Toner', 'columns': [], 'rows': []},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is True
|
||||
26
tests/test_core/test_employees.py
Normal file
26
tests/test_core/test_employees.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Characterization tests for the employee directory endpoints.
|
||||
|
||||
Written before extracting employees into a plugin. The wjf_employees DB is not
|
||||
available under test, so these assert the DB-independent behaviors: validation
|
||||
(400) and graceful failure (500 envelope, not an unhandled crash) when the
|
||||
directory is unreachable. Behavior must be identical as a plugin blueprint.
|
||||
"""
|
||||
|
||||
|
||||
def test_search_requires_min_query(client, db):
|
||||
"""A too-short query is rejected before touching the directory DB."""
|
||||
resp = client.get('/api/employees/search?q=a')
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_lookup_requires_numeric_sso(client, db):
|
||||
"""Non-numeric SSO is rejected without touching the directory DB."""
|
||||
resp = client.get('/api/employees/lookup/abc')
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_search_degrades_when_directory_unreachable(client, db):
|
||||
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
||||
resp = client.get('/api/employees/search?q=smith')
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()['status'] == 'error'
|
||||
77
tests/test_core/test_identifiers.py
Normal file
77
tests/test_core/test_identifiers.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for per-asset-type optional identifiers (gauge/maintenance refs).
|
||||
|
||||
Pins two behaviors that shipped without coverage:
|
||||
1. gaugelabreference + maintenancereference round-trip through each plugin's
|
||||
asset create AND update endpoints (computer, printer, network).
|
||||
2. The per-type identifier seed produces one setting per
|
||||
(identifier x asset type) pair.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset_types(db):
|
||||
"""Seed the asset types the create endpoints look up by name."""
|
||||
from shopdb.core.models import AssetType
|
||||
|
||||
for name in ('computer', 'printer', 'network_device'):
|
||||
db.session.add(AssetType(assettype=name, pluginname=name,
|
||||
tablename=name, description=name))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# (endpoint prefix, extension key in the response, extra create fields)
|
||||
PLUGIN_CASES = [
|
||||
('/api/computers', 'computer', {}),
|
||||
('/api/printers', 'printer', {}),
|
||||
('/api/network', 'networkdevice', {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prefix,extkey,extra', PLUGIN_CASES)
|
||||
def test_gauge_maintenance_roundtrip(client, db, auth_headers, asset_types,
|
||||
prefix, extkey, extra):
|
||||
"""Gauge/maintenance refs persist on create and update for each type."""
|
||||
payload = {
|
||||
'assetnumber': f'AST-{extkey}',
|
||||
'gaugelabreference': 'GL-1',
|
||||
'maintenancereference': 'MNT-1',
|
||||
**extra,
|
||||
}
|
||||
created = client.post(prefix, json=payload, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
body = created.get_json()['data']
|
||||
assert body['gaugelabreference'] == 'GL-1'
|
||||
assert body['maintenancereference'] == 'MNT-1'
|
||||
|
||||
extid = body[extkey][f'{extkey}id'] if extkey != 'networkdevice' \
|
||||
else body[extkey]['networkdeviceid']
|
||||
|
||||
updated = client.put(f'{prefix}/{extid}',
|
||||
json={'gaugelabreference': 'GL-2',
|
||||
'maintenancereference': 'MNT-2'},
|
||||
headers=auth_headers)
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
|
||||
fetched = client.get(f'{prefix}/{extid}', headers=auth_headers).get_json()['data']
|
||||
assert fetched['gaugelabreference'] == 'GL-2'
|
||||
assert fetched['maintenancereference'] == 'MNT-2'
|
||||
|
||||
|
||||
def test_per_type_identifier_seed_count(client, db, auth_headers):
|
||||
"""Seeding produces one boolean setting per identifier x asset type."""
|
||||
response = client.post('/api/settings/seed', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
keys = {s.key for s in Setting.query.filter_by(category='identifiers').all()}
|
||||
expected = {
|
||||
f'identifier_{name}_{assettype}_enabled'
|
||||
for name in IDENTIFIER_LABELS
|
||||
for assettype in IDENTIFIER_ASSETTYPES
|
||||
}
|
||||
assert expected <= keys
|
||||
assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES)
|
||||
209
tests/test_core/test_import_mode.py
Normal file
209
tests/test_core/test_import_mode.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Import-mode behavior: admin-gated legacy timestamp + event backdating.
|
||||
|
||||
Covers deliverables of the import-API work:
|
||||
- createddate/modifieddate passthrough on a reference entity (vendor) and an
|
||||
asset plugin (computer), gated on admin + X-Import-Mode header.
|
||||
- both negative cases: no header, and header sent by a non-admin.
|
||||
- a natural-key exact-match lookup filter added for idempotent import.
|
||||
- backdated USB checkout time in selfhosted mode.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
# Import the usb models at collection time so their tables register on the
|
||||
# metadata before the per-test db fixture calls create_all. The usb plugin is
|
||||
# disabled in the dev registry, so its tables would otherwise never be created.
|
||||
from plugins.usb.models import USBDevice, USBCheckout # noqa: F401
|
||||
|
||||
|
||||
IMPORT_HEADER = {'X-Import-Mode': 'true'}
|
||||
LEGACY_CREATED = '2020-01-05 08:30:00'
|
||||
LEGACY_MODIFIED = '2021-06-07T14:15:16'
|
||||
|
||||
|
||||
def _headers(auth_headers, importmode=True):
|
||||
merged = dict(auth_headers)
|
||||
if importmode:
|
||||
merged.update(IMPORT_HEADER)
|
||||
return merged
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset_types(db):
|
||||
"""Seed the asset types the plugin create endpoints look up by name."""
|
||||
from shopdb.core.models import AssetType
|
||||
|
||||
for name in ('computer',):
|
||||
db.session.add(AssetType(assettype=name, pluginname=name,
|
||||
tablename=name, description=name))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def creator_headers(client, db):
|
||||
"""A NON-admin user granted only computers.create/edit, logged in.
|
||||
|
||||
Proves import mode requires admin: this user carries the write permission
|
||||
but must NOT get timestamp passthrough.
|
||||
"""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
perms = []
|
||||
for name in ('computers.create', 'computers.edit'):
|
||||
p = Permission(name=name, description=name, category='computers')
|
||||
db.session.add(p)
|
||||
perms.append(p)
|
||||
role = Role(rolename='pcoperator', description='PC operator')
|
||||
role.permissions.extend(perms)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
|
||||
user = User(username='pcop', email='pcop@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': 'pcop', 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timestamp passthrough (positive)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_import_mode_preserves_vendor_timestamps(client, db, auth_headers):
|
||||
"""Admin + header: vendor keeps the legacy createddate/modifieddate."""
|
||||
payload = {'vendor': 'LegacyVendor',
|
||||
'createddate': LEGACY_CREATED,
|
||||
'modifieddate': LEGACY_MODIFIED}
|
||||
response = client.post('/api/vendors', json=payload,
|
||||
headers=_headers(auth_headers))
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
from shopdb.core.models import Vendor
|
||||
v = Vendor.query.filter_by(vendor='LegacyVendor').first()
|
||||
assert v.createddate.year == 2020 and v.createddate.month == 1
|
||||
assert v.createddate.day == 5 and v.createddate.hour == 8
|
||||
assert v.modifieddate.year == 2021 and v.modifieddate.month == 6
|
||||
|
||||
|
||||
def test_admin_import_mode_preserves_computer_asset_timestamps(
|
||||
client, db, auth_headers, asset_types):
|
||||
"""Admin + header: the created asset row keeps its legacy createddate."""
|
||||
payload = {'assetnumber': 'PC-IMPORT-1',
|
||||
'hostname': 'legacy-pc',
|
||||
'createddate': LEGACY_CREATED,
|
||||
'modifieddate': LEGACY_MODIFIED}
|
||||
response = client.post('/api/computers', json=payload,
|
||||
headers=_headers(auth_headers))
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
from shopdb.core.models import Asset
|
||||
asset = Asset.query.filter_by(assetnumber='PC-IMPORT-1').first()
|
||||
assert asset.createddate.year == 2020
|
||||
assert asset.modifieddate.year == 2021
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timestamp passthrough (negative): ignored without header, or for non-admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_header_ignores_timestamps(client, db, auth_headers):
|
||||
"""Admin but NO X-Import-Mode header: createddate falls back to now."""
|
||||
payload = {'vendor': 'NoHeaderVendor', 'createddate': LEGACY_CREATED}
|
||||
response = client.post('/api/vendors', json=payload,
|
||||
headers=_headers(auth_headers, importmode=False))
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
from shopdb.core.models import Vendor
|
||||
v = Vendor.query.filter_by(vendor='NoHeaderVendor').first()
|
||||
assert v.createddate.year >= 2024 # server-stamped now, not the 2020 payload
|
||||
|
||||
|
||||
def test_non_admin_ignores_timestamps(client, db, creator_headers, asset_types):
|
||||
"""Non-admin WITH the write permission + header: timestamps still ignored."""
|
||||
payload = {'assetnumber': 'PC-NONADMIN-1',
|
||||
'createddate': LEGACY_CREATED}
|
||||
response = client.post('/api/computers', json=payload,
|
||||
headers=_headers(creator_headers))
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
from shopdb.core.models import Asset
|
||||
asset = Asset.query.filter_by(assetnumber='PC-NONADMIN-1').first()
|
||||
assert asset.createddate.year >= 2024 # not backdated: caller is not admin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Natural-key lookup filter (idempotency recipe support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_vendor_exact_lookup_filter(client, db, auth_headers):
|
||||
"""GET /api/vendors?vendor=<name> exact-matches one vendor for lookup."""
|
||||
for name in ('Acme', 'AcmeTools', 'Beta'):
|
||||
resp = client.post('/api/vendors', json={'vendor': name},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
listed = client.get('/api/vendors?vendor=Acme', headers=auth_headers)
|
||||
assert listed.status_code == 200
|
||||
rows = listed.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['vendor'] == 'Acme'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backdated USB checkout (selfhosted mode)
|
||||
#
|
||||
# The usb plugin is disabled in the dev registry, so its blueprint may not be
|
||||
# registered. Exercise the selfhosted layer directly inside a request context
|
||||
# carrying the admin JWT + import header, which is what the route delegates to.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_usb_device(db, serialnumber):
|
||||
from plugins.usb.models import USBDevice
|
||||
device = USBDevice(serialnumber=serialnumber, ischeckedout=False, isactive=True)
|
||||
db.session.add(device)
|
||||
db.session.commit()
|
||||
return device
|
||||
|
||||
|
||||
def test_usb_backdated_checkout(app, db, auth_headers):
|
||||
"""Admin + header: a USB checkout records the historical checkouttime."""
|
||||
from plugins.usb.api import selfhosted
|
||||
from plugins.usb.models import USBDevice, USBCheckout
|
||||
|
||||
device = _seed_usb_device(db, 'USB-IMPORT-1')
|
||||
headers = _headers(auth_headers)
|
||||
with app.test_request_context('/api/usb/USB-IMPORT-1/checkout',
|
||||
method='POST', headers=headers):
|
||||
resp = selfhosted.checkout_device(
|
||||
'USB-IMPORT-1',
|
||||
{'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'})
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first()
|
||||
assert row.checkouttime.year == 2020 and row.checkouttime.month == 3
|
||||
reloaded = db.session.get(USBDevice, device.usbdeviceid)
|
||||
assert reloaded.currentcheckoutdate.year == 2020
|
||||
|
||||
|
||||
def test_usb_checkout_ignores_backdate_without_header(app, db, auth_headers):
|
||||
"""No import header: the checkouttime override is ignored, uses now."""
|
||||
from plugins.usb.api import selfhosted
|
||||
from plugins.usb.models import USBDevice, USBCheckout
|
||||
|
||||
device = _seed_usb_device(db, 'USB-IMPORT-2')
|
||||
with app.test_request_context('/api/usb/USB-IMPORT-2/checkout',
|
||||
method='POST', headers=auth_headers):
|
||||
resp = selfhosted.checkout_device(
|
||||
'USB-IMPORT-2',
|
||||
{'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'})
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first()
|
||||
assert row.checkouttime.year >= 2024 # server now, not the 2020 override
|
||||
34
tests/test_core/test_knowledgebase.py
Normal file
34
tests/test_core/test_knowledgebase.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Characterization tests for the Knowledge Base API.
|
||||
|
||||
Written before extracting Knowledge Base into a plugin (pinning-flask-behavior):
|
||||
the endpoints must behave identically whether KB is a core blueprint or a plugin
|
||||
blueprint, since both register at /api/knowledgebase.
|
||||
"""
|
||||
|
||||
|
||||
def test_create_list_get_article(client, db, auth_headers):
|
||||
"""Create an article, see it in the list, fetch it by id."""
|
||||
created = client.post('/api/knowledgebase',
|
||||
json={'shortdescription': 'How to reset a printer',
|
||||
'linkurl': 'https://kb.example/printer-reset',
|
||||
'keywords': 'printer reset'},
|
||||
headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
linkid = created.get_json()['data']['linkid']
|
||||
|
||||
listing = client.get('/api/knowledgebase', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
ids = [a['linkid'] for a in listing.get_json()['data']]
|
||||
assert linkid in ids
|
||||
|
||||
fetched = client.get(f'/api/knowledgebase/{linkid}', headers=auth_headers)
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.get_json()['data']['shortdescription'] == 'How to reset a printer'
|
||||
|
||||
|
||||
def test_create_requires_shortdescription(client, db, auth_headers):
|
||||
"""shortdescription is required."""
|
||||
resp = client.post('/api/knowledgebase',
|
||||
json={'linkurl': 'https://kb.example/x'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
27
tests/test_core/test_location_code.py
Normal file
27
tests/test_core/test_location_code.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Unit tests for the location operation-code derivation.
|
||||
|
||||
Locations carry no dedicated code column, so the operation code is the leading
|
||||
whitespace-delimited token of the location name (e.g. '0615 Blisk Inspection'
|
||||
-> '0615'). Printed asset labels for measuring tools encode this so every tool
|
||||
at one operation shares one code.
|
||||
"""
|
||||
|
||||
from shopdb.core.models.location import derive_locationcode
|
||||
|
||||
|
||||
def test_derive_leading_numeric_token():
|
||||
assert derive_locationcode('0615 Blisk Inspection') == '0615'
|
||||
|
||||
|
||||
def test_derive_single_token_name():
|
||||
assert derive_locationcode('Lab') == 'Lab'
|
||||
|
||||
|
||||
def test_derive_strips_surrounding_whitespace():
|
||||
assert derive_locationcode(' 0700 Final ') == '0700'
|
||||
|
||||
|
||||
def test_derive_none_when_blank():
|
||||
assert derive_locationcode('') is None
|
||||
assert derive_locationcode(' ') is None
|
||||
assert derive_locationcode(None) is None
|
||||
149
tests/test_core/test_model_image.py
Normal file
149
tests/test_core/test_model_image.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for vendor-model photo upload, replace, delete, and serve.
|
||||
|
||||
Covers the core model-image endpoints: admin-gated upload/delete, the public
|
||||
serve route, one-image-per-model replace semantics, and the guarantee that
|
||||
deleting a model whose imageurl is an external URL clears the field without
|
||||
touching the filesystem.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Model
|
||||
from shopdb.core.api.models import MODEL_IMAGE_URL_PREFIX
|
||||
|
||||
|
||||
def _make_model(db, imageurl=None):
|
||||
m = Model(modelnumber='TESTMODEL-1', imageurl=imageurl)
|
||||
db.session.add(m)
|
||||
db.session.commit()
|
||||
return m
|
||||
|
||||
|
||||
def test_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload a model image."""
|
||||
m = _make_model(db)
|
||||
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_upload_sets_imageurl_and_file_exists(client, db, auth_headers, app):
|
||||
"""Admin upload writes the served URL and the file lands in the instance dir."""
|
||||
m = _make_model(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nfake-png-bytes'
|
||||
data = {'file': (io.BytesIO(payload), 'photo.png')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
expected_url = f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png'
|
||||
assert resp.get_json()['data']['imageurl'] == expected_url
|
||||
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl == expected_url
|
||||
|
||||
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
|
||||
assert os.path.exists(path)
|
||||
with open(path, 'rb') as handle:
|
||||
assert handle.read() == payload
|
||||
|
||||
|
||||
def test_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
m = _make_model(db)
|
||||
data = {'file': (io.BytesIO(b'MZ...'), 'photo.exe')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_upload_missing_model_is_404(client, db, auth_headers):
|
||||
"""Uploading to a nonexistent model id is a 404."""
|
||||
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
|
||||
resp = client.post('/api/models/999999/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
|
||||
"""Re-upload with a different extension deletes the prior file."""
|
||||
m = _make_model(db)
|
||||
first = {'file': (io.BytesIO(b'first'), 'photo.png')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=first,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
oldpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
|
||||
assert os.path.exists(oldpath)
|
||||
|
||||
second = {'file': (io.BytesIO(b'second'), 'photo.jpg')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=second,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Old .png is gone; new .jpg exists and imageurl points at it.
|
||||
assert not os.path.exists(oldpath)
|
||||
newpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.jpg')
|
||||
assert os.path.exists(newpath)
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl == f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.jpg'
|
||||
|
||||
|
||||
def test_serve_returns_bytes(client, db, auth_headers):
|
||||
"""The public serve route returns the uploaded bytes without auth."""
|
||||
m = _make_model(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nserved-bytes'
|
||||
data = {'file': (io.BytesIO(payload), 'photo.png')}
|
||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png')
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
|
||||
"""Delete clears imageurl and removes the owned file."""
|
||||
m = _make_model(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'photo.png')}
|
||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
|
||||
assert os.path.exists(path)
|
||||
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['imageurl'] is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl is None
|
||||
|
||||
|
||||
def test_delete_external_url_clears_field_without_filesystem_error(client, db, auth_headers):
|
||||
"""Delete on a model whose imageurl is an external URL just clears the field."""
|
||||
m = _make_model(db, imageurl='https://example.com/product.png')
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['imageurl'] is None
|
||||
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl is None
|
||||
|
||||
|
||||
def test_delete_legacy_path_clears_field_without_filesystem_error(client, db, auth_headers):
|
||||
"""Delete on a shipped /images/models/* path clears the field, touches no disk."""
|
||||
m = _make_model(db, imageurl='/images/models/machines/legacy.png')
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['imageurl'] is None
|
||||
|
||||
|
||||
def test_delete_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot delete a model image."""
|
||||
m = _make_model(db, imageurl='https://example.com/product.png')
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
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
|
||||
29
tests/test_core/test_plugins_enabled.py
Normal file
29
tests/test_core/test_plugins_enabled.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""GET /api/plugins/enabled: the flat enabled-plugin-name list that drives
|
||||
frontend route gating (ADR-009).
|
||||
|
||||
Pins two properties: the endpoint is reachable anonymously and returns a
|
||||
plain array of names, and a disabled plugin's name drops out of that array
|
||||
(so its frontend routes gate off), mirroring the search-disabled pattern.
|
||||
"""
|
||||
|
||||
|
||||
def test_enabled_anonymous_returns_array(client):
|
||||
"""No auth required (jwt-optional): returns a flat array of name strings."""
|
||||
resp = client.get('/api/plugins/enabled')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert isinstance(data, list)
|
||||
assert all(isinstance(name, str) for name in data)
|
||||
|
||||
|
||||
def test_disabled_plugin_absent(app, client, monkeypatch):
|
||||
"""A disabled plugin's name is missing; enabled ones remain present."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
# Pretend printers is enabled but usb is not.
|
||||
monkeypatch.setattr(pm.registry, 'get_enabled_plugins',
|
||||
lambda: ['printers', 'computers'])
|
||||
resp = client.get('/api/plugins/enabled')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert 'printers' in data
|
||||
assert 'usb' not in data
|
||||
144
tests/test_core/test_pluginui.py
Normal file
144
tests/test_core/test_pluginui.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Tests for the ADR-010 frontend-contribution consumers (/api/pluginui/*).
|
||||
|
||||
Pins the wiring added for the four data-only presentation hooks
|
||||
(get_settings_cards, get_asset_panels, get_map_overlays,
|
||||
get_asset_presentation): each endpoint aggregates enabled plugins'
|
||||
contributions, tags them with the plugin name, and drops disabled plugins.
|
||||
Also asserts the pilot plugins (warranty, measuringtools) contribute.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
"""
|
||||
|
||||
|
||||
def _data(client, path, headers):
|
||||
response = client.get(path, headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
payload = response.get_json()['data']
|
||||
assert isinstance(payload, list)
|
||||
return payload
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# settings-cards
|
||||
# =============================================================================
|
||||
|
||||
def test_settings_cards_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools contributes its settings card, tagged + position-sorted."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
cards = _data(client, '/api/pluginui/settings-cards', auth_headers)
|
||||
plugins = {c.get('plugin') for c in cards}
|
||||
assert 'measuringtools' in plugins
|
||||
card = next(c for c in cards if c['plugin'] == 'measuringtools')
|
||||
assert card['group'] == 'Measuring Tools'
|
||||
assert card['to'] == '/settings/measuringtooltypes'
|
||||
positions = [c.get('position', 99) for c in cards]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
def test_settings_cards_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
"""A disabled measuringtools drops its settings card."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
|
||||
cards = _data(client, '/api/pluginui/settings-cards', auth_headers)
|
||||
assert 'measuringtools' not in {c.get('plugin') for c in cards}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# asset-panels
|
||||
# =============================================================================
|
||||
|
||||
def _make_asset(db):
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer', pluginname='computer',
|
||||
tablename='computer', description='c')
|
||||
db.session.add(atype)
|
||||
db.session.commit()
|
||||
asset = Asset(assetnumber='AST-PANEL01', assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def test_asset_panels_require_assetid(client, auth_headers):
|
||||
"""asset-panels without assetid is a 400."""
|
||||
response = client.get('/api/pluginui/asset-panels', headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_asset_panels_match_asset_type(app, client, db, auth_headers, monkeypatch):
|
||||
"""Warranty's ['*'] panel matches any asset; tagged with its plugin."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
assetid = _make_asset(db)
|
||||
|
||||
panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers)
|
||||
warranty = next((p for p in panels if p.get('plugin') == 'warranty'), None)
|
||||
assert warranty is not None, 'warranty asset panel missing'
|
||||
assert warranty['id'] == 'warranty'
|
||||
assert warranty['endpoint'] == '/api/warranty/asset/{assetid}'
|
||||
assert warranty['render'] in ('keyvalue', 'table', 'badge')
|
||||
|
||||
|
||||
def test_asset_panels_skip_disabled_plugin(app, client, db, auth_headers, monkeypatch):
|
||||
"""A disabled warranty drops its asset panel."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty')
|
||||
assetid = _make_asset(db)
|
||||
|
||||
panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers)
|
||||
assert 'warranty' not in {p.get('plugin') for p in panels}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# map-overlays
|
||||
# =============================================================================
|
||||
|
||||
def test_map_overlays_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools contributes its calibration-due overlay."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
overlays = _data(client, '/api/pluginui/map-overlays', auth_headers)
|
||||
overlay = next((o for o in overlays if o.get('plugin') == 'measuringtools'), None)
|
||||
assert overlay is not None
|
||||
assert overlay['id'] == 'calibration-due'
|
||||
assert overlay['legend'] is True
|
||||
|
||||
|
||||
def test_map_overlays_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
|
||||
overlays = _data(client, '/api/pluginui/map-overlays', auth_headers)
|
||||
assert 'measuringtools' not in {o.get('plugin') for o in overlays}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# asset-presentation
|
||||
# =============================================================================
|
||||
|
||||
def test_asset_presentation_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools declares presentation for its measuring_tool type."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||
entry = next((e for e in entries if e.get('assettype') == 'measuring_tool'), None)
|
||||
assert entry is not None
|
||||
assert entry['plugin'] == 'measuringtools'
|
||||
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
|
||||
|
||||
|
||||
def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
|
||||
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||
assert 'measuring_tool' not in {e.get('assettype') for e in entries}
|
||||
106
tests/test_core/test_relationship_directional.py
Normal file
106
tests/test_core/test_relationship_directional.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Relationship-type directionality: CRUD carries isdirectional and the
|
||||
per-asset relationships endpoint tags each row with its type directionality.
|
||||
|
||||
The frontend uses isdirectional to collapse symmetric-type rows (Dualpath and
|
||||
other physical links) into one direction-blind card entry, and to render
|
||||
directional types (controls, partof...) with source->target phrasing. That
|
||||
dedupe is view logic verified live; here we lock the backend surface it needs.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
|
||||
|
||||
def _make_asset(assetnumber, typeid):
|
||||
asset = Asset(assetnumber=assetnumber, assettypeid=typeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relationship type CRUD carries isdirectional
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_type_defaults_directional(client, db, auth_headers):
|
||||
resp = client.post('/api/assets/relationshiptypes',
|
||||
json={'relationshiptype': 'Backup For'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
assert resp.get_json()['data']['isdirectional'] is True
|
||||
|
||||
|
||||
def test_create_symmetric_type(client, db, auth_headers):
|
||||
resp = client.post('/api/assets/relationshiptypes',
|
||||
json={'relationshiptype': 'Dualpath',
|
||||
'isdirectional': False},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
assert resp.get_json()['data']['isdirectional'] is False
|
||||
|
||||
|
||||
def test_update_toggles_directional(client, db, auth_headers):
|
||||
typeid = client.post('/api/assets/relationshiptypes',
|
||||
json={'relationshiptype': 'USB'},
|
||||
headers=auth_headers).get_json()['data']['relationshiptypeid']
|
||||
resp = client.put(f'/api/assets/relationshiptypes/{typeid}',
|
||||
json={'isdirectional': False}, headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['isdirectional'] is False
|
||||
|
||||
|
||||
def test_list_includes_directional(client, db, auth_headers):
|
||||
client.post('/api/assets/relationshiptypes',
|
||||
json={'relationshiptype': 'WiFi', 'isdirectional': False},
|
||||
headers=auth_headers)
|
||||
resp = client.get('/api/assets/relationshiptypes')
|
||||
assert resp.status_code == 200
|
||||
row = next(t for t in resp.get_json()['data']
|
||||
if t['relationshiptype'] == 'WiFi')
|
||||
assert row['isdirectional'] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-asset relationships endpoint tags each row with isdirectional
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_relationships_row_carries_isdirectional(client, db, auth_headers):
|
||||
atype = AssetType(assettype='machine')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
|
||||
symmetric = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
||||
directional = RelationshipType(relationshiptype='controls', isdirectional=True)
|
||||
db.session.add_all([symmetric, directional])
|
||||
db.session.flush()
|
||||
|
||||
a = _make_asset('M-A', atype.assettypeid)
|
||||
b = _make_asset('M-B', atype.assettypeid)
|
||||
|
||||
# symmetric link stored in both directions (legacy shape)
|
||||
db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid,
|
||||
relationshiptypeid=symmetric.relationshiptypeid))
|
||||
db.session.add(AssetRelationship(sourceassetid=b.assetid, targetassetid=a.assetid,
|
||||
relationshiptypeid=symmetric.relationshiptypeid))
|
||||
# directional link a controls b
|
||||
db.session.add(AssetRelationship(sourceassetid=a.assetid, targetassetid=b.assetid,
|
||||
relationshiptypeid=directional.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/assets/{a.assetid}/relationships')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
|
||||
for row in data['outgoing']:
|
||||
assert 'isdirectional' in row
|
||||
for row in data['incoming']:
|
||||
assert 'isdirectional' in row
|
||||
|
||||
# symmetric type reports False both directions; directional reports True
|
||||
sym_rows = [r for r in data['outgoing'] + data['incoming']
|
||||
if r['relationshiptypename'] == 'Dualpath']
|
||||
assert sym_rows and all(r['isdirectional'] is False for r in sym_rows)
|
||||
ctrl_rows = [r for r in data['outgoing']
|
||||
if r['relationshiptypename'] == 'controls']
|
||||
assert ctrl_rows and all(r['isdirectional'] is True for r in ctrl_rows)
|
||||
295
tests/test_core/test_relationship_propagation.py
Normal file
295
tests/test_core/test_relationship_propagation.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Relationship propagation across symmetric rails (ADR-001, wired).
|
||||
|
||||
A Dualpath pair is one physical dual-bay machine with a single controller PC
|
||||
and a bay-selector switch, so BOTH bay records must carry the controls link to
|
||||
that PC. Creating PC controls bayA where bayA has a Dualpath partner bayB
|
||||
auto-creates PC controls bayB. The propagation set is M:N
|
||||
(relationshiptypepropagations): controls propagates through Dualpath
|
||||
(symmetric, consumed) and through partof (directional, declared but inert).
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import (
|
||||
RelationshipType,
|
||||
RelationshipTypePropagation,
|
||||
AssetRelationship,
|
||||
)
|
||||
|
||||
|
||||
def _make_asset(assetnumber, typeid):
|
||||
asset = Asset(assetnumber=assetnumber, assettypeid=typeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _setup_types():
|
||||
atype = AssetType(assettype='machine')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
|
||||
controls = RelationshipType(relationshiptype='controls', isdirectional=True)
|
||||
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
||||
partof = RelationshipType(relationshiptype='partof', isdirectional=True)
|
||||
db.session.add_all([controls, dualpath, partof])
|
||||
db.session.flush()
|
||||
return atype, controls, dualpath, partof
|
||||
|
||||
|
||||
def _rel_exists(sourceid, targetid, typeid):
|
||||
return AssetRelationship.query.filter_by(
|
||||
sourceassetid=sourceid, targetassetid=targetid, relationshiptypeid=typeid
|
||||
).first() is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Symmetric through-type propagates (controls fans across a Dualpath pair)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_controls_propagates_across_dualpath_pair(client, db, auth_headers):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
throughtypeid=dualpath.relationshiptypeid))
|
||||
|
||||
pc = _make_asset('PC-1', atype.assettypeid)
|
||||
baya = _make_asset('BAY-A', atype.assettypeid)
|
||||
bayb = _make_asset('BAY-B', atype.assettypeid)
|
||||
|
||||
# the two bays are Dualpath partners (one physical machine)
|
||||
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
|
||||
'sourceassetid': pc.assetid,
|
||||
'targetassetid': baya.assetid,
|
||||
'relationshiptypeid': controls.relationshiptypeid,
|
||||
})
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
assert resp.get_json()['data']['propagatedcount'] == 1
|
||||
|
||||
# PC now controls BOTH bays
|
||||
assert _rel_exists(pc.assetid, baya.assetid, controls.relationshiptypeid)
|
||||
assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
|
||||
|
||||
|
||||
def test_propagation_finds_partner_stored_either_direction(client, db, auth_headers):
|
||||
# Dualpath row stored partner->target; propagation must still catch it.
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
throughtypeid=dualpath.relationshiptypeid))
|
||||
|
||||
pc = _make_asset('PC-2', atype.assettypeid)
|
||||
baya = _make_asset('BAY-A2', atype.assettypeid)
|
||||
bayb = _make_asset('BAY-B2', atype.assettypeid)
|
||||
# note: bayb is the SOURCE of the Dualpath row, target is baya
|
||||
db.session.add(AssetRelationship(sourceassetid=bayb.assetid, targetassetid=baya.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
|
||||
'sourceassetid': pc.assetid,
|
||||
'targetassetid': baya.assetid,
|
||||
'relationshiptypeid': controls.relationshiptypeid,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
|
||||
|
||||
|
||||
def test_controls_propagates_when_bay_is_source(client, db, auth_headers):
|
||||
# Matches real dev data: controls is stored bay -> PC (source is the bay).
|
||||
# The bay's Dualpath partner must also get a controls -> PC row.
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
throughtypeid=dualpath.relationshiptypeid))
|
||||
|
||||
pc = _make_asset('PC-S', atype.assettypeid)
|
||||
baya = _make_asset('BAY-AS', atype.assettypeid)
|
||||
bayb = _make_asset('BAY-BS', atype.assettypeid)
|
||||
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
|
||||
'sourceassetid': baya.assetid,
|
||||
'targetassetid': pc.assetid,
|
||||
'relationshiptypeid': controls.relationshiptypeid,
|
||||
})
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
assert resp.get_json()['data']['propagatedcount'] == 1
|
||||
# partner bay now also controls the PC, same direction (bay -> PC)
|
||||
assert _rel_exists(bayb.assetid, pc.assetid, controls.relationshiptypeid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotent: pre-existing propagated row is not duplicated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_propagation_idempotent(client, db, auth_headers):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
throughtypeid=dualpath.relationshiptypeid))
|
||||
|
||||
pc = _make_asset('PC-3', atype.assettypeid)
|
||||
baya = _make_asset('BAY-A3', atype.assettypeid)
|
||||
bayb = _make_asset('BAY-B3', atype.assettypeid)
|
||||
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid))
|
||||
# the mirrored row already exists
|
||||
db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
|
||||
'sourceassetid': pc.assetid,
|
||||
'targetassetid': baya.assetid,
|
||||
'relationshiptypeid': controls.relationshiptypeid,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()['data']['propagatedcount'] == 0
|
||||
|
||||
count = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).count()
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-propagating type creates no extras
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_non_propagating_type_no_extras(client, db, auth_headers):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
# controls has NO propagation rows here
|
||||
|
||||
pc = _make_asset('PC-4', atype.assettypeid)
|
||||
baya = _make_asset('BAY-A4', atype.assettypeid)
|
||||
bayb = _make_asset('BAY-B4', atype.assettypeid)
|
||||
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
|
||||
'sourceassetid': pc.assetid,
|
||||
'targetassetid': baya.assetid,
|
||||
'relationshiptypeid': controls.relationshiptypeid,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()['data']['propagatedcount'] == 0
|
||||
assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directional through-type (partof) does NOT propagate (deferred)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_directional_through_type_does_not_propagate(client, db, auth_headers):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
# controls propagates only through partof, which is directional -> inert
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
throughtypeid=partof.relationshiptypeid))
|
||||
|
||||
pc = _make_asset('PC-5', atype.assettypeid)
|
||||
parent = _make_asset('PARENT-5', atype.assettypeid)
|
||||
child = _make_asset('CHILD-5', atype.assettypeid)
|
||||
db.session.add(AssetRelationship(sourceassetid=child.assetid, targetassetid=parent.assetid,
|
||||
relationshiptypeid=partof.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/assets/relationships', headers=auth_headers, json={
|
||||
'sourceassetid': pc.assetid,
|
||||
'targetassetid': parent.assetid,
|
||||
'relationshiptypeid': controls.relationshiptypeid,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()['data']['propagatedcount'] == 0
|
||||
assert not _rel_exists(pc.assetid, child.assetid, controls.relationshiptypeid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI backfill creates missing rows and is idempotent run twice
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_backfill_propagates_and_is_idempotent(client, db, auth_headers, runner):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
db.session.add(RelationshipTypePropagation(
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
throughtypeid=dualpath.relationshiptypeid))
|
||||
|
||||
pc = _make_asset('PC-6', atype.assettypeid)
|
||||
baya = _make_asset('BAY-A6', atype.assettypeid)
|
||||
bayb = _make_asset('BAY-B6', atype.assettypeid)
|
||||
db.session.add(AssetRelationship(sourceassetid=baya.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid))
|
||||
# legacy-import shape: controls exists only on the primary bay
|
||||
db.session.add(AssetRelationship(sourceassetid=pc.assetid, targetassetid=baya.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
assert not _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
|
||||
|
||||
result = runner.invoke(args=['relationships', 'propagate'])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert 'Propagated 1' in result.output
|
||||
assert _rel_exists(pc.assetid, bayb.assetid, controls.relationshiptypeid)
|
||||
|
||||
# second run creates nothing more
|
||||
result2 = runner.invoke(args=['relationships', 'propagate'])
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert 'Propagated 0' in result2.output
|
||||
count = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).count()
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fix-controls-direction flips reversed legacy rows to PC -> machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_fix_controls_direction(client, db, auth_headers, runner):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
ctype = AssetType(assettype='computer')
|
||||
db.session.add(ctype)
|
||||
db.session.flush()
|
||||
|
||||
pc = _make_asset('PC-7', ctype.assettypeid)
|
||||
pc2 = _make_asset('PC-8', ctype.assettypeid)
|
||||
bay = _make_asset('BAY-7', atype.assettypeid)
|
||||
bay2 = _make_asset('BAY-8', atype.assettypeid)
|
||||
|
||||
# reversed legacy row: machine -> PC
|
||||
db.session.add(AssetRelationship(sourceassetid=bay.assetid, targetassetid=pc.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
# reversed row whose flip already exists -> reversed one gets deactivated
|
||||
db.session.add(AssetRelationship(sourceassetid=bay2.assetid, targetassetid=pc2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.add(AssetRelationship(sourceassetid=pc2.assetid, targetassetid=bay2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
result = runner.invoke(args=['relationships', 'fix-controls-direction'])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert 'Flipped 1' in result.output
|
||||
assert '1 reversed duplicate(s) deactivated' in result.output
|
||||
|
||||
# first row now reads PC -> machine
|
||||
assert _rel_exists(pc.assetid, bay.assetid, controls.relationshiptypeid)
|
||||
assert not _rel_exists(bay.assetid, pc.assetid, controls.relationshiptypeid)
|
||||
# duplicate pair: canonical row stays active, reversed one deactivated
|
||||
rev = AssetRelationship.query.filter_by(
|
||||
sourceassetid=bay2.assetid, targetassetid=pc2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
assert rev is not None and rev.isactive is False
|
||||
|
||||
# idempotent: second run finds nothing
|
||||
result2 = runner.invoke(args=['relationships', 'fix-controls-direction'])
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert 'Flipped 0' in result2.output
|
||||
assert '0 reversed duplicate(s) deactivated' in result2.output
|
||||
55
tests/test_core/test_reports_hook.py
Normal file
55
tests/test_core/test_reports_hook.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for the get_reports hook consumer (GET /api/reports).
|
||||
|
||||
Pins the wiring added for BasePlugin.get_reports: the reports list merges
|
||||
enabled plugins' report cards after the static core reports and skips disabled
|
||||
ones. Also guards the removed legacy warranty-status core report.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
"""
|
||||
|
||||
|
||||
def _report_ids(client, headers):
|
||||
response = client.get('/api/reports', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
reports = response.get_json()['data']['reports']
|
||||
assert isinstance(reports, list)
|
||||
return reports, {r['id'] for r in reports}
|
||||
|
||||
|
||||
def test_reports_include_core_reports(client, auth_headers):
|
||||
"""The static core reports are always listed."""
|
||||
_, ids = _report_ids(client, auth_headers)
|
||||
assert 'machines-by-type' in ids
|
||||
assert 'pc-relationships' in ids
|
||||
|
||||
|
||||
def test_legacy_warranty_status_report_absent(client, auth_headers):
|
||||
"""The dead warranty-status core report was removed."""
|
||||
_, ids = _report_ids(client, auth_headers)
|
||||
assert 'warranty-status' not in ids
|
||||
|
||||
|
||||
def test_enabled_plugin_reports_are_merged(app, client, auth_headers, monkeypatch):
|
||||
"""Enabled plugins implementing get_reports contribute their cards."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
reports, ids = _report_ids(client, auth_headers)
|
||||
# warranty + printers implement get_reports
|
||||
assert 'warranty' in ids
|
||||
assert 'toner' in ids
|
||||
# merged cards carry their originating plugin name
|
||||
warranty_card = next(r for r in reports if r['id'] == 'warranty')
|
||||
assert warranty_card['plugin'] == 'warranty'
|
||||
assert warranty_card['route'] == '/reports/warranty'
|
||||
|
||||
|
||||
def test_disabled_plugin_reports_drop_out(app, client, auth_headers, monkeypatch):
|
||||
"""A disabled plugin's report cards disappear from the list."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty')
|
||||
|
||||
_, ids = _report_ids(client, auth_headers)
|
||||
assert 'warranty' not in ids
|
||||
assert 'toner' in ids # printers still enabled
|
||||
114
tests/test_core/test_search_customfields.py
Normal file
114
tests/test_core/test_search_customfields.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Global search matches custom-field VALUES for fields flagged searchable.
|
||||
|
||||
Pins: a searchable custom field's value routes to its asset; a non-searchable
|
||||
field is NOT matched; disabling the asset's search domain excludes the hit; an
|
||||
asset matched by BOTH its assetnumber and a custom field appears once; an
|
||||
inactive field is excluded.
|
||||
|
||||
Search by a distinctive value string so only the custom-field domain can match
|
||||
(the value is not any built-in identifier).
|
||||
"""
|
||||
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
|
||||
def _seed_computer(client, db, auth_headers, assetnumber):
|
||||
"""Create a computer asset and return its assetid."""
|
||||
from shopdb.core.models import AssetType, Asset
|
||||
if not AssetType.query.filter_by(assettype='computer').first():
|
||||
db.session.add(AssetType(assettype='computer', pluginname='computer',
|
||||
tablename='computer', description='c'))
|
||||
db.session.commit()
|
||||
resp = client.post('/api/computers',
|
||||
json={'assetnumber': assetnumber},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
asset = Asset.query.filter_by(assetnumber=assetnumber).first()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _seed_field(db, label, searchable=True, isactive=True):
|
||||
"""Create a computer-scoped custom field and return its fieldid."""
|
||||
from shopdb.core.models import AssetType, CustomField
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
field = CustomField(assettypeid=assettype.assettypeid,
|
||||
fieldkey=label.replace(' ', '').lower(),
|
||||
label=label, datatype='text',
|
||||
searchable=searchable, isactive=isactive)
|
||||
db.session.add(field)
|
||||
db.session.commit()
|
||||
return field.fieldid
|
||||
|
||||
|
||||
def _set_value(db, fieldid, assetid, value):
|
||||
from shopdb.core.models import CustomFieldValue
|
||||
db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _hits(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data']['results']
|
||||
|
||||
|
||||
def test_searchable_field_value_matches_and_routes(client, db, auth_headers):
|
||||
"""A searchable custom-field value matches and routes to the asset page."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF01')
|
||||
fieldid = _seed_field(db, 'Tool Crib Number', searchable=True)
|
||||
_set_value(db, fieldid, assetid, 'cribval01')
|
||||
|
||||
hits = [r for r in _hits(client, auth_headers, 'cribval01')
|
||||
if r['type'] == 'computer']
|
||||
assert len(hits) == 1
|
||||
assert hits[0]['url'].startswith('/pcs/')
|
||||
assert hits[0]['subtitle'] == 'Tool Crib Number'
|
||||
|
||||
|
||||
def test_non_searchable_field_value_does_not_match(client, db, auth_headers):
|
||||
"""A value on a NON-searchable field is not matched by global search."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF02')
|
||||
fieldid = _seed_field(db, 'Internal Note', searchable=False)
|
||||
_set_value(db, fieldid, assetid, 'cribval02')
|
||||
|
||||
assert _hits(client, auth_headers, 'cribval02') == []
|
||||
|
||||
|
||||
def test_disabled_domain_excludes_customfield_hit(client, db, auth_headers):
|
||||
"""search_computer_enabled=false hides a custom-field hit on a computer."""
|
||||
from shopdb.core.models import Setting
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF03')
|
||||
fieldid = _seed_field(db, 'Bench Tag', searchable=True)
|
||||
_set_value(db, fieldid, assetid, 'cribval03')
|
||||
|
||||
# Sanity: enabled by default.
|
||||
assert [r for r in _hits(client, auth_headers, 'cribval03')
|
||||
if r['type'] == 'computer']
|
||||
|
||||
Setting.set('search_computer_enabled', False, valuetype='boolean',
|
||||
category='search')
|
||||
invalidate_settings_cache()
|
||||
assert [r for r in _hits(client, auth_headers, 'cribval03')
|
||||
if r['type'] == 'computer'] == []
|
||||
|
||||
|
||||
def test_dedupe_assetnumber_and_customfield(client, db, auth_headers):
|
||||
"""An asset matched by both assetnumber and a custom field appears once."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'cribval04')
|
||||
fieldid = _seed_field(db, 'Crib Ref', searchable=True)
|
||||
_set_value(db, fieldid, assetid, 'cribval04')
|
||||
|
||||
hits = [r for r in _hits(client, auth_headers, 'cribval04')
|
||||
if r['type'] == 'computer']
|
||||
assert len(hits) == 1
|
||||
# The higher-relevance built-in assetnumber match wins the dedupe.
|
||||
assert hits[0]['relevance'] == 100
|
||||
|
||||
|
||||
def test_inactive_field_excluded(client, db, auth_headers):
|
||||
"""A value on an inactive (searchable) field is not matched."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF05')
|
||||
fieldid = _seed_field(db, 'Retired Tag', searchable=True, isactive=False)
|
||||
_set_value(db, fieldid, assetid, 'cribval05')
|
||||
|
||||
assert _hits(client, auth_headers, 'cribval05') == []
|
||||
43
tests/test_core/test_search_disabled.py
Normal file
43
tests/test_core/test_search_disabled.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Global search must exclude rows from disabled plugins (search.py _require_enabled).
|
||||
|
||||
Pins the data-exposure property: disabling a plugin removes its plugin-specific
|
||||
matches (e.g. hostname) from /api/search, mirroring the plugin being absent.
|
||||
|
||||
Search by HOSTNAME (distinct from assetnumber) so only the gated hostname domain
|
||||
can match - the core asset-number domain is intentionally always-on.
|
||||
"""
|
||||
|
||||
|
||||
def _seed_computer(client, db, auth_headers, assetnumber, hostname):
|
||||
from shopdb.core.models import AssetType
|
||||
if not AssetType.query.filter_by(assettype='computer').first():
|
||||
db.session.add(AssetType(assettype='computer', pluginname='computer',
|
||||
tablename='computer', description='c'))
|
||||
db.session.commit()
|
||||
resp = client.post('/api/computers',
|
||||
json={'assetnumber': assetnumber, 'hostname': hostname},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
|
||||
def _computer_hits(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
results = resp.get_json()['data']['results']
|
||||
return [r for r in results if r.get('type') == 'computer']
|
||||
|
||||
|
||||
def test_search_includes_enabled_plugin(app, client, db, auth_headers, monkeypatch):
|
||||
"""An enabled plugin's hostname matches appear in search."""
|
||||
_seed_computer(client, db, auth_headers, 'AST-SD01', 'hostsearch01')
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
assert _computer_hits(client, auth_headers, 'hostsearch01')
|
||||
|
||||
|
||||
def test_search_excludes_disabled_plugin(app, client, db, auth_headers, monkeypatch):
|
||||
"""A disabled plugin's hostname matches drop out of search results."""
|
||||
_seed_computer(client, db, auth_headers, 'AST-SD02', 'hostsearch02')
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers')
|
||||
assert _computer_hits(client, auth_headers, 'hostsearch02') == []
|
||||
105
tests/test_core/test_search_integrations.py
Normal file
105
tests/test_core/test_search_integrations.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Settings-driven search integrations (ServiceNow + employee-ID pattern).
|
||||
|
||||
The ServiceNow ticket prefixes, the ServiceNow search URL/enabled flag, and the
|
||||
employee-ID recognition pattern all ship as GE defaults but are configurable
|
||||
per-site via Settings. These tests pin that the search endpoint honors those
|
||||
settings and that a bad employeeid_pattern regex falls back rather than 500ing.
|
||||
|
||||
Settings are cached, so every case seeds its rows and calls
|
||||
invalidate_settings_cache() before exercising search.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
from shopdb.core.api.search import _get_search_integrations, _classify_query
|
||||
|
||||
|
||||
def _redirect(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data'].get('redirect')
|
||||
|
||||
|
||||
def test_default_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
"""With no override, a GE-prefixed ticket redirects to ServiceNow."""
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is not None
|
||||
assert redirect['type'] == 'servicenow'
|
||||
|
||||
|
||||
def test_custom_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
"""A site-configured prefix classifies its tickets as ServiceNow."""
|
||||
Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'ACMEINC123')
|
||||
assert redirect is not None
|
||||
assert redirect['type'] == 'servicenow'
|
||||
# Ticket is url-encoded into the {ticket} placeholder.
|
||||
assert 'ACMEINC123' in redirect['url']
|
||||
|
||||
|
||||
def test_custom_prefix_ignores_default_prefix(client, db, auth_headers):
|
||||
"""Overriding the prefixes drops the built-in GE prefixes."""
|
||||
Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
# GEINC is no longer a configured prefix, so no ServiceNow redirect.
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_disabled_servicenow_yields_no_redirect(client, db, auth_headers):
|
||||
"""servicenow_enabled=false suppresses the ServiceNow redirect entirely."""
|
||||
Setting.set('servicenow_enabled', False, valuetype='boolean',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_blank_search_url_yields_no_redirect(client, db, auth_headers):
|
||||
"""An empty servicenow_search_url disables the redirect."""
|
||||
Setting.set('servicenow_search_url', '', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_custom_employeeid_pattern_recognizes_six_digits(client, db, auth_headers):
|
||||
"""A ^\\d{6}$ pattern makes six-digit IDs classify as employee queries."""
|
||||
Setting.set('employeeid_pattern', r'^\d{6}$', valuetype='string',
|
||||
category='site')
|
||||
invalidate_settings_cache()
|
||||
integrations = _get_search_integrations()
|
||||
assert _classify_query('123456', integrations)['is_sso'] is True
|
||||
# Anchored pattern rejects the old nine-digit shape.
|
||||
assert _classify_query('123456789', integrations)['is_sso'] is False
|
||||
|
||||
|
||||
def test_default_employeeid_pattern_recognizes_nine_digits(client, db, auth_headers):
|
||||
"""The shipped default recognizes nine-digit SSO IDs."""
|
||||
invalidate_settings_cache()
|
||||
integrations = _get_search_integrations()
|
||||
assert _classify_query('123456789', integrations)['is_sso'] is True
|
||||
assert _classify_query('123456', integrations)['is_sso'] is False
|
||||
|
||||
|
||||
def test_invalid_employeeid_pattern_falls_back(client, db, auth_headers):
|
||||
"""A malformed regex falls back to nine-digit matching without erroring."""
|
||||
Setting.set('employeeid_pattern', '[', valuetype='string', category='site')
|
||||
invalidate_settings_cache()
|
||||
# Must not raise on compile of the bad pattern.
|
||||
integrations = _get_search_integrations()
|
||||
assert _classify_query('123456789', integrations)['is_sso'] is True
|
||||
assert _classify_query('12345', integrations)['is_sso'] is False
|
||||
|
||||
|
||||
def test_invalid_pattern_search_does_not_500(client, db, auth_headers):
|
||||
"""End-to-end: a bad employeeid_pattern must not break the search endpoint."""
|
||||
Setting.set('employeeid_pattern', '(', valuetype='string', category='site')
|
||||
invalidate_settings_cache()
|
||||
resp = client.get('/api/search?q=hello', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
44
tests/test_core/test_search_settings.py
Normal file
44
tests/test_core/test_search_settings.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Per-domain global-search toggles (search_<type>_enabled).
|
||||
|
||||
A content type can be hidden from search independently of whether its plugin is
|
||||
enabled, by setting search_<type>_enabled = false.
|
||||
"""
|
||||
|
||||
from shopdb.core.api.settings import SEARCH_DOMAINS
|
||||
|
||||
|
||||
def _kb_types(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return [r for r in resp.get_json()['data']['results']
|
||||
if r.get('type') == 'knowledgebase']
|
||||
|
||||
|
||||
def test_domain_included_by_default(client, db, auth_headers):
|
||||
"""With no toggle set, the knowledgebase domain appears in search."""
|
||||
client.post('/api/knowledgebase',
|
||||
json={'shortdescription': 'searchtoggle alpha',
|
||||
'linkurl': 'https://kb.example/a'},
|
||||
headers=auth_headers)
|
||||
assert _kb_types(client, auth_headers, 'searchtoggle')
|
||||
|
||||
|
||||
def test_disabled_domain_excluded(client, db, auth_headers):
|
||||
"""search_knowledgebase_enabled=false hides KB results."""
|
||||
from shopdb.core.models import Setting
|
||||
client.post('/api/knowledgebase',
|
||||
json={'shortdescription': 'searchtoggle beta',
|
||||
'linkurl': 'https://kb.example/b'},
|
||||
headers=auth_headers)
|
||||
Setting.set('search_knowledgebase_enabled', False, valuetype='boolean',
|
||||
category='search')
|
||||
assert _kb_types(client, auth_headers, 'searchtoggle') == []
|
||||
|
||||
|
||||
def test_search_seed_creates_domain_keys(client, db, auth_headers):
|
||||
"""Seeding produces one boolean setting per search domain."""
|
||||
resp = client.post('/api/settings/seed', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
from shopdb.core.models import Setting
|
||||
keys = {s.key for s in Setting.query.filter_by(category='search').all()}
|
||||
assert {f'search_{k}_enabled' for k in SEARCH_DOMAINS} <= keys
|
||||
56
tests/test_core/test_setting_model.py
Normal file
56
tests/test_core/test_setting_model.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for the Setting key-value model, incl. the create-race recovery."""
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
def test_set_creates_then_updates(db):
|
||||
"""set() creates a missing key, then updates the existing row in place."""
|
||||
created = Setting.set('greeting', 'hello', category='site')
|
||||
assert created.settingid is not None
|
||||
assert Setting.get('greeting') == 'hello'
|
||||
|
||||
Setting.set('greeting', 'goodbye')
|
||||
assert Setting.get('greeting') == 'goodbye'
|
||||
|
||||
# Only one row for the key (update, not a second insert).
|
||||
assert Setting.query.filter_by(key='greeting').count() == 1
|
||||
|
||||
|
||||
def test_set_boolean_roundtrip(db):
|
||||
"""Booleans store as canonical strings and read back typed."""
|
||||
Setting.set('flag', True, valuetype='boolean')
|
||||
assert Setting.get('flag') is True
|
||||
Setting.set('flag', False)
|
||||
assert Setting.get('flag') is False
|
||||
|
||||
|
||||
def test_set_recovers_from_create_race(db, monkeypatch):
|
||||
"""A concurrent insert of the same key makes our commit raise IntegrityError;
|
||||
set() rolls back, re-fetches the winner's row, and applies our value."""
|
||||
real_commit = _db.session.commit
|
||||
calls = {'n': 0}
|
||||
|
||||
def flaky_commit():
|
||||
calls['n'] += 1
|
||||
if calls['n'] == 1:
|
||||
# Drop our own pending insert, then persist the "winner" row exactly
|
||||
# as a racing transaction would have, and simulate the unique-key
|
||||
# violation our losing INSERT would raise.
|
||||
_db.session.rollback()
|
||||
winner = Setting(key='racekey', value='winner', valuetype='string')
|
||||
_db.session.add(winner)
|
||||
real_commit()
|
||||
raise IntegrityError('INSERT INTO settings', {}, Exception('duplicate key'))
|
||||
return real_commit()
|
||||
|
||||
monkeypatch.setattr(_db.session, 'commit', flaky_commit)
|
||||
|
||||
result = Setting.set('racekey', 'mine')
|
||||
|
||||
# Recovery path ran: the row exists once and carries our value.
|
||||
assert result is not None
|
||||
assert Setting.get('racekey') == 'mine'
|
||||
assert Setting.query.filter_by(key='racekey').count() == 1
|
||||
192
tests/test_core/test_settings_branding.py
Normal file
192
tests/test_core/test_settings_branding.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests for branding-logo upload and settings-secret masking.
|
||||
|
||||
Covers WP1 of the multi-site distribution work: the branding-logo upload
|
||||
endpoint (admin-only, kind/extension validation, round-trip), the public
|
||||
serve route, the secret-masking guarantee on the settings list, and the
|
||||
canonical new-settings defaults.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from shopdb.core.api.settings import (
|
||||
build_default_settings,
|
||||
BRANDING_KIND_SETTINGS,
|
||||
)
|
||||
|
||||
|
||||
def _defaults_by_key():
|
||||
return {d['key']: d for d in build_default_settings()}
|
||||
|
||||
|
||||
def test_anon_settings_list_excludes_nonpublic(client, db):
|
||||
"""Anonymous GET /api/settings returns only the public allowlist - secret
|
||||
AND non-secret internal keys are absent, so plaintext can never leak."""
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
Setting.set('smtp_password', 'supersecret', valuetype='string', category='email')
|
||||
Setting.set('zabbix_token', 'tok-abc-123', valuetype='string', category='integrations')
|
||||
Setting.set('warranty_dell_clientsecret', 'shh', valuetype='string', category='integrations')
|
||||
# A public key stays visible for contrast.
|
||||
Setting.set('facility_name', 'Test Plant', valuetype='string', category='site')
|
||||
|
||||
resp = client.get('/api/settings')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
by_key = {s['key']: s['value'] for s in resp.get_json()['data']}
|
||||
|
||||
# Non-public keys are not enumerable at all (stronger than masking).
|
||||
assert 'smtp_password' not in by_key
|
||||
assert 'zabbix_token' not in by_key
|
||||
assert 'warranty_dell_clientsecret' not in by_key
|
||||
# Plaintext secrets must not leak anywhere in the response body.
|
||||
assert 'supersecret' not in resp.get_data(as_text=True)
|
||||
assert 'tok-abc-123' not in resp.get_data(as_text=True)
|
||||
# A public key passes through untouched.
|
||||
assert by_key['facility_name'] == 'Test Plant'
|
||||
|
||||
|
||||
def test_branding_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload a branding logo."""
|
||||
data = {
|
||||
'kind': 'site',
|
||||
'file': (io.BytesIO(b'<svg/>'), 'logo.svg'),
|
||||
}
|
||||
resp = client.post('/api/settings/branding-logo', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_branding_upload_requires_auth(client, db):
|
||||
"""No token at all cannot reach the upload route."""
|
||||
data = {'kind': 'site', 'file': (io.BytesIO(b'<svg/>'), 'logo.svg')}
|
||||
resp = client.post('/api/settings/branding-logo', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert resp.status_code in (401, 422)
|
||||
|
||||
|
||||
def test_branding_upload_round_trip(client, db, auth_headers):
|
||||
"""Admin upload writes the matching setting and the file is then served."""
|
||||
payload = b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
data = {'kind': 'site', 'file': (io.BytesIO(payload), 'mylogo.svg')}
|
||||
resp = client.post('/api/settings/branding-logo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
body = resp.get_json()['data']
|
||||
assert body['key'] == 'site_logo'
|
||||
assert body['value'] == '/api/settings/branding/logo-site.svg'
|
||||
|
||||
# Setting row now points at the served URL.
|
||||
from shopdb.core.models import Setting
|
||||
setting = Setting.query.filter_by(key='site_logo').first()
|
||||
assert setting is not None
|
||||
assert setting.value == '/api/settings/branding/logo-site.svg'
|
||||
|
||||
# Public serve route returns the uploaded bytes without auth.
|
||||
served = client.get('/api/settings/branding/logo-site.svg')
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_branding_upload_favicon_allows_ico(client, db, auth_headers):
|
||||
"""The favicon kind accepts .ico (extra extension beyond map images)."""
|
||||
data = {'kind': 'favicon', 'file': (io.BytesIO(b'icodata'), 'fav.ico')}
|
||||
resp = client.post('/api/settings/branding-logo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['key'] == 'site_favicon'
|
||||
|
||||
|
||||
def test_branding_upload_rejects_invalid_kind(client, db, auth_headers):
|
||||
"""An unknown kind is a validation error."""
|
||||
data = {'kind': 'banner', 'file': (io.BytesIO(b'<svg/>'), 'logo.svg')}
|
||||
resp = client.post('/api/settings/branding-logo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_branding_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
data = {'kind': 'site', 'file': (io.BytesIO(b'MZ...'), 'logo.exe')}
|
||||
resp = client.post('/api/settings/branding-logo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_kind_map_covers_expected_kinds():
|
||||
"""The kind->setting map matches the documented branding kinds."""
|
||||
assert BRANDING_KIND_SETTINGS == {
|
||||
'site': 'site_logo',
|
||||
'qr': 'qr_logo',
|
||||
'badge': 'badge_logo',
|
||||
'favicon': 'site_favicon',
|
||||
}
|
||||
|
||||
|
||||
def test_defaults_contain_new_branding_keys():
|
||||
"""build_default_settings seeds every branding key with its default."""
|
||||
by_key = _defaults_by_key()
|
||||
expected = {
|
||||
'site_logo': '/ge-aerospace-logo.svg',
|
||||
'qr_logo': '/ge-monogram.svg',
|
||||
'badge_logo': '/ge-aerospace-logo.svg',
|
||||
'site_favicon': '',
|
||||
'brand_primary_color': '',
|
||||
}
|
||||
for key, value in expected.items():
|
||||
assert key in by_key, f'missing default {key}'
|
||||
assert by_key[key]['value'] == value
|
||||
assert by_key[key]['category'] == 'branding'
|
||||
|
||||
|
||||
def test_defaults_contain_new_integration_keys():
|
||||
"""build_default_settings seeds the ServiceNow integration keys."""
|
||||
by_key = _defaults_by_key()
|
||||
assert by_key['servicenow_enabled']['value'] == 'true'
|
||||
assert by_key['servicenow_enabled']['category'] == 'integrations'
|
||||
assert by_key['servicenow_ticket_prefixes']['value'] == 'GEINC,GECHG,GERIT,GESCT'
|
||||
assert '{ticket}' in by_key['servicenow_search_url']['value']
|
||||
assert '{ticket}' in by_key['servicenow_incident_url']['value']
|
||||
assert '{ticket}' in by_key['servicenow_change_url']['value']
|
||||
|
||||
|
||||
def test_defaults_contain_new_site_keys():
|
||||
"""build_default_settings seeds the employee-id and printer-hostname keys."""
|
||||
by_key = _defaults_by_key()
|
||||
assert by_key['employeeid_pattern']['value'] == r'^\d{9}$'
|
||||
assert by_key['printer_hostname_template']['value'] == 'Printer-{ip}.printer.geaerospace.net'
|
||||
assert by_key['contact_email_domain']['value'] == 'geaerospace.com'
|
||||
assert by_key['contact_email_domain']['category'] == 'site'
|
||||
|
||||
|
||||
def test_defaults_contain_label_target_keys():
|
||||
"""build_default_settings seeds the per-type label targets + label defaults."""
|
||||
by_key = _defaults_by_key()
|
||||
for key in ('qr_target_machine', 'qr_target_computer',
|
||||
'qr_target_network_device', 'qr_target_measuring_tool'):
|
||||
assert key in by_key, f'missing default {key}'
|
||||
assert by_key[key]['value'] == ''
|
||||
assert by_key[key]['category'] == 'printing'
|
||||
assert '{locationcode}' in by_key['qr_target_measuring_tool']['description']
|
||||
assert by_key['label_default_style']['value'] == 'card'
|
||||
assert by_key['label_default_codetype']['value'] == 'qr'
|
||||
assert by_key['label_default_style']['category'] == 'printing'
|
||||
assert by_key['label_default_codetype']['category'] == 'printing'
|
||||
# Per-type default encode mode: machines -> their machine number,
|
||||
# measuring tools -> inspection location, the rest -> asset page.
|
||||
assert by_key['label_default_encodes_machine']['value'] == 'assetnumber'
|
||||
assert by_key['label_default_encodes_measuring_tool']['value'] == 'location'
|
||||
for assettype in ('computer', 'printer', 'network_device'):
|
||||
assert by_key[f'label_default_encodes_{assettype}']['value'] == 'assetpage'
|
||||
assert by_key[f'label_default_encodes_{assettype}']['category'] == 'printing'
|
||||
|
||||
|
||||
def test_defaults_changed_facility_and_map():
|
||||
"""facility_name default is now blank; map blueprints point at the placeholder."""
|
||||
by_key = _defaults_by_key()
|
||||
assert by_key['facility_name']['value'] == ''
|
||||
assert by_key['map_blueprint_light']['value'] == '/static/images/floorplan-placeholder.svg'
|
||||
assert by_key['map_blueprint_dark']['value'] == '/static/images/floorplan-placeholder.svg'
|
||||
# No leftover West Jefferson sitemap references.
|
||||
assert 'sitemap2025' not in by_key['map_blueprint_light']['value']
|
||||
25
tests/test_core/test_slides.py
Normal file
25
tests/test_core/test_slides.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Characterization test for the slides (TV slideshow) plugin feed.
|
||||
|
||||
The slide manager rework moved the public playlist to /api/slides/feed with a
|
||||
flat (non-enveloped) response shape so the screensaver's existing parser needs
|
||||
no change. Pin that contract here.
|
||||
"""
|
||||
|
||||
|
||||
def test_slides_feed_shape(client, db):
|
||||
"""GET /api/slides/feed returns a flat playlist for a surface (no auth)."""
|
||||
resp = client.get('/api/slides/feed?surface=lobby')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['success'] is True
|
||||
assert data['surface'] == 'lobby'
|
||||
assert isinstance(data['slides'], list)
|
||||
assert data['basepath'] == '/api/slides/img/lobby/'
|
||||
assert 'interval' in data
|
||||
|
||||
|
||||
def test_slides_feed_unknown_surface_falls_back(client, db):
|
||||
"""An invalid surface name falls back to lobby instead of erroring."""
|
||||
resp = client.get('/api/slides/feed?surface=../etc')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['surface'] == 'lobby'
|
||||
234
tests/test_core/test_supportteams.py
Normal file
234
tests/test_core/test_supportteams.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Support teams + contacts: team/contact CRUD, lookup, delete-guard, import.
|
||||
|
||||
The application/support-team wiring: a team owns ordered contacts, an
|
||||
application carries one team, and its payload flattens the team name/url +
|
||||
active contacts so the frontend needs a single call.
|
||||
"""
|
||||
|
||||
IMPORT_HEADER = {'X-Import-Mode': 'true'}
|
||||
LEGACY_CREATED = '2020-01-05 08:30:00'
|
||||
LEGACY_MODIFIED = '2021-06-07T14:15:16'
|
||||
|
||||
|
||||
def _import_headers(auth_headers):
|
||||
merged = dict(auth_headers)
|
||||
merged.update(IMPORT_HEADER)
|
||||
return merged
|
||||
|
||||
|
||||
def _create_team(client, auth_headers, teamname='Controls', teamurl=None):
|
||||
return client.post('/api/supportteams',
|
||||
json={'teamname': teamname, 'teamurl': teamurl},
|
||||
headers=auth_headers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_and_get_team(client, db, auth_headers):
|
||||
"""Create a team, then fetch it back with an (empty) contacts list."""
|
||||
resp = _create_team(client, auth_headers, 'Controls',
|
||||
'https://servicenow.example/group/controls')
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
teamid = resp.get_json()['data']['supportteamid']
|
||||
|
||||
got = client.get(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert got.status_code == 200
|
||||
data = got.get_json()['data']
|
||||
assert data['teamname'] == 'Controls'
|
||||
assert data['teamurl'] == 'https://servicenow.example/group/controls'
|
||||
assert data['contacts'] == []
|
||||
|
||||
|
||||
def test_create_team_requires_teamname(client, db, auth_headers):
|
||||
resp = client.post('/api/supportteams', json={}, headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_team_duplicate_conflict(client, db, auth_headers):
|
||||
_create_team(client, auth_headers, 'Controls')
|
||||
dup = _create_team(client, auth_headers, 'Controls')
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_update_team(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
resp = client.put(f'/api/supportteams/{teamid}',
|
||||
json={'teamname': 'Controls Renamed',
|
||||
'teamurl': 'https://x.example'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['teamname'] == 'Controls Renamed'
|
||||
|
||||
|
||||
def test_delete_team(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'ToDelete').get_json()['data']['supportteamid']
|
||||
resp = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
gone = client.get(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert gone.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# teamname exact-match lookup (import recipe) + active filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_teamname_lookup_filter(client, db, auth_headers):
|
||||
for name in ('Controls', 'ControlsB', 'Networking'):
|
||||
_create_team(client, auth_headers, name)
|
||||
listed = client.get('/api/supportteams?teamname=Controls', headers=auth_headers)
|
||||
assert listed.status_code == 200
|
||||
rows = listed.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['teamname'] == 'Controls'
|
||||
|
||||
|
||||
def test_list_active_filter_hides_inactive(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Retired').get_json()['data']['supportteamid']
|
||||
client.put(f'/api/supportteams/{teamid}', json={'isactive': False},
|
||||
headers=auth_headers)
|
||||
active = client.get('/api/supportteams', headers=auth_headers)
|
||||
assert all(t['teamname'] != 'Retired' for t in active.get_json()['data'])
|
||||
allteams = client.get('/api/supportteams?active=false', headers=auth_headers)
|
||||
assert any(t['teamname'] == 'Retired' for t in allteams.get_json()['data'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete-with-applications 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_team_with_applications_conflicts(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'InUse').get_json()['data']['supportteamid']
|
||||
appresp = client.post('/api/applications',
|
||||
json={'appname': 'DependentApp',
|
||||
'supportteamid': teamid},
|
||||
headers=auth_headers)
|
||||
assert appresp.status_code == 201, appresp.get_json()
|
||||
|
||||
conflict = client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert conflict.status_code == 409
|
||||
# Still there.
|
||||
assert client.get(f'/api/supportteams/{teamid}',
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested contact CRUD + ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_contact_crud_and_ordering(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
|
||||
# Add two contacts out of sort order.
|
||||
c2 = client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'Second', 'sso': '222', 'sortorder': 2},
|
||||
headers=auth_headers)
|
||||
assert c2.status_code == 201, c2.get_json()
|
||||
c1 = client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'First', 'sso': '111', 'sortorder': 1},
|
||||
headers=auth_headers)
|
||||
assert c1.status_code == 201
|
||||
|
||||
# Team now nests active contacts in sortorder.
|
||||
team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data']
|
||||
names = [c['name'] for c in team['contacts']]
|
||||
assert names == ['First', 'Second']
|
||||
|
||||
# Update one contact.
|
||||
contactid = c1.get_json()['data']['contactid']
|
||||
upd = client.put(f'/api/supportteams/{teamid}/contacts/{contactid}',
|
||||
json={'name': 'First Updated'}, headers=auth_headers)
|
||||
assert upd.status_code == 200
|
||||
assert upd.get_json()['data']['name'] == 'First Updated'
|
||||
|
||||
# Delete the other contact.
|
||||
otherid = c2.get_json()['data']['contactid']
|
||||
dele = client.delete(f'/api/supportteams/{teamid}/contacts/{otherid}',
|
||||
headers=auth_headers)
|
||||
assert dele.status_code == 200
|
||||
team = client.get(f'/api/supportteams/{teamid}', headers=auth_headers).get_json()['data']
|
||||
assert [c['name'] for c in team['contacts']] == ['First Updated']
|
||||
|
||||
|
||||
def test_contact_requires_name(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
resp = client.post(f'/api/supportteams/{teamid}/contacts', json={},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_delete_team_cascades_contacts(client, db, auth_headers):
|
||||
from shopdb.core.models import SupportTeamContact
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'Someone'}, headers=auth_headers)
|
||||
client.delete(f'/api/supportteams/{teamid}', headers=auth_headers)
|
||||
assert SupportTeamContact.query.filter_by(supportteamid=teamid).count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application carries the team + flattened contacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_application_carries_supportteam_payload(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls',
|
||||
'https://sn.example/controls').get_json()['data']['supportteamid']
|
||||
client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'Alice', 'sso': 'a01', 'sortorder': 0},
|
||||
headers=auth_headers)
|
||||
|
||||
appresp = client.post('/api/applications',
|
||||
json={'appname': 'TeamApp', 'supportteamid': teamid},
|
||||
headers=auth_headers)
|
||||
appid = appresp.get_json()['data']['appid']
|
||||
|
||||
got = client.get(f'/api/applications/{appid}', headers=auth_headers)
|
||||
data = got.get_json()['data']
|
||||
assert data['supportteamid'] == teamid
|
||||
assert data['supportteamname'] == 'Controls'
|
||||
assert data['teamurl'] == 'https://sn.example/controls'
|
||||
assert data['contacts'] == [{'name': 'Alice', 'sso': 'a01'}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import-mode timestamps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_import_mode_preserves_team_timestamps(client, db, auth_headers):
|
||||
resp = client.post('/api/supportteams',
|
||||
json={'teamname': 'Legacy',
|
||||
'createddate': LEGACY_CREATED,
|
||||
'modifieddate': LEGACY_MODIFIED},
|
||||
headers=_import_headers(auth_headers))
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
from shopdb.core.models import SupportTeam
|
||||
team = SupportTeam.query.filter_by(teamname='Legacy').first()
|
||||
assert team.createddate.year == 2020 and team.createddate.month == 1
|
||||
assert team.modifieddate.year == 2021
|
||||
|
||||
|
||||
def test_import_mode_preserves_contact_timestamps(client, db, auth_headers):
|
||||
teamid = _create_team(client, auth_headers, 'Controls').get_json()['data']['supportteamid']
|
||||
resp = client.post(f'/api/supportteams/{teamid}/contacts',
|
||||
json={'name': 'LegacyOwner',
|
||||
'createddate': LEGACY_CREATED,
|
||||
'modifieddate': LEGACY_MODIFIED},
|
||||
headers=_import_headers(auth_headers))
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
from shopdb.core.models import SupportTeamContact
|
||||
contact = SupportTeamContact.query.filter_by(name='LegacyOwner').first()
|
||||
assert contact.createddate.year == 2020
|
||||
assert contact.modifieddate.year == 2021
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authz (belt-and-suspenders; the sweep in test_authz also covers these)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_member_cannot_create_team(client, db, member_headers):
|
||||
resp = client.post('/api/supportteams', json={'teamname': 'X'},
|
||||
headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
309
tests/test_plugin_contract.py
Normal file
309
tests/test_plugin_contract.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""Plugin contract compliance tests.
|
||||
|
||||
Asserts every bundled plugin satisfies the contract surface declared in
|
||||
ADR-001 and the BasePlugin ABC. Run on every change to BasePlugin or any
|
||||
plugin's plugin.py / manifest.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Blueprint
|
||||
|
||||
from shopdb import __contract_version__
|
||||
from shopdb.plugins import plugin_manager
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
|
||||
BUNDLED_PLUGINS = ('computers', 'employees', 'geenforce', 'knowledgebase', 'machines', 'measuringtools', 'network', 'notifications', 'printers', 'slides', 'usb', 'warranty')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_classes(app):
|
||||
"""Load each bundled plugin's class without instantiating in app context."""
|
||||
classes = {}
|
||||
with app.app_context():
|
||||
loader = plugin_manager.loader
|
||||
for name in BUNDLED_PLUGINS:
|
||||
cls = loader.load_plugin_class(name)
|
||||
assert cls is not None, f'Plugin class for {name} could not be loaded'
|
||||
classes[name] = cls
|
||||
return classes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_instances(app, plugin_classes):
|
||||
"""Instantiate each bundled plugin in app context."""
|
||||
return {name: cls() for name, cls in plugin_classes.items()}
|
||||
|
||||
|
||||
def test_contract_version_is_set():
|
||||
"""Framework declares a contract version."""
|
||||
assert __contract_version__
|
||||
parts = __contract_version__.split('.')
|
||||
assert len(parts) == 3, f'Expected semver X.Y.Z, got {__contract_version__}'
|
||||
for part in parts:
|
||||
assert part.isdigit(), f'Non-numeric semver part in {__contract_version__}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_class_subclasses_baseplugin(plugin_classes, name):
|
||||
"""Each plugin class extends BasePlugin."""
|
||||
cls = plugin_classes[name]
|
||||
assert issubclass(cls, BasePlugin), f'{name} does not subclass BasePlugin'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_manifest_has_required_fields(name):
|
||||
"""Each plugin's manifest.json declares the required fields."""
|
||||
manifestpath = Path('plugins') / name / 'manifest.json'
|
||||
assert manifestpath.exists(), f'{name} is missing manifest.json'
|
||||
|
||||
manifest = json.loads(manifestpath.read_text())
|
||||
|
||||
assert manifest.get('name') == name
|
||||
assert manifest.get('version'), f'{name} manifest missing version'
|
||||
assert manifest.get('description'), f'{name} manifest missing description'
|
||||
assert manifest.get('core_version'), f'{name} manifest missing core_version'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_meta_returns_pluginmeta(plugin_instances, name):
|
||||
"""plugin.meta returns a PluginMeta instance with sane fields."""
|
||||
plugin = plugin_instances[name]
|
||||
meta = plugin.meta
|
||||
assert isinstance(meta, PluginMeta)
|
||||
assert meta.name == name
|
||||
assert meta.version
|
||||
assert meta.api_prefix and meta.api_prefix.startswith('/api/')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_blueprint_returns_blueprint_or_none(plugin_instances, name):
|
||||
"""get_blueprint returns a Flask Blueprint or None."""
|
||||
plugin = plugin_instances[name]
|
||||
bp = plugin.get_blueprint()
|
||||
assert bp is None or isinstance(bp, Blueprint), (
|
||||
f'{name}.get_blueprint returned {type(bp).__name__}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_models_returns_list(plugin_instances, name):
|
||||
"""get_models returns a list of model classes."""
|
||||
plugin = plugin_instances[name]
|
||||
models = plugin.get_models()
|
||||
assert isinstance(models, list), f'{name}.get_models did not return a list'
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__'), (
|
||||
f'{name}.get_models returned {model} without __tablename__'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_collector_schema_returns_dict_or_none(plugin_instances, name):
|
||||
"""get_collector_schema returns None or a dict with identityfield + fields."""
|
||||
plugin = plugin_instances[name]
|
||||
schema = plugin.get_collector_schema()
|
||||
if schema is None:
|
||||
return
|
||||
assert isinstance(schema, dict)
|
||||
assert 'identityfield' in schema, (
|
||||
f'{name}.get_collector_schema must declare identityfield'
|
||||
)
|
||||
assert 'fields' in schema, (
|
||||
f'{name}.get_collector_schema must declare fields'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_navigation_items_is_iterable(plugin_instances, name):
|
||||
"""get_navigation_items returns an iterable (default empty list)."""
|
||||
plugin = plugin_instances[name]
|
||||
items = plugin.get_navigation_items()
|
||||
assert isinstance(items, list)
|
||||
|
||||
|
||||
def test_baseplugin_has_no_searchable_fields_hook():
|
||||
"""get_searchable_fields was removed: global search is a core concern over
|
||||
the asset model, and no plugin ever implemented the hook (contract 0.4.0)."""
|
||||
assert not hasattr(BasePlugin, 'get_searchable_fields')
|
||||
|
||||
|
||||
def test_baseplugin_has_dashboard_widgets_hook():
|
||||
"""The dashboard widgets hook is on the contract surface and consumed."""
|
||||
assert hasattr(BasePlugin, 'get_dashboard_widgets')
|
||||
|
||||
|
||||
def test_baseplugin_has_reports_hook():
|
||||
"""The reports hook is on the contract surface (contract 0.6.0)."""
|
||||
assert hasattr(BasePlugin, 'get_reports')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_reports_is_iterable(plugin_instances, name):
|
||||
"""get_reports returns a list (default empty)."""
|
||||
plugin = plugin_instances[name]
|
||||
reports = plugin.get_reports()
|
||||
assert isinstance(reports, list)
|
||||
# Each card carries an id/name/category plus exactly one of route/endpoint.
|
||||
for card in reports:
|
||||
assert card.get('id') and card.get('name') and card.get('category')
|
||||
assert ('route' in card) ^ ('endpoint' in card), (
|
||||
f'{name} report card must have exactly one of route/endpoint'
|
||||
)
|
||||
|
||||
|
||||
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',
|
||||
'get_map_overlays', 'get_asset_presentation'):
|
||||
assert hasattr(BasePlugin, hook), f'{hook} missing from BasePlugin'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_frontend_hooks_return_lists(plugin_instances, name):
|
||||
"""The four ADR-010 hooks default to a list on every bundled plugin."""
|
||||
plugin = plugin_instances[name]
|
||||
assert isinstance(plugin.get_settings_cards(), list)
|
||||
assert isinstance(plugin.get_asset_panels(), list)
|
||||
assert isinstance(plugin.get_map_overlays(), list)
|
||||
assert isinstance(plugin.get_asset_presentation(), list)
|
||||
|
||||
|
||||
def test_get_services_hook_has_consumer(app):
|
||||
"""get_services is consumed by plugin_manager.get_service (no dead hook)."""
|
||||
with app.app_context():
|
||||
pm = app.extensions['plugin_manager']
|
||||
assert hasattr(pm, 'get_service')
|
||||
# Unknown service name resolves to None, not an error.
|
||||
assert pm.get_service('definitely-not-a-real-service') is None
|
||||
|
||||
|
||||
def test_baseplugin_does_not_have_event_handlers_hook():
|
||||
"""The removed get_event_handlers hook must not be on BasePlugin."""
|
||||
assert not hasattr(BasePlugin, 'get_event_handlers'), (
|
||||
'get_event_handlers was removed for v1. See ADR-001 contract surface.'
|
||||
)
|
||||
|
||||
|
||||
def test_baseplugin_has_collector_schema_hook():
|
||||
"""The collector schema hook is on the contract surface."""
|
||||
assert hasattr(BasePlugin, 'get_collector_schema')
|
||||
|
||||
|
||||
def test_baseplugin_has_apply_collector_payload_hook():
|
||||
"""The collector upsert hook is on the contract surface (ADR-006)."""
|
||||
assert hasattr(BasePlugin, 'apply_collector_payload')
|
||||
|
||||
|
||||
def test_schema_declaring_plugins_implement_apply(plugin_instances):
|
||||
"""Any plugin returning a collector schema must implement the upsert hook."""
|
||||
for name, plugin in plugin_instances.items():
|
||||
if plugin.get_collector_schema() is not None:
|
||||
overridden = type(plugin).apply_collector_payload \
|
||||
is not BasePlugin.apply_collector_payload
|
||||
assert overridden, (
|
||||
f'Plugin {name} declares a collector schema but does not '
|
||||
f'override apply_collector_payload'
|
||||
)
|
||||
|
||||
|
||||
# Imports a plugin may make from the core. shopdb.api is the contract surface;
|
||||
# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*,
|
||||
# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001.
|
||||
ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base')
|
||||
|
||||
_PLUGIN_IMPORT_RE = re.compile(
|
||||
r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def _plugin_source_files():
|
||||
root = Path(__file__).resolve().parent.parent / 'plugins'
|
||||
# Skip migrations/ - Alembic env.py is framework glue that legitimately
|
||||
# calls the shared runner in shopdb.plugins.alembic_template (see ADR-008),
|
||||
# not plugin domain code. Mirrors the style check excluding versions/.
|
||||
# Resolve each plugin dir before rglob: rglob does not descend symlinked
|
||||
# directories, and external plugins arrive as symlinks per ADR-003.
|
||||
files = [p for p in root.glob('*.py')]
|
||||
for plugindir in sorted(root.iterdir()):
|
||||
if not plugindir.is_dir():
|
||||
continue
|
||||
for path in plugindir.resolve().rglob('*.py'):
|
||||
if '__pycache__' in path.parts or 'migrations' in path.parts:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def test_plugins_only_import_contract_surface():
|
||||
"""Plugins must import core code only via shopdb.api / shopdb.plugins.base."""
|
||||
violations = []
|
||||
for path in _plugin_source_files():
|
||||
text = path.read_text()
|
||||
for match in _PLUGIN_IMPORT_RE.finditer(text):
|
||||
module = match.group(1) or match.group(2)
|
||||
if not module.startswith('shopdb'):
|
||||
continue
|
||||
if any(module == a or module.startswith(a + '.')
|
||||
for a in ALLOWED_CORE_IMPORTS):
|
||||
continue
|
||||
line = text[:match.start()].count('\n') + 1
|
||||
violations.append(f'{path.name}:{line} imports {module}')
|
||||
assert not violations, (
|
||||
'Plugins must import core only via shopdb.api or shopdb.plugins.base. '
|
||||
'Violations:\n' + '\n'.join(violations)
|
||||
)
|
||||
|
||||
|
||||
def test_import_scan_covers_symlinked_plugins(tmp_path):
|
||||
"""External plugins symlinked into plugins/ ARE scanned for violations.
|
||||
|
||||
Path.rglob does not descend symlinked directories, so a naive scan would
|
||||
silently skip external plugins installed per ADR-003 (clone or symlink
|
||||
into plugins/<name>/). The scanner resolves each plugin dir first; this
|
||||
test plants a symlinked plugin with a violating import and asserts the
|
||||
scan both sees the file and flags the violation.
|
||||
"""
|
||||
root = Path(__file__).resolve().parent.parent / 'plugins'
|
||||
external = tmp_path / 'symlinkdemo'
|
||||
external.mkdir()
|
||||
# violating import: internal core path, not the contract surface
|
||||
(external / 'plugin.py').write_text('from shopdb.extensions import db\n')
|
||||
|
||||
link = root / 'zzsymlinkdemo'
|
||||
link.symlink_to(external, target_is_directory=True)
|
||||
try:
|
||||
scanned = {str(p) for p in _plugin_source_files()}
|
||||
assert any('symlinkdemo' in s for s in scanned), (
|
||||
'symlinked plugin sources were not scanned'
|
||||
)
|
||||
with pytest.raises(AssertionError, match='shopdb.extensions'):
|
||||
test_plugins_only_import_contract_surface()
|
||||
finally:
|
||||
link.unlink()
|
||||
165
tests/test_plugin_lifecycle.py
Normal file
165
tests/test_plugin_lifecycle.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Plugin lifecycle + registry tests.
|
||||
|
||||
Covers the framework's own core feature - the plugin manager's enable/disable
|
||||
guards and permission seeding, plus the registry's persistence and the
|
||||
equipment->machines rename migration. These paths had no direct coverage; a
|
||||
dropped dependency check or a broken rename migration would let an operator
|
||||
disable a required plugin (breaking boot) with no failing test.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.plugins import PluginManager
|
||||
from shopdb.plugins.loader import PluginLoader
|
||||
from shopdb.plugins.registry import PluginRegistry
|
||||
|
||||
|
||||
# --- Registry persistence + rename migration --------------------------------
|
||||
|
||||
def test_registry_disable_survives_reload(tmp_path):
|
||||
"""A disabled plugin stays disabled when the registry is re-read."""
|
||||
path = tmp_path / 'plugins.json'
|
||||
reg = PluginRegistry(path)
|
||||
reg.register('demo', '1.0.0', enabled=True)
|
||||
reg.disable('demo')
|
||||
|
||||
reloaded = PluginRegistry(path)
|
||||
assert reloaded.is_installed('demo')
|
||||
assert not reloaded.is_enabled('demo')
|
||||
|
||||
|
||||
def test_registry_recovers_from_corrupt_file(tmp_path):
|
||||
"""An unparseable plugins.json loads as empty instead of crashing."""
|
||||
path = tmp_path / 'plugins.json'
|
||||
path.write_text('{ this is not valid json')
|
||||
reg = PluginRegistry(path)
|
||||
assert reg.get_all() == {}
|
||||
|
||||
|
||||
def test_registry_migrates_equipment_to_machines(tmp_path):
|
||||
"""A legacy 'equipment' entry is carried over to 'machines' on load (the
|
||||
real plugins/machines dir exists), preserving enabled state, once."""
|
||||
path = tmp_path / 'plugins.json'
|
||||
seed = PluginRegistry(path)
|
||||
seed.register('equipment', '1.0.0', enabled=True)
|
||||
assert seed.is_installed('equipment')
|
||||
|
||||
migrated = PluginRegistry(path) # _load runs the rename migration
|
||||
assert migrated.is_installed('machines')
|
||||
assert migrated.is_enabled('machines')
|
||||
assert not migrated.is_installed('equipment')
|
||||
|
||||
# Persisted: a third read sees machines, and never re-migrates.
|
||||
again = PluginRegistry(path)
|
||||
assert again.is_installed('machines')
|
||||
|
||||
|
||||
# --- Manager enable/disable dependency guards + permission seeding -----------
|
||||
|
||||
def _write_plugin(plugins_dir, name, deps=None, permissions=None):
|
||||
"""Write a minimal valid synthetic plugin (manifest.json + plugin.py)."""
|
||||
pdir = plugins_dir / name
|
||||
pdir.mkdir()
|
||||
(pdir / 'manifest.json').write_text(json.dumps({
|
||||
'name': name,
|
||||
'version': '1.0.0',
|
||||
'description': f'synthetic {name}',
|
||||
'dependencies': deps or [],
|
||||
'core_version': '>=0.1.0,<1.0.0',
|
||||
'api_prefix': f'/api/{name}',
|
||||
}))
|
||||
(pdir / '__init__.py').write_text('')
|
||||
(pdir / 'plugin.py').write_text(
|
||||
'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n'
|
||||
'class ThePlugin(BasePlugin):\n'
|
||||
' @property\n'
|
||||
' def meta(self):\n'
|
||||
f' return PluginMeta(name={name!r}, version="1.0.0",\n'
|
||||
f' description="synthetic", dependencies={deps or []!r})\n'
|
||||
' def get_blueprint(self):\n'
|
||||
' return None\n'
|
||||
' def get_models(self):\n'
|
||||
' return []\n'
|
||||
' def get_permissions(self):\n'
|
||||
f' return {permissions or []!r}\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(app, db, tmp_path):
|
||||
"""A PluginManager wired to a temp plugins dir + registry, with synthetic
|
||||
plugins 'alpha' (owns a permission) and 'beta' (depends on alpha)."""
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
_write_plugin(plugins_dir, 'alpha',
|
||||
permissions=[('alpha.view', 'View alpha', 'alpha')])
|
||||
_write_plugin(plugins_dir, 'beta', deps=['alpha'])
|
||||
|
||||
registry = PluginRegistry(tmp_path / 'plugins.json')
|
||||
registry.register('alpha', '1.0.0', enabled=False)
|
||||
registry.register('beta', '1.0.0', enabled=False)
|
||||
|
||||
pluginmanager = PluginManager()
|
||||
pluginmanager._app = app
|
||||
pluginmanager._db = db
|
||||
pluginmanager.registry = registry
|
||||
pluginmanager.loader = PluginLoader(plugins_dir, registry)
|
||||
pluginmanager.migration_manager = None
|
||||
return pluginmanager
|
||||
|
||||
|
||||
def test_enable_blocked_until_dependency_enabled(manager):
|
||||
"""beta depends on alpha: enabling beta first is refused."""
|
||||
assert manager.enable_plugin('beta') is False
|
||||
assert not manager.registry.is_enabled('beta')
|
||||
|
||||
assert manager.enable_plugin('alpha') is True
|
||||
assert manager.enable_plugin('beta') is True
|
||||
|
||||
|
||||
def test_disable_blocked_while_dependent_enabled(manager):
|
||||
"""alpha cannot be disabled while beta (which depends on it) is enabled."""
|
||||
manager.enable_plugin('alpha')
|
||||
manager.enable_plugin('beta')
|
||||
|
||||
assert manager.disable_plugin('alpha') is False
|
||||
assert manager.registry.is_enabled('alpha')
|
||||
|
||||
# Disable the dependent first, then alpha frees up.
|
||||
assert manager.disable_plugin('beta') is True
|
||||
assert manager.disable_plugin('alpha') is True
|
||||
|
||||
|
||||
def test_enable_seeds_plugin_permissions(manager, app, db):
|
||||
"""Enabling a plugin idempotently seeds its declared RBAC permissions."""
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
with app.app_context():
|
||||
assert Permission.query.filter_by(name='alpha.view').first() is None
|
||||
|
||||
assert manager.enable_plugin('alpha') is True
|
||||
with app.app_context():
|
||||
assert Permission.query.filter_by(name='alpha.view').first() is not None
|
||||
|
||||
# Re-enable is a no-op (already enabled) and never duplicates the row.
|
||||
manager.enable_plugin('alpha')
|
||||
with app.app_context():
|
||||
assert Permission.query.filter_by(name='alpha.view').count() == 1
|
||||
|
||||
|
||||
# --- Seed idempotency -------------------------------------------------------
|
||||
|
||||
def test_seed_settings_is_idempotent(app, db):
|
||||
"""A second `flask seed settings` pass creates zero net-new rows."""
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
runner = app.test_cli_runner()
|
||||
assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None)
|
||||
with app.app_context():
|
||||
first = Setting.query.count()
|
||||
assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None)
|
||||
with app.app_context():
|
||||
second = Setting.query.count()
|
||||
assert second == first
|
||||
261
tests/test_plugin_loader.py
Normal file
261
tests/test_plugin_loader.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Tests for the plugin loader behavior.
|
||||
|
||||
Pins the manifest-first design and the strict-vs-isolate failure policy
|
||||
described in the enforcing-plugin-contract skill.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.plugins.loader import PluginLoader
|
||||
from shopdb.plugins.registry import PluginRegistry
|
||||
from shopdb.exceptions import (
|
||||
PluginNotFoundError,
|
||||
PluginContractError,
|
||||
PluginVersionError,
|
||||
PluginDependencyError,
|
||||
PluginError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_plugins_dir(tmp_path):
|
||||
"""An empty temporary plugins directory."""
|
||||
plugins = tmp_path / 'plugins'
|
||||
plugins.mkdir()
|
||||
return plugins
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_registry(tmp_path):
|
||||
"""An empty temporary registry."""
|
||||
return PluginRegistry(tmp_path / 'plugins.json')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader(temp_plugins_dir, temp_registry):
|
||||
"""A loader pointed at the temporary plugins dir."""
|
||||
return PluginLoader(temp_plugins_dir, temp_registry)
|
||||
|
||||
|
||||
def write_plugin(plugins_dir, name, manifest=None, plugin_py=None):
|
||||
"""Helper: write a plugin directory with manifest.json and plugin.py."""
|
||||
plugin_dir = plugins_dir / name
|
||||
plugin_dir.mkdir()
|
||||
|
||||
if manifest is not None:
|
||||
(plugin_dir / 'manifest.json').write_text(json.dumps(manifest))
|
||||
|
||||
if plugin_py is not None:
|
||||
(plugin_dir / 'plugin.py').write_text(plugin_py)
|
||||
|
||||
return plugin_dir
|
||||
|
||||
|
||||
VALID_PLUGIN_PY = '''
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
|
||||
class FakePlugin(BasePlugin):
|
||||
@property
|
||||
def meta(self):
|
||||
return PluginMeta(
|
||||
name='fake',
|
||||
version='1.0.0',
|
||||
description='Fake test plugin',
|
||||
)
|
||||
|
||||
def get_blueprint(self):
|
||||
return None
|
||||
|
||||
def get_models(self):
|
||||
return []
|
||||
'''
|
||||
|
||||
|
||||
def test_load_manifest_raises_on_missing_manifest(loader, temp_plugins_dir):
|
||||
"""A plugin without manifest.json raises PluginNotFoundError."""
|
||||
plugin_dir = temp_plugins_dir / 'noplugin'
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / 'plugin.py').write_text('# empty')
|
||||
|
||||
with pytest.raises(PluginNotFoundError, match='manifest.json'):
|
||||
loader.load_manifest('noplugin')
|
||||
|
||||
|
||||
def test_load_manifest_raises_on_invalid_json(loader, temp_plugins_dir):
|
||||
"""An unparseable manifest.json raises PluginContractError."""
|
||||
plugin_dir = temp_plugins_dir / 'bad'
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / 'manifest.json').write_text('{not json}')
|
||||
|
||||
with pytest.raises(PluginContractError, match='invalid manifest.json'):
|
||||
loader.load_manifest('bad')
|
||||
|
||||
|
||||
def test_load_manifest_raises_on_missing_required_fields(loader, temp_plugins_dir):
|
||||
"""Missing name/version/description raises PluginContractError."""
|
||||
write_plugin(temp_plugins_dir, 'incomplete', manifest={'name': 'incomplete'})
|
||||
|
||||
with pytest.raises(PluginContractError, match='missing required field'):
|
||||
loader.load_manifest('incomplete')
|
||||
|
||||
|
||||
def test_load_manifest_raises_on_name_mismatch(loader, temp_plugins_dir):
|
||||
"""manifest.name must match the directory name."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'mydir',
|
||||
manifest={'name': 'different', 'version': '1.0.0', 'description': 'x'},
|
||||
)
|
||||
|
||||
with pytest.raises(PluginContractError, match='does not match manifest name'):
|
||||
loader.load_manifest('mydir')
|
||||
|
||||
|
||||
def test_check_contract_version_passes_when_in_range(loader, temp_plugins_dir):
|
||||
"""A core_version range that covers the framework version passes."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'compat',
|
||||
manifest={
|
||||
'name': 'compat',
|
||||
'version': '1.0.0',
|
||||
'description': 'x',
|
||||
'core_version': '>=0.1.0,<1.0.0',
|
||||
},
|
||||
)
|
||||
loader.check_contract_version('compat', '0.1.0')
|
||||
|
||||
|
||||
def test_check_contract_version_raises_when_out_of_range(loader, temp_plugins_dir):
|
||||
"""A core_version range that excludes the framework version raises."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'incompat',
|
||||
manifest={
|
||||
'name': 'incompat',
|
||||
'version': '1.0.0',
|
||||
'description': 'x',
|
||||
'core_version': '>=2.0.0',
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(PluginVersionError, match='requires core_version'):
|
||||
loader.check_contract_version('incompat', '0.1.0')
|
||||
|
||||
|
||||
def test_check_contract_version_skipped_when_unspecified(loader, temp_plugins_dir):
|
||||
"""A manifest without core_version does not raise."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'unspec',
|
||||
manifest={
|
||||
'name': 'unspec',
|
||||
'version': '1.0.0',
|
||||
'description': 'x',
|
||||
},
|
||||
)
|
||||
loader.check_contract_version('unspec', '0.1.0')
|
||||
|
||||
|
||||
def test_load_plugin_class_raises_on_missing_plugin_py(loader, temp_plugins_dir):
|
||||
"""A directory with no plugin.py raises PluginNotFoundError."""
|
||||
plugin_dir = temp_plugins_dir / 'nofile'
|
||||
plugin_dir.mkdir()
|
||||
|
||||
with pytest.raises(PluginNotFoundError, match='plugin.py not found'):
|
||||
loader.load_plugin_class('nofile')
|
||||
|
||||
|
||||
def test_load_plugin_class_raises_when_no_subclass_found(loader, temp_plugins_dir):
|
||||
"""A plugin.py without a BasePlugin subclass raises PluginContractError."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'empty',
|
||||
manifest={'name': 'empty', 'version': '1.0.0', 'description': 'x'},
|
||||
plugin_py='# no plugin defined',
|
||||
)
|
||||
|
||||
with pytest.raises(PluginContractError, match='no BasePlugin subclass'):
|
||||
loader.load_plugin_class('empty')
|
||||
|
||||
|
||||
def test_load_plugin_class_raises_on_import_error(loader, temp_plugins_dir):
|
||||
"""A plugin.py with a syntax/import error raises PluginContractError."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'broken',
|
||||
manifest={'name': 'broken', 'version': '1.0.0', 'description': 'x'},
|
||||
plugin_py='import nonexistent_module_xyz',
|
||||
)
|
||||
|
||||
with pytest.raises(PluginContractError, match='import failed'):
|
||||
loader.load_plugin_class('broken')
|
||||
|
||||
|
||||
def test_sort_by_dependencies_uses_manifest_not_instantiation(loader, temp_plugins_dir):
|
||||
"""Topological sort reads manifest.json directly, never instantiates."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'a',
|
||||
manifest={
|
||||
'name': 'a',
|
||||
'version': '1.0.0',
|
||||
'description': 'x',
|
||||
'dependencies': ['b'],
|
||||
},
|
||||
)
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'b',
|
||||
manifest={'name': 'b', 'version': '1.0.0', 'description': 'x'},
|
||||
)
|
||||
|
||||
sorted_names = loader._sort_by_dependencies(['a', 'b'])
|
||||
assert sorted_names.index('b') < sorted_names.index('a')
|
||||
|
||||
|
||||
def test_load_plugin_strict_mode_reraises(loader, app, temp_plugins_dir):
|
||||
"""In TESTING/DEBUG mode, load_plugin re-raises plugin errors."""
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'incompat',
|
||||
manifest={
|
||||
'name': 'incompat',
|
||||
'version': '1.0.0',
|
||||
'description': 'x',
|
||||
'core_version': '>=99.0.0',
|
||||
},
|
||||
plugin_py=VALID_PLUGIN_PY,
|
||||
)
|
||||
|
||||
assert app.config.get('TESTING') is True
|
||||
with pytest.raises(PluginVersionError):
|
||||
loader.load_plugin('incompat', app, None)
|
||||
|
||||
|
||||
def test_load_plugin_production_mode_isolates(loader, temp_plugins_dir):
|
||||
"""In production-like config, load_plugin returns None on failure."""
|
||||
from flask import Flask
|
||||
|
||||
fake_app = Flask(__name__)
|
||||
fake_app.config['TESTING'] = False
|
||||
fake_app.config['DEBUG'] = False
|
||||
|
||||
write_plugin(
|
||||
temp_plugins_dir,
|
||||
'incompat',
|
||||
manifest={
|
||||
'name': 'incompat',
|
||||
'version': '1.0.0',
|
||||
'description': 'x',
|
||||
'core_version': '>=99.0.0',
|
||||
},
|
||||
plugin_py=VALID_PLUGIN_PY,
|
||||
)
|
||||
|
||||
result = loader.load_plugin('incompat', fake_app, None)
|
||||
assert result is None
|
||||
242
tests/test_plugin_migrations.py
Normal file
242
tests/test_plugin_migrations.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""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'
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline'
|
||||
# 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'
|
||||
# notifications indexes businessunitid on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||
|
||||
# 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()
|
||||
|
||||
assert set(first) == set(PLUGIN_TABLE_OWNERS)
|
||||
assert all(status == 'ok' for status in first.values()), first
|
||||
assert all(status == 'ok' for status in second.values()), 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
|
||||
189
tests/test_plugin_scaffold.py
Normal file
189
tests/test_plugin_scaffold.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Canary tests for the plugin scaffolder.
|
||||
|
||||
The scaffold output must satisfy the framework contract the moment it
|
||||
is generated. If a contract test would fail on a freshly scaffolded
|
||||
plugin, the scaffold templates are broken, not the plugin author's
|
||||
mistake.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Blueprint
|
||||
|
||||
from shopdb.plugins.scaffolder import (
|
||||
scaffold_plugin,
|
||||
validate_name,
|
||||
pascal_case,
|
||||
ScaffoldError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def scaffolded(tmp_path_factory):
|
||||
"""Scaffold a plugin named 'widgets' under a temporary plugins dir.
|
||||
|
||||
Module-scoped so the generated plugin module imports exactly once
|
||||
per test module. SQLAlchemy registers the model once, no metadata
|
||||
collision. Returns a tuple (target_dir, plugins_dir) where
|
||||
plugins_dir is suitable as PluginLoader's plugins_dir argument.
|
||||
|
||||
The scaffold output is loaded via PluginLoader's spec-based import
|
||||
so the canary does not collide with the real `plugins/` package
|
||||
that already exists at <repo>/plugins/.
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp('scaffold')
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
|
||||
target = scaffold_plugin(
|
||||
name='widgets',
|
||||
description='Test widgets plugin',
|
||||
plugins_dir=plugins_dir,
|
||||
)
|
||||
|
||||
import plugins as real_plugins
|
||||
real_plugins.__path__.insert(0, str(plugins_dir))
|
||||
|
||||
try:
|
||||
yield target, plugins_dir
|
||||
finally:
|
||||
try:
|
||||
real_plugins.__path__.remove(str(plugins_dir))
|
||||
except ValueError:
|
||||
pass
|
||||
for mod_name in list(sys.modules):
|
||||
if mod_name.startswith('plugins.widgets'):
|
||||
del sys.modules[mod_name]
|
||||
from shopdb.extensions import db as _db
|
||||
if 'widgets' in _db.metadata.tables:
|
||||
_db.metadata.remove(_db.metadata.tables['widgets'])
|
||||
|
||||
|
||||
def _load_widgets_plugin_class(plugins_dir):
|
||||
"""Load the scaffolded widgets plugin class via PluginLoader."""
|
||||
from shopdb.plugins.loader import PluginLoader
|
||||
from shopdb.plugins.registry import PluginRegistry
|
||||
|
||||
registry = PluginRegistry(plugins_dir.parent / 'plugins.json')
|
||||
loader = PluginLoader(plugins_dir, registry)
|
||||
return loader.load_plugin_class('widgets')
|
||||
|
||||
|
||||
def test_validate_name_accepts_simple_lowercase():
|
||||
validate_name('cameras')
|
||||
validate_name('barcode')
|
||||
validate_name('vlan2')
|
||||
|
||||
|
||||
def test_validate_name_rejects_underscore():
|
||||
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||
validate_name('my_plugin')
|
||||
|
||||
|
||||
def test_validate_name_rejects_hyphen():
|
||||
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||
validate_name('my-plugin')
|
||||
|
||||
|
||||
def test_validate_name_rejects_uppercase():
|
||||
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||
validate_name('MyPlugin')
|
||||
|
||||
|
||||
def test_validate_name_rejects_leading_digit():
|
||||
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||
validate_name('1plugin')
|
||||
|
||||
|
||||
def test_validate_name_rejects_reserved():
|
||||
with pytest.raises(ScaffoldError, match='reserved'):
|
||||
validate_name('plugins')
|
||||
|
||||
|
||||
def test_pascal_case():
|
||||
assert pascal_case('cameras') == 'Cameras'
|
||||
assert pascal_case('vlan') == 'Vlan'
|
||||
|
||||
|
||||
def test_scaffold_creates_expected_files(scaffolded):
|
||||
"""Scaffold output has every file the contract expects."""
|
||||
target, _ = scaffolded
|
||||
expected = [
|
||||
'manifest.json',
|
||||
'plugin.py',
|
||||
'__init__.py',
|
||||
'models/__init__.py',
|
||||
'models/widgets.py',
|
||||
'api/__init__.py',
|
||||
'api/routes.py',
|
||||
'schemas/__init__.py',
|
||||
'tests/__init__.py',
|
||||
'tests/test_plugin.py',
|
||||
'README.md',
|
||||
]
|
||||
for relpath in expected:
|
||||
assert (target / relpath).exists(), f'Missing scaffold output: {relpath}'
|
||||
|
||||
|
||||
def test_scaffold_manifest_has_required_fields(scaffolded):
|
||||
"""manifest.json contains the fields the loader requires."""
|
||||
target, _ = scaffolded
|
||||
manifest = json.loads((target / 'manifest.json').read_text())
|
||||
assert manifest['name'] == 'widgets'
|
||||
assert manifest['version']
|
||||
assert manifest['description'] == 'Test widgets plugin'
|
||||
assert manifest['core_version']
|
||||
assert manifest['api_prefix'] == '/api/widgets'
|
||||
|
||||
|
||||
def test_scaffold_plugin_class_is_importable(scaffolded):
|
||||
"""The generated plugin.py loads via PluginLoader and subclasses BasePlugin."""
|
||||
from shopdb.plugins.base import BasePlugin
|
||||
|
||||
_, plugins_dir = scaffolded
|
||||
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
||||
|
||||
assert issubclass(plugin_class, BasePlugin)
|
||||
instance = plugin_class()
|
||||
assert instance.meta.name == 'widgets'
|
||||
|
||||
|
||||
def test_scaffold_get_blueprint_returns_blueprint(scaffolded):
|
||||
"""The generated plugin's get_blueprint returns a Flask Blueprint."""
|
||||
_, plugins_dir = scaffolded
|
||||
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
||||
bp = plugin_class().get_blueprint()
|
||||
assert isinstance(bp, Blueprint)
|
||||
|
||||
|
||||
def test_scaffold_get_models_returns_model_class(scaffolded):
|
||||
"""The generated plugin's get_models returns at least one model with a tablename."""
|
||||
_, plugins_dir = scaffolded
|
||||
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
||||
models = plugin_class().get_models()
|
||||
assert len(models) == 1
|
||||
assert models[0].__tablename__ == 'widgets'
|
||||
|
||||
|
||||
def test_scaffold_refuses_to_overwrite_by_default(tmp_path):
|
||||
"""A second scaffold of the same name without overwrite raises."""
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
scaffold_plugin('widgets', 'first', plugins_dir)
|
||||
|
||||
with pytest.raises(ScaffoldError, match='already exists'):
|
||||
scaffold_plugin('widgets', 'second', plugins_dir)
|
||||
|
||||
|
||||
def test_scaffold_overwrites_when_explicitly_requested(tmp_path):
|
||||
"""overwrite=True allows a re-scaffold."""
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
scaffold_plugin('widgets', 'first', plugins_dir)
|
||||
scaffold_plugin('widgets', 'second', plugins_dir, overwrite=True)
|
||||
|
||||
manifest = json.loads((plugins_dir / 'widgets' / 'manifest.json').read_text())
|
||||
assert manifest['description'] == 'second'
|
||||
252
tests/test_plugin_scaffold_frontend.py
Normal file
252
tests/test_plugin_scaffold_frontend.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Canary tests for the plugin scaffolder's frontend generation.
|
||||
|
||||
`flask plugin new <name>` generates the backend skeleton and, when a frontend
|
||||
tree is present, a matching Vue frontend starting point: list, detail, and form
|
||||
views, a router route file, and a paste-in api-client snippet. These tests
|
||||
guard the file names, the substitutions, the ADR-009 plugin gating on the
|
||||
routes, the use of global CSS classes over per-page input styling, graceful
|
||||
skipping when there is no frontend tree, and the no-clobber behaviour.
|
||||
|
||||
All generation runs against tmp_path. The real frontend/src tree is never
|
||||
touched.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError
|
||||
|
||||
|
||||
def _make_dirs(tmp_path: Path):
|
||||
"""Create a tmp plugins dir and a tmp frontend/src tree.
|
||||
|
||||
Returns (plugins_dir, frontend_dir).
|
||||
"""
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
frontend_dir = tmp_path / 'frontend' / 'src'
|
||||
frontend_dir.mkdir(parents=True)
|
||||
return plugins_dir, frontend_dir
|
||||
|
||||
|
||||
def test_frontend_files_generated_with_correct_names(tmp_path):
|
||||
"""Views, route file, and api snippet appear with the expected names."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
|
||||
target = scaffold_plugin(
|
||||
name='widgets',
|
||||
description='Test widgets plugin',
|
||||
plugins_dir=plugins_dir,
|
||||
frontend_dir=frontend_dir,
|
||||
)
|
||||
|
||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists()
|
||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists()
|
||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists()
|
||||
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
||||
# paste-in snippet lands in the plugin dir, not the frontend tree
|
||||
assert (target / 'frontend-api-snippet.js').exists()
|
||||
|
||||
|
||||
def test_frontend_substitutions_applied(tmp_path):
|
||||
"""Name and Name placeholders are substituted, none left raw."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||
|
||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||
assert "import('../../views/widgets/WidgetsList.vue')" in route
|
||||
assert "import('../../views/widgets/WidgetsForm.vue')" in route
|
||||
assert "import('../../views/widgets/WidgetsDetail.vue')" in route
|
||||
|
||||
list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text()
|
||||
assert 'widgetsApi' in list_view
|
||||
assert "api.get('/widgets'" in list_view
|
||||
# no unresolved template placeholders for our known substitution keys
|
||||
assert '${name}' not in list_view
|
||||
assert '${Name}' not in list_view
|
||||
|
||||
|
||||
def test_route_file_has_plugin_gating_and_requiresauth(tmp_path):
|
||||
"""Every route carries meta.plugin; form routes add requiresAuth."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||
|
||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||
|
||||
# ADR-009 gating on every route
|
||||
assert route.count("plugin: 'widgets'") == 4
|
||||
# form routes (new + edit) require auth; list + detail do not
|
||||
assert route.count('requiresAuth: true') == 2
|
||||
assert "path: 'widgets/new'" in route
|
||||
assert "path: 'widgets/:id/edit'" in route
|
||||
|
||||
|
||||
def test_views_use_global_classes_not_custom_input_css(tmp_path):
|
||||
"""Views lean on global .filters / .form-control / .card classes and never
|
||||
redefine input styling in a scoped block."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||
|
||||
views = frontend_dir / 'views' / 'widgets'
|
||||
list_view = (views / 'WidgetsList.vue').read_text()
|
||||
form_view = (views / 'WidgetsForm.vue').read_text()
|
||||
|
||||
assert 'class="filters"' in list_view
|
||||
assert 'class="form-control"' in list_view
|
||||
assert 'class="card"' in list_view
|
||||
assert 'class="form-control"' in form_view
|
||||
|
||||
# per-page input styling is forbidden: no scoped rule targets .form-control
|
||||
# on its own (a descendant layout helper like `.filters .form-control` is ok)
|
||||
import re
|
||||
for view in (list_view, form_view):
|
||||
assert not re.search(r'(?m)^\s*\.form-control\s*\{', view)
|
||||
|
||||
|
||||
def test_default_frontend_dir_resolves_relative_to_plugins(tmp_path):
|
||||
"""With no frontend_dir arg, views land in <plugins_dir>/../frontend/src."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists()
|
||||
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
||||
|
||||
|
||||
def test_skip_when_no_frontend_dir(tmp_path):
|
||||
"""No frontend tree: views and route are skipped, backend still generated.
|
||||
|
||||
The api snippet is still emitted because it is a plugin-directory artifact.
|
||||
"""
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
missing_frontend = tmp_path / 'nowhere' / 'src'
|
||||
|
||||
target = scaffold_plugin(
|
||||
name='widgets',
|
||||
description='Test',
|
||||
plugins_dir=plugins_dir,
|
||||
frontend_dir=missing_frontend,
|
||||
)
|
||||
|
||||
# backend skeleton is intact
|
||||
assert (target / 'manifest.json').exists()
|
||||
assert (target / 'plugin.py').exists()
|
||||
# frontend views and route were skipped
|
||||
assert not missing_frontend.exists()
|
||||
# snippet still lands in the plugin dir
|
||||
assert (target / 'frontend-api-snippet.js').exists()
|
||||
|
||||
|
||||
def test_frontend_can_be_disabled(tmp_path):
|
||||
"""frontend=False generates neither views, route, nor snippet."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
|
||||
target = scaffold_plugin(
|
||||
name='widgets',
|
||||
description='Test',
|
||||
plugins_dir=plugins_dir,
|
||||
frontend_dir=frontend_dir,
|
||||
frontend=False,
|
||||
)
|
||||
|
||||
assert not (frontend_dir / 'views' / 'widgets').exists()
|
||||
assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
||||
assert not (target / 'frontend-api-snippet.js').exists()
|
||||
|
||||
|
||||
def test_no_clobber_without_overwrite(tmp_path):
|
||||
"""An existing frontend view is left untouched unless overwrite is set."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
|
||||
# author already hand-edited a view before re-scaffolding
|
||||
views_dir = frontend_dir / 'views' / 'widgets'
|
||||
views_dir.mkdir(parents=True)
|
||||
existing = views_dir / 'WidgetsList.vue'
|
||||
existing.write_text('SENTINEL do not clobber')
|
||||
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||
|
||||
# untouched without overwrite
|
||||
assert existing.read_text() == 'SENTINEL do not clobber'
|
||||
|
||||
|
||||
def test_overwrite_replaces_existing_frontend(tmp_path):
|
||||
"""overwrite=True regenerates an existing frontend view."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
|
||||
views_dir = frontend_dir / 'views' / 'widgets'
|
||||
views_dir.mkdir(parents=True)
|
||||
existing = views_dir / 'WidgetsList.vue'
|
||||
existing.write_text('SENTINEL do not clobber')
|
||||
|
||||
# plugin dir must also exist to reach overwrite path
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||
scaffold_plugin(
|
||||
'widgets', 'Test', plugins_dir,
|
||||
frontend_dir=frontend_dir, overwrite=True,
|
||||
)
|
||||
|
||||
assert existing.read_text() != 'SENTINEL do not clobber'
|
||||
assert 'widgetsApi' in existing.read_text()
|
||||
|
||||
|
||||
def test_route_references_only_existing_views(tmp_path):
|
||||
"""Every view lazy-imported by the route file exists on disk.
|
||||
|
||||
Guards the write-views-before-route ordering: a route pointing at a missing
|
||||
view crashes the Vite dev server.
|
||||
"""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||
|
||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||
import re
|
||||
imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route)
|
||||
assert imported
|
||||
for rel in imported:
|
||||
# rel is relative to router/routes/; resolve against that dir
|
||||
resolved = (frontend_dir / 'router' / 'routes' / rel).resolve()
|
||||
assert resolved.exists(), f'route imports missing view: {rel}'
|
||||
|
||||
|
||||
def test_generated_frontend_is_ascii(tmp_path):
|
||||
"""Generated frontend files contain plain ASCII only."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
target = scaffold_plugin(
|
||||
'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir,
|
||||
)
|
||||
|
||||
generated = [
|
||||
frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue',
|
||||
frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue',
|
||||
frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue',
|
||||
frontend_dir / 'router' / 'routes' / 'widgets.js',
|
||||
target / 'frontend-api-snippet.js',
|
||||
]
|
||||
for path in generated:
|
||||
text = path.read_text()
|
||||
text.encode('ascii') # raises if any non-ASCII slipped in
|
||||
|
||||
|
||||
def test_frontend_dir_not_created_when_absent(tmp_path):
|
||||
"""Scaffolder does not fabricate a frontend tree that was not there."""
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
plugins_dir.mkdir()
|
||||
|
||||
# no frontend dir exists; default resolves to <tmp>/frontend/src (absent)
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
assert not (tmp_path / 'frontend').exists()
|
||||
|
||||
|
||||
def test_scaffold_still_raises_on_existing_plugin(tmp_path):
|
||||
"""Frontend generation does not weaken the plugin-dir overwrite guard."""
|
||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||
scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir)
|
||||
|
||||
with pytest.raises(ScaffoldError, match='already exists'):
|
||||
scaffold_plugin(
|
||||
'widgets', 'second', plugins_dir, frontend_dir=frontend_dir,
|
||||
)
|
||||
0
tests/test_plugins/__init__.py
Normal file
0
tests/test_plugins/__init__.py
Normal file
163
tests/test_plugins/test_dualpath_single.py
Normal file
163
tests/test_plugins/test_dualpath_single.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Dualpath single-machine collapse (site setting dualpath_single_machine).
|
||||
|
||||
A Dualpath pair is one physical dual-bay machine recorded as two asset rows.
|
||||
With the setting on (default), the machines list, dashboard machine count, and
|
||||
the floor map collapse the pair to the PRIMARY bay (lower natural-sort
|
||||
assetnumber) and annotate it with its hidden partner. With the setting off,
|
||||
both bays list and count separately. The data model always keeps both rows.
|
||||
|
||||
Settings are cached, so each test invalidates the cache after seeding.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
from plugins.machines.models import Machine
|
||||
|
||||
|
||||
def _seed_dualpath_pair():
|
||||
"""Create a 'machine' AssetType, two machine assets (2007, 2008) placed on
|
||||
the map, and an active Dualpath relationship between them. 2007 is PRIMARY
|
||||
(lower natural-sort). Returns (primary_machine, secondary_machine)."""
|
||||
machinetype = AssetType(assettype='machine')
|
||||
_db.session.add(machinetype)
|
||||
_db.session.flush()
|
||||
|
||||
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
||||
_db.session.add(dualpath)
|
||||
_db.session.flush()
|
||||
|
||||
primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid,
|
||||
mapx=100, mapy=100)
|
||||
secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid,
|
||||
mapx=110, mapy=110)
|
||||
_db.session.add_all([primary_asset, secondary_asset])
|
||||
_db.session.flush()
|
||||
|
||||
primary_machine = Machine(assetid=primary_asset.assetid)
|
||||
secondary_machine = Machine(assetid=secondary_asset.assetid)
|
||||
_db.session.add_all([primary_machine, secondary_machine])
|
||||
_db.session.flush()
|
||||
|
||||
_db.session.add(AssetRelationship(
|
||||
sourceassetid=primary_asset.assetid,
|
||||
targetassetid=secondary_asset.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid,
|
||||
))
|
||||
_db.session.commit()
|
||||
return primary_machine, secondary_machine
|
||||
|
||||
|
||||
def _set_toggle(value):
|
||||
Setting.set('dualpath_single_machine', value, valuetype='boolean',
|
||||
category='site')
|
||||
invalidate_settings_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting ON (default): pair collapses to the primary bay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
||||
invalidate_settings_cache() # default on (no row)
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
items = payload['data']
|
||||
numbers = [i['assetnumber'] for i in items]
|
||||
|
||||
# secondary bay (2008) is hidden; total reflects the collapse
|
||||
assert numbers == ['2007']
|
||||
assert payload['meta']['pagination']['total'] == 1
|
||||
|
||||
# the visible primary carries its partner
|
||||
partner = items[0]['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == '2008'
|
||||
assert partner['assetid'] is not None
|
||||
assert partner['machineid'] is not None
|
||||
|
||||
|
||||
def test_dashboard_machine_count_collapses(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
counts = resp.get_json()['data']
|
||||
# two machine rows, one physical machine
|
||||
assert counts['totalmachines'] == 1
|
||||
assert counts['counts']['machines'] == 1
|
||||
|
||||
|
||||
def test_map_excludes_secondary_marker(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assets = resp.get_json()['data']['assets']
|
||||
numbers = [a['assetnumber'] for a in assets]
|
||||
|
||||
assert '2007' in numbers
|
||||
assert '2008' not in numbers
|
||||
primary = next(a for a in assets if a['assetnumber'] == '2007')
|
||||
assert primary['dualpathpartner'] == '2008'
|
||||
|
||||
|
||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
invalidate_settings_cache()
|
||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||
|
||||
# banner data is present on BOTH bays even with the toggle off
|
||||
_set_toggle(False)
|
||||
for machine_id, expected in (
|
||||
(primary_machine.machineid, '2008'),
|
||||
(secondary_machine.machineid, '2007'),
|
||||
):
|
||||
resp = client.get(f'/api/machines/{machine_id}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
partner = resp.get_json()['data']['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting OFF: both bays list and count separately
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||
|
||||
assert numbers == ['2007', '2008']
|
||||
assert payload['meta']['pagination']['total'] == 2
|
||||
|
||||
|
||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['totalmachines'] == 2
|
||||
|
||||
|
||||
def test_map_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||
assert '2007' in numbers
|
||||
assert '2008' in numbers
|
||||
212
tests/test_plugins/test_employee_photo.py
Normal file
212
tests/test_plugins/test_employee_photo.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
|
||||
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
|
||||
|
||||
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
|
||||
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
|
||||
directory modes. The wjf_employees HR DB is not available under test, so external
|
||||
resolution is exercised by passing the Picture value directly (no query)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.employees.api.routes import (
|
||||
resolve_employee_photo_url,
|
||||
EMPLOYEE_PHOTO_URL_PREFIX,
|
||||
EMPLOYEE_PHOTO_STATIC_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def _set_mode(db, mode):
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
if row:
|
||||
row.value = mode
|
||||
else:
|
||||
db.session.add(Setting(key='employee_directory_mode', value=mode,
|
||||
valuetype='string', category='site'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _make_employee(db, sso=210000001):
|
||||
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
|
||||
db.session.add(emp)
|
||||
db.session.commit()
|
||||
return emp
|
||||
|
||||
|
||||
# --- upload / replace / delete (self-hosted) --------------------------------
|
||||
|
||||
def test_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app):
|
||||
"""Admin upload writes photofilename + photourl and lands the file on disk."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nfake-png'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
filename = f'photo-{emp.sso}.png'
|
||||
body = resp.get_json()['data']
|
||||
assert body['photofilename'] == filename
|
||||
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == filename
|
||||
|
||||
path = os.path.join(app.instance_path, 'employeephotos', filename)
|
||||
assert os.path.exists(path)
|
||||
with open(path, 'rb') as handle:
|
||||
assert handle.read() == payload
|
||||
|
||||
|
||||
def test_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_upload_missing_employee_is_404(client, db, auth_headers):
|
||||
"""Uploading to a nonexistent SSO is a 404."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/999999/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
|
||||
"""Re-upload with a different extension deletes the prior file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
first = {'file': (io.BytesIO(b'first'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=first,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(oldpath)
|
||||
|
||||
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert not os.path.exists(oldpath)
|
||||
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
|
||||
assert os.path.exists(newpath)
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
|
||||
|
||||
|
||||
def test_serve_returns_bytes(client, db, auth_headers):
|
||||
"""The public serve route returns the uploaded bytes without auth."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nserved'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
|
||||
"""Delete clears photofilename and removes the uploaded file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(path)
|
||||
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename is None
|
||||
|
||||
|
||||
def test_delete_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot delete an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- external-mode 409 ------------------------------------------------------
|
||||
|
||||
def test_upload_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Upload is a 409 when the directory is external (HR owns the photo)."""
|
||||
_set_mode(db, 'external')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/210000001/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
|
||||
|
||||
|
||||
def test_delete_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Delete is a 409 when the directory is external."""
|
||||
_set_mode(db, 'external')
|
||||
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# --- resolver ---------------------------------------------------------------
|
||||
|
||||
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
|
||||
"""Self-hosted: served upload URL when a photo exists, else None."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
assert resolve_employee_photo_url(emp.sso) is None
|
||||
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
|
||||
|
||||
def test_resolver_external_prefixes_relative_and_passes_urls(db):
|
||||
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
|
||||
_set_mode(db, 'external')
|
||||
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
|
||||
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
|
||||
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
|
||||
'https://hr.example.net/p.png'
|
||||
assert resolve_employee_photo_url(210000001, None) is None
|
||||
assert resolve_employee_photo_url(None) is None
|
||||
|
||||
|
||||
def test_lookup_includes_photourl(client, db, auth_headers):
|
||||
"""The lookup serializer carries the resolved photourl (self-hosted)."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
resp = client.get(f'/api/employees/lookup/{emp.sso}')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
166
tests/test_plugins/test_geenforce_compliance.py
Normal file
166
tests/test_plugins/test_geenforce_compliance.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""GE-Enforce compliance: fleet-install coverage per app-linked entry.
|
||||
|
||||
Compliance pairs each app-linked manifest entry with collected PC data
|
||||
(ComputerInstalledApp) to answer 'how many PCs have this app, and how many on
|
||||
the expected version'. Only entries carrying a curated appid appear; unlinked
|
||||
entries are skipped. FileVersion detection supplies the expected-version target;
|
||||
every other detection method yields a null target (no meaningful match). When
|
||||
the optional computers plugin is absent, counts degrade to null gracefully.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Application
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
def _seed_app(db, appname):
|
||||
app_row = Application(appname=appname)
|
||||
db.session.add(app_row)
|
||||
db.session.commit()
|
||||
return app_row.appid
|
||||
|
||||
|
||||
def _seed_pc_with_app(db, hostname, appid, installedversion, isactive=True):
|
||||
"""Create a Computer + one ComputerInstalledApp row for it."""
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=f'PC-{hostname}', assettypeid=atype.assettypeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
link = ComputerInstalledApp(computerid=comp.computerid, appid=appid,
|
||||
installedversion=installedversion,
|
||||
isactive=isactive)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
return comp
|
||||
|
||||
|
||||
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': name, 'phase': 'runtime'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['scopeid']
|
||||
|
||||
|
||||
def _add_entry(client, auth_headers, scopeid, entry):
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json=entry, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['entryid']
|
||||
|
||||
|
||||
def test_compliance_only_lists_app_linked_entries(client, db, auth_headers):
|
||||
appid = _seed_app(db, 'PC-DMIS')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||
'DetectionValue': '2019 R1'})
|
||||
# An unlinked entry (no appid) must NOT appear.
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Unlinked', 'Type': 'MSI', 'Installer': 'apps/x.msi'})
|
||||
|
||||
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data['computersplugin'] is True
|
||||
assert [r['entryname'] for r in data['rows']] == ['PC-DMIS 2019']
|
||||
row = data['rows'][0]
|
||||
assert row['appid'] == appid
|
||||
assert row['appname'] == 'PC-DMIS'
|
||||
assert row['expectedversion'] == '2019 R1'
|
||||
|
||||
|
||||
def test_compliance_counts_active_installs_and_version_match(client, db,
|
||||
auth_headers):
|
||||
appid = _seed_app(db, 'PC-DMIS')
|
||||
# Two PCs on the expected version, one on a different version, one inactive.
|
||||
_seed_pc_with_app(db, 'CMM01', appid, '2019 R1')
|
||||
_seed_pc_with_app(db, 'CMM02', appid, '2019 R1')
|
||||
_seed_pc_with_app(db, 'CMM03', appid, '2016 R2')
|
||||
_seed_pc_with_app(db, 'CMM04', appid, '2019 R1', isactive=False)
|
||||
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||
'DetectionValue': '2019 R1'})
|
||||
|
||||
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']['rows'][0]
|
||||
# Inactive row excluded: 3 active installs, 2 on the expected version.
|
||||
assert row['installedcount'] == 3
|
||||
assert row['versionmatchcount'] == 2
|
||||
assert '2 of 3' in row['coveragenote']
|
||||
|
||||
|
||||
def test_compliance_non_fileversion_has_null_version_target(client, db,
|
||||
auth_headers):
|
||||
appid = _seed_app(db, 'eDNC')
|
||||
_seed_pc_with_app(db, 'DNC01', appid, '5.0')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
# Registry detection is not a version string -> no expected-version target.
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'eDNC', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'Registry',
|
||||
'DetectionPath': 'HKLM\\Software\\eDNC'})
|
||||
|
||||
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']['rows'][0]
|
||||
assert row['expectedversion'] is None
|
||||
assert row['installedcount'] == 1
|
||||
assert row['versionmatchcount'] is None
|
||||
assert 'no version target' in row['coveragenote']
|
||||
|
||||
|
||||
def test_compliance_not_installed_anywhere(client, db, auth_headers):
|
||||
appid = _seed_app(db, 'Orphan')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Orphan', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\o.exe',
|
||||
'DetectionValue': '1.0'})
|
||||
|
||||
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']['rows'][0]
|
||||
assert row['installedcount'] == 0
|
||||
assert row['versionmatchcount'] == 0
|
||||
assert row['coveragenote'] == 'not installed on any collected PC'
|
||||
|
||||
|
||||
def test_compliance_scope_not_found(client, db, auth_headers):
|
||||
resp = client.get('/api/geenforce/scopes/999999/compliance',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_compliance_requires_manage_permission(client, db, member_headers):
|
||||
resp = client.get('/api/geenforce/scopes/1/compliance',
|
||||
headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_compliance_graceful_without_computers_plugin(client, db, auth_headers,
|
||||
monkeypatch):
|
||||
appid = _seed_app(db, 'PC-DMIS')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||
'DetectionValue': '2019 R1'})
|
||||
monkeypatch.setattr(service, '_installed_app_model', lambda: None)
|
||||
|
||||
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data['computersplugin'] is False
|
||||
row = data['rows'][0]
|
||||
assert row['installedcount'] is None
|
||||
assert row['versionmatchcount'] is None
|
||||
assert 'computers plugin not installed' in row['coveragenote']
|
||||
278
tests/test_plugins/test_geenforce_crud.py
Normal file
278
tests/test_plugins/test_geenforce_crud.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""GE-Enforce P2 admin CRUD: scopes, entries, reorder, simulate, publish cycle.
|
||||
|
||||
All admin endpoints are JWT + geenforce.manage/publish gated (the admin user
|
||||
holds both). Entry payloads use the manifest Applications[] shape.
|
||||
"""
|
||||
|
||||
|
||||
def _create_scope(client, auth_headers, name='gea-shopfloor-test'):
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': name, 'phase': 'runtime',
|
||||
'description': 'test scope'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['scopeid']
|
||||
|
||||
|
||||
def _add_entry(client, auth_headers, scopeid, entry):
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json=entry, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['entryid']
|
||||
|
||||
|
||||
def test_scope_crud(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
|
||||
got = client.get(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers)
|
||||
assert got.status_code == 200
|
||||
assert got.get_json()['data']['scopename'] == 'gea-shopfloor-test'
|
||||
|
||||
upd = client.put(f'/api/geenforce/scopes/{scopeid}',
|
||||
json={'description': 'updated', 'computertypeid': 3},
|
||||
headers=auth_headers)
|
||||
assert upd.get_json()['data']['computertypeid'] == 3
|
||||
|
||||
dele = client.delete(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers)
|
||||
assert dele.status_code == 200
|
||||
assert client.get(f'/api/geenforce/scopes/{scopeid}',
|
||||
headers=auth_headers).status_code == 404
|
||||
|
||||
|
||||
def test_duplicate_scope_rejected(client, db, auth_headers):
|
||||
_create_scope(client, auth_headers, 'gea-shopfloor-dup')
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': 'gea-shopfloor-dup', 'phase': 'runtime'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_entry_crud_and_reorder(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
a = _add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/a.msi'})
|
||||
b = _add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/b.ps1'})
|
||||
|
||||
# Update Alpha: change type + add detection.
|
||||
upd = client.put(f'/api/geenforce/entries/{a}',
|
||||
json={'Name': 'Alpha', 'Type': 'EXE', 'Installer': 'apps/a.exe',
|
||||
'DetectionMethod': 'FileVersion',
|
||||
'DetectionPath': 'C:\\a.exe', 'DetectionValue': '1.0'},
|
||||
headers=auth_headers)
|
||||
assert upd.status_code == 200
|
||||
assert upd.get_json()['data']['Type'] == 'EXE'
|
||||
assert upd.get_json()['data']['DetectionMethod'] == 'FileVersion'
|
||||
|
||||
# Reorder: Beta first.
|
||||
reo = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder',
|
||||
json={'order': [b, a]}, headers=auth_headers)
|
||||
assert reo.status_code == 200
|
||||
entries = client.get(f'/api/geenforce/scopes/{scopeid}',
|
||||
headers=auth_headers).get_json()['data']['entries']
|
||||
assert [e['Name'] for e in entries] == ['Beta', 'Alpha']
|
||||
|
||||
# Delete Beta.
|
||||
assert client.delete(f'/api/geenforce/entries/{b}',
|
||||
headers=auth_headers).status_code == 200
|
||||
entries = client.get(f'/api/geenforce/scopes/{scopeid}',
|
||||
headers=auth_headers).get_json()['data']['entries']
|
||||
assert [e['Name'] for e in entries] == ['Alpha']
|
||||
|
||||
|
||||
def test_entry_edit_preserves_full_fidelity(client, db, auth_headers):
|
||||
"""Editing an entry with the complete payload keeps InUseCheck ExePath +
|
||||
timeout, LogFile, and preinstall flags (the UI now sends all of them)."""
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
rich = {
|
||||
'Name': 'PC-DMIS', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi',
|
||||
'LogFile': 'C:\\Logs\\pcdmis.log',
|
||||
'PreEnrollment': True, 'PCTypesStrict': True,
|
||||
'InUseCheck': {'Behavior': 'CloseAndReopen', 'Processes': [
|
||||
{'Name': 'PCDLRN', 'ExePath': 'C:\\PCDLRN.exe',
|
||||
'GracefulCloseTimeoutSec': 15}]},
|
||||
}
|
||||
entryid = _add_entry(client, auth_headers, scopeid, rich)
|
||||
|
||||
# Re-send the same full payload (what the editor now does) and confirm
|
||||
# nothing is stripped.
|
||||
upd = client.put(f'/api/geenforce/entries/{entryid}', json=rich,
|
||||
headers=auth_headers)
|
||||
assert upd.status_code == 200
|
||||
entry = upd.get_json()['data']
|
||||
assert entry['LogFile'] == 'C:\\Logs\\pcdmis.log'
|
||||
assert entry['PreEnrollment'] is True
|
||||
assert entry['PCTypesStrict'] is True
|
||||
proc = entry['InUseCheck']['Processes'][0]
|
||||
assert proc['ExePath'] == 'C:\\PCDLRN.exe'
|
||||
assert proc['GracefulCloseTimeoutSec'] == 15
|
||||
|
||||
|
||||
def test_entry_curated_app_link(client, db, auth_headers):
|
||||
"""An entry can be linked to an existing Application; the link is shopdb
|
||||
metadata and must NOT leak into the manifest JSON (parity/enforcement)."""
|
||||
from shopdb.core.models import Application
|
||||
app_row = Application(appname='eDNC (tracked)')
|
||||
db.session.add(app_row)
|
||||
db.session.commit()
|
||||
appid = app_row.appid
|
||||
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'eDNC install', 'Type': 'MSI',
|
||||
'appid': appid}, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
data = created.get_json()['data']
|
||||
assert data['appid'] == appid
|
||||
assert data['appname'] == 'eDNC (tracked)'
|
||||
|
||||
# the link is NOT in the rendered manifest (would break the engine contract)
|
||||
manifest = client.get(f'/api/geenforce/scopes/{scopeid}/preview',
|
||||
headers=auth_headers).get_json()['data']['manifest']
|
||||
entry_json = manifest['Applications'][0]
|
||||
assert 'appid' not in entry_json and 'appname' not in entry_json
|
||||
|
||||
# unlink
|
||||
updated = client.put(f"/api/geenforce/entries/{data['entryid']}",
|
||||
json={'Name': 'eDNC install', 'Type': 'MSI', 'appid': None},
|
||||
headers=auth_headers)
|
||||
assert updated.get_json()['data']['appid'] is None
|
||||
|
||||
# picker lists the app
|
||||
picker = client.get('/api/geenforce/applications',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert any(a['appname'] == 'eDNC (tracked)' for a in picker)
|
||||
|
||||
|
||||
def test_entry_nonnumeric_appid_ignored(client, db, auth_headers):
|
||||
"""A non-numeric appid must be ignored (link stays null), not 500. Matches
|
||||
the _apply_app_link docstring contract."""
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'x', 'Type': 'MSI', 'appid': 'notanumber'},
|
||||
headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['appid'] is None
|
||||
|
||||
|
||||
def test_invalid_entry_type_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'Bad', 'Type': 'NOTATYPE'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_reorder_rejects_foreign_ids(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
a = _add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Alpha', 'Type': 'MSI'})
|
||||
resp = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder',
|
||||
json={'order': [a, 9999]}, headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_simulate_cmm_version_gate(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm')
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'CLM (untagged)', 'Type': 'MSI', 'Installer': 'apps/clm.msi'})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2016', 'Type': 'MSI', 'Installer': 'apps/16.msi',
|
||||
'_CmmVersion': '2016'})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'Installer': 'apps/19.msi',
|
||||
'_CmmVersion': '2019'})
|
||||
|
||||
sim = client.get(f'/api/geenforce/scopes/{scopeid}/simulate?cmmversion=2019',
|
||||
headers=auth_headers)
|
||||
assert sim.status_code == 200
|
||||
data = sim.get_json()['data']
|
||||
assert data['applied'] == ['CLM (untagged)', 'PC-DMIS 2019']
|
||||
filtered = {f['name']: f['filteredby'] for f in data['filtered']}
|
||||
assert filtered['PC-DMIS 2016'] == ['_CmmVersion']
|
||||
|
||||
|
||||
def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
|
||||
dup = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'Alpha', 'Type': 'PS1'}, headers=auth_headers)
|
||||
assert dup.status_code == 400
|
||||
|
||||
|
||||
def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers):
|
||||
"""A collections-only strict entry must NOT match a nocollections PC via the
|
||||
shared Standard alias group. PCTypesStrict is preinstall-only, so the scope
|
||||
must be the preinstall phase (the runtime engine ignores strict)."""
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': 'preinstall', 'phase': 'preinstall'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
scopeid = resp.get_json()['data']['scopeid']
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
|
||||
'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'UDC (loose)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
|
||||
'PCTypes': ['gea-shopfloor-collections']})
|
||||
|
||||
sim = client.get(
|
||||
f'/api/geenforce/scopes/{scopeid}/simulate?pctype=gea-shopfloor-nocollections',
|
||||
headers=auth_headers).get_json()['data']
|
||||
# loose entry matches via the Standard alias group; strict entry does not.
|
||||
assert sim['applied'] == ['UDC (loose)']
|
||||
filtered = {f['name']: f['filteredby'] for f in sim['filtered']}
|
||||
assert filtered['UDC (strict)'] == ['PCTypes']
|
||||
|
||||
|
||||
def test_simulate_machine_number_gate(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections')
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Okuma cred', 'Type': 'PS1', 'Script': 'scripts/o.ps1',
|
||||
'TargetMachineNumbers': ['3201', '3202']})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Everyone', 'Type': 'PS1', 'Script': 'scripts/e.ps1'})
|
||||
|
||||
match = client.get(
|
||||
f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=3201',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert set(match['applied']) == {'Okuma cred', 'Everyone'}
|
||||
|
||||
nomatch = client.get(
|
||||
f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=9999',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert nomatch['applied'] == ['Everyone']
|
||||
|
||||
|
||||
def test_publish_versions_rollback(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm')
|
||||
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
|
||||
|
||||
pub1 = client.post(f'/api/geenforce/scopes/{scopeid}/publish',
|
||||
json={'notes': 'v1'}, headers=auth_headers)
|
||||
assert pub1.status_code == 201
|
||||
assert pub1.get_json()['data']['versionnumber'] == 1
|
||||
|
||||
_add_entry(client, auth_headers, scopeid, {'Name': 'Beta', 'Type': 'PS1'})
|
||||
pub2 = client.post(f'/api/geenforce/scopes/{scopeid}/publish',
|
||||
json={'notes': 'v2'}, headers=auth_headers)
|
||||
assert pub2.get_json()['data']['versionnumber'] == 2
|
||||
|
||||
versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [v['versionnumber'] for v in versions] == [2, 1]
|
||||
assert versions[0]['iscurrent'] is True
|
||||
|
||||
# v2 manifest has both entries.
|
||||
v2doc = client.get(f'/api/geenforce/scopes/{scopeid}/versions/2',
|
||||
headers=auth_headers).get_json()['data']['manifest']
|
||||
assert [e['Name'] for e in v2doc['Applications']] == ['Alpha', 'Beta']
|
||||
|
||||
# Rollback to v1.
|
||||
rb = client.post(f'/api/geenforce/scopes/{scopeid}/rollback',
|
||||
json={'versionnumber': 1}, headers=auth_headers)
|
||||
assert rb.status_code == 200
|
||||
versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions',
|
||||
headers=auth_headers).get_json()['data']
|
||||
current = next(v for v in versions if v['iscurrent'])
|
||||
assert current['versionnumber'] == 1
|
||||
134
tests/test_plugins/test_geenforce_ddl_parity.py
Normal file
134
tests/test_plugins/test_geenforce_ddl_parity.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""GE-Enforce DDL parity gate: models match the hand-written Alembic baseline.
|
||||
|
||||
The geenforce baseline (0001_geenforce_baseline.py) actually CREATES the plugin
|
||||
tables (ADR-008 per-plugin chain), and it is hand-written - the models and the
|
||||
migration can drift silently (a column added to a model but not the migration
|
||||
ships a schema that upgrade() never builds, and vice versa). This guard builds
|
||||
the schema two ways and diffs them column-by-column:
|
||||
|
||||
1. the SQLAlchemy models via metadata.create_all (desired shape), and
|
||||
2. the migration's upgrade() run against a fresh engine (shipped shape).
|
||||
|
||||
Both go through the same SQLite dialect, so the reflected column types render
|
||||
identically and the comparison stays low-flake. A drift (missing column, changed
|
||||
type, changed nullability) fails here instead of on a real deploy insert.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_BASELINE = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
|
||||
'geenforce' / 'migrations' / 'versions' /
|
||||
'0001_geenforce_baseline.py')
|
||||
|
||||
|
||||
def _geenforce_models():
|
||||
from plugins.geenforce.models import manifest
|
||||
return [
|
||||
manifest.ManifestScope,
|
||||
manifest.ManifestEntry,
|
||||
manifest.ManifestEntryPcType,
|
||||
manifest.ManifestEntryHostname,
|
||||
manifest.ManifestEntryMachineNumber,
|
||||
manifest.ManifestInUseCheck,
|
||||
manifest.ManifestInUseCheckProcess,
|
||||
manifest.ManifestPublishedVersion,
|
||||
manifest.ManifestPayload,
|
||||
manifest.ManifestEnforcementReport,
|
||||
manifest.ManifestEnforcementResult,
|
||||
manifest.PcTypeAlias,
|
||||
]
|
||||
|
||||
|
||||
def _model_schema(db, models):
|
||||
"""Build the geenforce tables from the models; return an inspector."""
|
||||
engine = create_engine('sqlite://')
|
||||
db.metadata.create_all(engine, tables=[model.__table__ for model in models])
|
||||
return inspect(engine)
|
||||
|
||||
|
||||
def _migration_schema():
|
||||
"""Run the baseline migration's upgrade() on a fresh engine; return columns.
|
||||
|
||||
Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens
|
||||
while the connection is open, so we materialize the dicts before it closes.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location('geenforce_baseline',
|
||||
str(_BASELINE))
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
schema = {}
|
||||
with engine.connect() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
migration.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
inspector = inspect(connection)
|
||||
for table in inspector.get_table_names():
|
||||
schema[table] = {column['name']: column
|
||||
for column in inspector.get_columns(table)}
|
||||
return schema
|
||||
|
||||
|
||||
def _colkey(column):
|
||||
"""Normalize a reflected column to the facets we gate on."""
|
||||
return (str(column['type']), bool(column['nullable']))
|
||||
|
||||
|
||||
def test_models_match_migration_baseline(app, db):
|
||||
"""Every geenforce model table+column matches what the migration builds."""
|
||||
with app.app_context():
|
||||
models = _geenforce_models()
|
||||
model_inspector = _model_schema(db, models)
|
||||
migration_schema = _migration_schema()
|
||||
|
||||
drift = []
|
||||
for model in models:
|
||||
table = model.__tablename__
|
||||
if table not in migration_schema:
|
||||
drift.append(f'{table}: table missing from migration')
|
||||
continue
|
||||
modelcols = {column['name']: column
|
||||
for column in model_inspector.get_columns(table)}
|
||||
migrationcols = migration_schema[table]
|
||||
|
||||
missing = set(modelcols) - set(migrationcols)
|
||||
extra = set(migrationcols) - set(modelcols)
|
||||
for name in sorted(missing):
|
||||
drift.append(f'{table}.{name}: in model, not in migration')
|
||||
for name in sorted(extra):
|
||||
drift.append(f'{table}.{name}: in migration, not in model')
|
||||
for name in sorted(set(modelcols) & set(migrationcols)):
|
||||
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
|
||||
drift.append(
|
||||
f'{table}.{name}: model {_colkey(modelcols[name])} != '
|
||||
f'migration {_colkey(migrationcols[name])}')
|
||||
|
||||
assert not drift, 'model/migration DDL drift:\n' + '\n'.join(drift)
|
||||
|
||||
|
||||
def test_curated_and_payload_columns_present_both_sides(app, db):
|
||||
"""Spot-guard the two real-work columns: manifestentries.appid and
|
||||
manifestpayloads.payloadsha256 exist in BOTH the models and the migration
|
||||
(they were added for the app-link + inline-payload features)."""
|
||||
with app.app_context():
|
||||
models = _geenforce_models()
|
||||
model_inspector = _model_schema(db, models)
|
||||
migration_schema = _migration_schema()
|
||||
|
||||
for table, column in (('manifestentries', 'appid'),
|
||||
('manifestpayloads', 'payloadsha256')):
|
||||
modelcols = {c['name'] for c in model_inspector.get_columns(table)}
|
||||
assert column in modelcols, f'{table}.{column} missing from models'
|
||||
assert column in migration_schema[table], \
|
||||
f'{table}.{column} missing from migration'
|
||||
65
tests/test_plugins/test_geenforce_export.py
Normal file
65
tests/test_plugins/test_geenforce_export.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""GE-Enforce export-to-share: config the share root, publish, write to share.
|
||||
|
||||
This is the Milestone 1 loop - author + publish in shopdb, then export the
|
||||
published JSON to the on-share manifest with a history backup, engine untouched.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
def _scope_with_entry(app, name='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(name, 'runtime', {
|
||||
'Version': '2.6',
|
||||
'Applications': [{'Name': 'Alpha', 'Type': 'MSI',
|
||||
'Installer': 'apps/a.msi'}]})
|
||||
service.publish_scope(name, 'runtime', notes='v1')
|
||||
scope = service.ManifestScope.query.filter_by(scopename=name).first()
|
||||
scopeid = scope.scopeid
|
||||
service.db.session.commit()
|
||||
return scopeid
|
||||
|
||||
|
||||
def test_export_requires_config(client, db, app, auth_headers):
|
||||
scopeid = _scope_with_entry(app)
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400 # no share root configured
|
||||
|
||||
|
||||
def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path):
|
||||
scopeid = _scope_with_entry(app)
|
||||
|
||||
# Configure the share root.
|
||||
put = client.put('/api/geenforce/config',
|
||||
json={'shareroot': str(tmp_path)}, headers=auth_headers)
|
||||
assert put.status_code == 200
|
||||
got = client.get('/api/geenforce/config', headers=auth_headers)
|
||||
assert got.get_json()['data']['shareroot'] == str(tmp_path)
|
||||
|
||||
# Export writes the published JSON to <shareroot>/<scope>/manifest.json.
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
path = resp.get_json()['data']['path']
|
||||
assert os.path.isfile(path)
|
||||
written = json.load(open(path))
|
||||
assert [e['Name'] for e in written['Applications']] == ['Alpha']
|
||||
|
||||
|
||||
def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
|
||||
scopeid = _scope_with_entry(app)
|
||||
client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)},
|
||||
headers=auth_headers)
|
||||
# First export creates the file.
|
||||
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
# Second export backs the old file into _meta/history.
|
||||
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
historydir = tmp_path / '_meta' / 'history'
|
||||
assert historydir.is_dir()
|
||||
assert any(historydir.iterdir())
|
||||
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""GE-Enforce first slice: import a scope, publish it, serve it to a client.
|
||||
|
||||
Exercises the vertical the plan calls the first slice (via a small synthetic
|
||||
scope rather than the real gea-shopfloor-cmm): draft import -> publish ->
|
||||
GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304;
|
||||
draft edits never change the served bytes; publish + rollback change them.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.geenforce.models import ManifestScope
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi',
|
||||
'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1',
|
||||
'DetectionMethod': 'Always'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE)
|
||||
service.publish_scope(scopename, 'runtime', notes='initial')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _mint_fetch_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_import_publish_serve(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.data
|
||||
body = json.loads(resp.data)
|
||||
assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta']
|
||||
assert resp.headers.get('ETag')
|
||||
assert resp.headers.get('X-Manifest-Version') == '1'
|
||||
|
||||
|
||||
def test_etag_304(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
etag = first.headers['ETag']
|
||||
again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret, 'If-None-Match': etag})
|
||||
assert again.status_code == 304
|
||||
|
||||
|
||||
def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
# Edit the DRAFT (add an entry) but do NOT publish.
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.db.session.commit()
|
||||
|
||||
after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
assert before == after # served bytes come from the published snapshot only
|
||||
|
||||
|
||||
def test_publish_then_rollback(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma')
|
||||
service.db.session.commit()
|
||||
|
||||
v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert v2.headers['X-Manifest-Version'] == '2'
|
||||
assert b'Gamma' in v2.data
|
||||
|
||||
with app.app_context():
|
||||
service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1)
|
||||
service.db.session.commit()
|
||||
|
||||
rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert rolled.data == v1 # byte-identical: published snapshots are frozen text
|
||||
|
||||
|
||||
def test_unauthenticated_rejected(client, db, app):
|
||||
_seed_and_publish(app)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_scope_rejected(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'wrong', 'scopes': ['collector.ingest']},
|
||||
headers=auth_headers)
|
||||
secret = resp.get_json()['data']['secret']
|
||||
manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert manifest.status_code == 401
|
||||
|
||||
|
||||
def test_unpublished_scope_404(client, db, app, auth_headers):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE)
|
||||
service.db.session.commit() # draft only, never published
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_list_and_preview(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
listing = client.get('/api/geenforce/scopes', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
scopes = listing.get_json()['data']
|
||||
cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm')
|
||||
assert cmm['entrycount'] == 2
|
||||
assert cmm['publishedversion'] == 1
|
||||
|
||||
preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview",
|
||||
headers=auth_headers)
|
||||
assert preview.status_code == 200
|
||||
assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \
|
||||
== ['Alpha', 'Beta']
|
||||
125
tests/test_plugins/test_geenforce_payload.py
Normal file
125
tests/test_plugins/test_geenforce_payload.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""GE-Enforce inline payloads: upload small scripts/configs into shopdb.
|
||||
|
||||
An entry can carry one inline payload (<= 1 MB) stored as ManifestPayload bytes.
|
||||
Upload computes a sha256, sets the entry's payloadsource='inline' + payloadref,
|
||||
and enforces one payload row per entry (re-upload replaces). GET returns the
|
||||
raw bytes. Upload needs geenforce.publish; GET needs geenforce.manage. appid and
|
||||
the payload metadata are shopdb-only and never enter the served manifest JSON.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
from plugins.geenforce.models import ManifestPayload
|
||||
|
||||
|
||||
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': name, 'phase': 'runtime'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['scopeid']
|
||||
|
||||
|
||||
def _add_entry(client, auth_headers, scopeid, name='eDNC install'):
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': name, 'Type': 'PS1',
|
||||
'Script': 'scripts/x.ps1'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['entryid']
|
||||
|
||||
|
||||
def _upload(client, headers, entryid, content, filename='config.reg',
|
||||
content_type='multipart/form-data'):
|
||||
return client.post(
|
||||
f'/api/geenforce/entries/{entryid}/payload',
|
||||
data={'file': (io.BytesIO(content), filename)},
|
||||
content_type=content_type, headers=headers)
|
||||
|
||||
|
||||
def test_upload_stores_payload_and_links_entry(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
content = b'Windows Registry Editor Version 5.00\r\n'
|
||||
|
||||
resp = _upload(client, auth_headers, entryid, content)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['payloadsource'] == 'inline'
|
||||
assert data['payloadref'] == 'config.reg'
|
||||
assert data['payloadsha256'] == hashlib.sha256(content).hexdigest()
|
||||
assert data['haspayload'] is True
|
||||
|
||||
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].payloadbytes == content
|
||||
|
||||
|
||||
def test_reupload_replaces_single_row(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
_upload(client, auth_headers, entryid, b'first')
|
||||
newcontent = b'second version'
|
||||
resp = _upload(client, auth_headers, entryid, newcontent)
|
||||
assert resp.status_code == 201
|
||||
|
||||
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].payloadbytes == newcontent
|
||||
assert rows[0].payloadsha256 == hashlib.sha256(newcontent).hexdigest()
|
||||
|
||||
|
||||
def test_oversized_payload_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
toobig = b'x' * (1024 * 1024 + 1)
|
||||
resp = _upload(client, auth_headers, entryid, toobig)
|
||||
assert resp.status_code == 400
|
||||
assert ManifestPayload.query.filter_by(entryid=entryid).count() == 0
|
||||
|
||||
|
||||
def test_empty_and_missing_file_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
empty = _upload(client, auth_headers, entryid, b'')
|
||||
assert empty.status_code == 400
|
||||
nofile = client.post(f'/api/geenforce/entries/{entryid}/payload',
|
||||
data={}, content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
assert nofile.status_code == 400
|
||||
|
||||
|
||||
def test_download_returns_exact_bytes(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
content = b'\x00\x01binary payload\xff'
|
||||
_upload(client, auth_headers, entryid, content, filename='blob.bin')
|
||||
|
||||
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == content
|
||||
assert 'blob.bin' in resp.headers['Content-Disposition']
|
||||
|
||||
|
||||
def test_download_404_when_no_payload(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_upload_404_unknown_entry(client, db, auth_headers):
|
||||
resp = _upload(client, auth_headers, 999999, b'data')
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_upload_requires_publish_permission(client, db, auth_headers,
|
||||
member_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
# member (no permissions) cannot upload.
|
||||
resp = _upload(client, member_headers, entryid, b'data')
|
||||
assert resp.status_code == 403
|
||||
183
tests/test_plugins/test_geenforce_reporting.py
Normal file
183
tests/test_plugins/test_geenforce_reporting.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""GE-Enforce observed-state reporting: PCs POST enforcement results.
|
||||
|
||||
Covers the report ingest (applied version + per-entry self-heal outcomes), the
|
||||
latest-per-host upsert, received-latest derivation (did the PC pick up the newest
|
||||
published manifest?), status derivation (ok / selfhealed / failed), and the admin
|
||||
fleet-compliance views.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', SCOPE)
|
||||
version = service.publish_scope(scopename, 'runtime', notes='v')
|
||||
service.db.session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def _token(client, auth_headers, scopes):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': scopes},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_report_malformed_counts_is_400(client, db, app, auth_headers):
|
||||
"""A wrong JSON shape (counts as a string, results as a string) must be a
|
||||
clean 400, not a 500 from a later .get()/iteration."""
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for bad in ({'counts': 'nope'}, {'results': 'nope'}):
|
||||
body = {'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm'}
|
||||
body.update(bad)
|
||||
resp = client.post('/api/geenforce/report', json=body,
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 400, resp.get_json()
|
||||
|
||||
|
||||
def test_report_recorded_and_listed(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'enforcerversion': '2.6',
|
||||
'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0},
|
||||
'results': [
|
||||
{'name': 'Alpha', 'action': 'installed', 'selfhealed': True},
|
||||
{'name': 'Beta', 'action': 'skipped'},
|
||||
],
|
||||
}, headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['status'] == 'selfhealed'
|
||||
|
||||
listing = client.get('/api/geenforce/reports', headers=auth_headers)
|
||||
rows = listing.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row['hostname'] == 'WJCMM01'
|
||||
assert row['appliedversion'] == 1
|
||||
assert row['latestversion'] == 1
|
||||
assert row['receivedlatest'] is True
|
||||
assert row['installed'] == 1 and row['skipped'] == 1
|
||||
|
||||
|
||||
def test_received_latest_flips_when_new_version_published(client, db, app,
|
||||
auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'counts': {}},
|
||||
headers={'X-API-Key': secret})
|
||||
# Publish a newer version; the PC is now behind.
|
||||
with app.app_context():
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2')
|
||||
service.db.session.commit()
|
||||
|
||||
row = client.get('/api/geenforce/reports?hostname=WJCMM02',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['appliedversion'] == 1
|
||||
assert row['latestversion'] == 2
|
||||
assert row['receivedlatest'] is False
|
||||
|
||||
|
||||
def test_latest_report_upserts_per_host(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for installed in (0, 1):
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1,
|
||||
'counts': {'installed': installed}},
|
||||
headers={'X-API-Key': secret})
|
||||
rows = client.get('/api/geenforce/reports?hostname=WJCMM03',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert len(rows) == 1 # only the latest is current
|
||||
assert rows[0]['installed'] == 1
|
||||
|
||||
|
||||
def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers):
|
||||
"""A PC reporting its hostname in different casing must not leave two
|
||||
iscurrent rows (the demote is case-insensitive, matching the ilike read)."""
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for host in ('WJCMM09', 'wjcmm09'):
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': host, 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'counts': {'installed': 1}},
|
||||
headers={'X-API-Key': secret})
|
||||
from plugins.geenforce.models import ManifestEnforcementReport
|
||||
with app.app_context():
|
||||
current = ManifestEnforcementReport.query.filter(
|
||||
ManifestEnforcementReport.hostname.ilike('WJCMM09'),
|
||||
ManifestEnforcementReport.iscurrent == True).all()
|
||||
assert len(current) == 1
|
||||
|
||||
|
||||
def test_status_failed_when_failures(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm',
|
||||
'counts': {'failed': 1},
|
||||
'results': [{'name': 'Alpha', 'action': 'failed',
|
||||
'exitcode': 1603,
|
||||
'message': 'MSI 1603'}]},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.get_json()['data']['status'] == 'failed'
|
||||
|
||||
|
||||
def test_report_detail_shows_per_entry_results(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
post = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [
|
||||
{'name': 'Alpha', 'action': 'installed',
|
||||
'selfhealed': True},
|
||||
{'name': 'Beta', 'action': 'failed',
|
||||
'exitcode': 1, 'message': 'boom'}]},
|
||||
headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
results = {r['entryname']: r for r in detail['results']}
|
||||
assert results['Alpha']['selfhealed'] is True
|
||||
assert results['Beta']['action'] == 'failed'
|
||||
assert results['Beta']['message'] == 'boom'
|
||||
|
||||
|
||||
def test_report_requires_report_scope(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
# A fetch-only token cannot report.
|
||||
secret = _token(client, auth_headers, ['geenforce.fetch'])
|
||||
resp = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM06'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_report_unauthenticated_rejected(client, db, app):
|
||||
resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_report_missing_hostname_rejected(client, db, app, auth_headers):
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 400
|
||||
57
tests/test_plugins/test_installed_apps.py
Normal file
57
tests/test_plugins/test_installed_apps.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Installed-applications detail endpoint serializes correctly.
|
||||
|
||||
Regression guard: GET /api/applications/machines/<computerid> serializes each
|
||||
ComputerInstalledApp via its to_dict(). A missing to_dict raised a 500 that the
|
||||
PCDetail page swallowed (v-if on a non-empty list), so real PCs silently showed
|
||||
no installed software. This exercises the with-data path that the empty-list
|
||||
case short-circuits past.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Application
|
||||
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
|
||||
|
||||
def _seed_pc_with_app(client_db):
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
_db.session.add(atype)
|
||||
_db.session.flush()
|
||||
asset = Asset(assetnumber='PC-APPTEST', assettypeid=atype.assettypeid)
|
||||
_db.session.add(asset)
|
||||
_db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname='PC-APPTEST')
|
||||
_db.session.add(comp)
|
||||
_db.session.flush()
|
||||
app = Application(appname='Test App')
|
||||
_db.session.add(app)
|
||||
_db.session.flush()
|
||||
link = ComputerInstalledApp(computerid=comp.computerid, appid=app.appid,
|
||||
installedversion='1.2.3')
|
||||
_db.session.add(link)
|
||||
_db.session.commit()
|
||||
return comp
|
||||
|
||||
|
||||
def test_installed_apps_endpoint_returns_data(client, db, auth_headers):
|
||||
comp = _seed_pc_with_app(db)
|
||||
resp = client.get(f'/api/applications/machines/{comp.computerid}',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
rows = resp.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row['appname'] == 'Test App'
|
||||
assert row['installedversion'] == '1.2.3'
|
||||
assert row['computerid'] == comp.computerid
|
||||
|
||||
|
||||
def test_installed_app_to_dict_prefers_curated_version(db):
|
||||
comp = _seed_pc_with_app(db)
|
||||
link = ComputerInstalledApp.query.filter_by(computerid=comp.computerid).first()
|
||||
data = link.to_dict()
|
||||
assert data['appname'] == 'Test App'
|
||||
assert data['installedversion'] == '1.2.3'
|
||||
assert data['isactive'] is True
|
||||
475
tests/test_plugins/test_measuringtools.py
Normal file
475
tests/test_plugins/test_measuringtools.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""Tests for the measuringtools plugin.
|
||||
|
||||
The plugin ships default-disabled, so the shared session app does not register
|
||||
its blueprint. This module builds its own app, registers the blueprint, runs the
|
||||
plugin's on_install seeding, and restores the process-wide plugin_manager
|
||||
singleton afterwards (create_app repoints it) so other test modules are
|
||||
unaffected - the same snapshot/restore dance used by test_plugin_migrations.
|
||||
|
||||
Coverage: derived calibration status (all four buckets + the 30-day boundary),
|
||||
type CRUD with the in-use delete guard, tool create/update in one merged
|
||||
payload, and the calibration report shape.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins import plugin_manager
|
||||
from plugins.measuringtools.models import (
|
||||
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pure unit tests: derived status (no app needed)
|
||||
# =============================================================================
|
||||
|
||||
def test_derive_status_unknown_when_no_date():
|
||||
assert derive_status(None) == 'unknown'
|
||||
|
||||
|
||||
def test_derive_status_overdue_in_past():
|
||||
assert derive_status(date.today() - timedelta(days=1)) == 'overdue'
|
||||
|
||||
|
||||
def test_derive_status_current_far_future():
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_within_window():
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_on_boundary():
|
||||
"""Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive)."""
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_today():
|
||||
"""Due today is not yet overdue - it falls in the due-soon window."""
|
||||
assert derive_status(date.today()) == 'duesoon'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API tests (self-contained app with the blueprint registered)
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mt_app():
|
||||
"""A testing app with the measuringtools blueprint registered + seeded."""
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
application = create_app('testing')
|
||||
|
||||
from plugins.measuringtools.plugin import MeasuringToolsPlugin
|
||||
plugin = MeasuringToolsPlugin()
|
||||
pm = application.extensions['plugin_manager']
|
||||
if plugin.meta.api_prefix not in pm._registered_prefixes:
|
||||
pm._register_plugin_components(plugin)
|
||||
|
||||
with application.app_context():
|
||||
_db.create_all()
|
||||
_seed(application, plugin)
|
||||
yield application
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
def _seed(application, plugin):
|
||||
"""Seed permissions, an admin, a status, and the plugin's own reference data."""
|
||||
from shopdb.core.models import Permission, User, Role, AssetStatus
|
||||
Permission.seed()
|
||||
role = Role(rolename='admin', description='Administrator')
|
||||
_db.session.add(role)
|
||||
_db.session.add(AssetStatus(status='In Use', description='In use'))
|
||||
_db.session.flush()
|
||||
user = User(username='testadmin', email='admin@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
_db.session.add(user)
|
||||
_db.session.commit()
|
||||
# Seeds AssetType 'measuring_tool' + the starter tool types.
|
||||
plugin.on_install(application)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mt_app):
|
||||
return mt_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _status_id(client):
|
||||
return 1 # only status seeded
|
||||
|
||||
|
||||
# -- Type CRUD + in-use guard -------------------------------------------------
|
||||
|
||||
def test_starter_types_seeded(client):
|
||||
response = client.get('/api/measuringtools/types')
|
||||
assert response.status_code == 200
|
||||
names = {t['name'] for t in response.get_json()['data']}
|
||||
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
|
||||
|
||||
|
||||
def test_type_create_requires_auth(client):
|
||||
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_type_crud_lifecycle(client, auth_headers):
|
||||
created = client.post('/api/measuringtools/types', headers=auth_headers,
|
||||
json={'name': 'Pin Gage', 'description': 'Pin gage set',
|
||||
'color': '#123456'})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
type_id = created.get_json()['data']['measuringtooltypeid']
|
||||
|
||||
updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers,
|
||||
json={'description': 'Updated'})
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['description'] == 'Updated'
|
||||
|
||||
dup = client.post('/api/measuringtools/types', headers=auth_headers,
|
||||
json={'name': 'Pin Gage'})
|
||||
assert dup.status_code == 409
|
||||
|
||||
deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
|
||||
def test_type_delete_blocked_when_in_use(client, auth_headers):
|
||||
type_created = client.post('/api/measuringtools/types', headers=auth_headers,
|
||||
json={'name': 'Depth Gage'})
|
||||
type_id = type_created.get_json()['data']['measuringtooltypeid']
|
||||
tool_created = client.post('/api/measuringtools', headers=auth_headers,
|
||||
json={'assetnumber': 'MT-INUSE-1',
|
||||
'measuringtooltypeid': type_id,
|
||||
'statusid': _status_id(client)})
|
||||
assert tool_created.status_code == 201, tool_created.get_json()
|
||||
|
||||
blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
|
||||
assert blocked.status_code == 409
|
||||
assert 'still use this type' in blocked.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
# -- Tool create/update merged payload ----------------------------------------
|
||||
|
||||
def test_tool_create_and_get_merged(client, auth_headers):
|
||||
caliper = client.get('/api/measuringtools/types').get_json()['data']
|
||||
caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper')
|
||||
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-001',
|
||||
'name': 'Bench caliper',
|
||||
'serialnumber': 'SN-CAL-9',
|
||||
'gaugelabreference': 'GL-42',
|
||||
'measuringtooltypeid': caliper_id,
|
||||
'statusid': _status_id(client),
|
||||
'calibrationintervaldays': 365,
|
||||
'lastcalibrationdate': '2026-01-01',
|
||||
'nextcalibrationdate': '2027-01-01',
|
||||
'calibrationprovider': 'Metro Cal Lab',
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
payload = created.get_json()['data']
|
||||
# Asset core fields at the top level, extension nested.
|
||||
assert payload['assetnumber'] == 'MT-001'
|
||||
assert payload['serialnumber'] == 'SN-CAL-9'
|
||||
assert payload['gaugelabreference'] == 'GL-42'
|
||||
ext = payload['measuringtool']
|
||||
assert ext['measuringtooltypename'] == 'Caliper'
|
||||
assert ext['calibrationprovider'] == 'Metro Cal Lab'
|
||||
assert ext['calibrationstatus'] == 'current'
|
||||
tool_id = ext['measuringtoolid']
|
||||
|
||||
fetched = client.get(f'/api/measuringtools/{tool_id}')
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
|
||||
|
||||
|
||||
def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers):
|
||||
"""A tool at an operation location exposes locationcode (leading token)
|
||||
and locationname so a printed label can encode the inspection operation."""
|
||||
with mt_app.app_context():
|
||||
from shopdb.core.models import Location
|
||||
location = Location(locationname='0615 Blisk Inspection')
|
||||
_db.session.add(location)
|
||||
_db.session.commit()
|
||||
location_id = location.locationid
|
||||
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client),
|
||||
'locationid': location_id,
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
payload = created.get_json()['data']
|
||||
assert payload['locationname'] == '0615 Blisk Inspection'
|
||||
assert payload['locationcode'] == '0615'
|
||||
|
||||
|
||||
def test_tool_payload_location_code_none_when_unplaced(client, auth_headers):
|
||||
"""A tool with no location degrades gracefully: locationcode is None."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client),
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['locationcode'] is None
|
||||
|
||||
|
||||
def test_tool_update_merged_payload(client, auth_headers):
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': '2030-01-01',
|
||||
})
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
|
||||
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={
|
||||
'name': 'Renamed tool', # asset core field
|
||||
'calibrationprovider': 'In-house', # extension field
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue
|
||||
})
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
data = updated.get_json()['data']
|
||||
assert data['name'] == 'Renamed tool'
|
||||
assert data['measuringtool']['calibrationprovider'] == 'In-house'
|
||||
assert data['measuringtool']['calibrationstatus'] == 'overdue'
|
||||
|
||||
|
||||
def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers):
|
||||
body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)}
|
||||
first = client.post('/api/measuringtools', headers=auth_headers, json=body)
|
||||
assert first.status_code == 201
|
||||
second = client.post('/api/measuringtools', headers=auth_headers, json=body)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_list_filter_by_calibrationstatus(client, auth_headers):
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
|
||||
})
|
||||
response = client.get('/api/measuringtools?calibrationstatus=overdue')
|
||||
assert response.status_code == 200
|
||||
rows = response.get_json()['data']
|
||||
assert rows, 'expected at least one overdue tool'
|
||||
assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows)
|
||||
|
||||
|
||||
# -- Maintenance reference identifier -----------------------------------------
|
||||
|
||||
def test_maintenancereference_roundtrip(client, auth_headers):
|
||||
"""Maintenance reference persists on create and update."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client),
|
||||
'maintenancereference': 'MNT-9',
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['maintenancereference'] == 'MNT-9'
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
|
||||
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers,
|
||||
json={'maintenancereference': 'MNT-10'})
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['maintenancereference'] == 'MNT-10'
|
||||
|
||||
|
||||
# -- Global search ------------------------------------------------------------
|
||||
|
||||
def _mt_search_hits(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return [r for r in resp.get_json()['data']['results']
|
||||
if r.get('type') == 'measuring_tool']
|
||||
|
||||
|
||||
def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch):
|
||||
"""A measuring tool appears in global search by asset number, routed right."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)})
|
||||
pm = mt_app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN')
|
||||
assert hits
|
||||
assert hits[0]['url'].startswith('/measuringtools/')
|
||||
|
||||
|
||||
def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch):
|
||||
"""A gage-tag lookup by gaugelabreference finds the tool."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client),
|
||||
'gaugelabreference': 'GLREF-778'})
|
||||
pm = mt_app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
hits = _mt_search_hits(client, auth_headers, 'GLREF-778')
|
||||
assert hits
|
||||
assert hits[0]['url'].startswith('/measuringtools/')
|
||||
|
||||
|
||||
def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch):
|
||||
"""search_measuring_tool_enabled=false hides measuring tools from search."""
|
||||
from shopdb.core.models import Setting
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client),
|
||||
'gaugelabreference': 'GLREF-OFF-1'})
|
||||
pm = mt_app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
Setting.set('search_measuring_tool_enabled', False, valuetype='boolean',
|
||||
category='search')
|
||||
try:
|
||||
assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == []
|
||||
assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == []
|
||||
finally:
|
||||
Setting.set('search_measuring_tool_enabled', True, valuetype='boolean',
|
||||
category='search')
|
||||
|
||||
|
||||
# -- Report shape -------------------------------------------------------------
|
||||
|
||||
def test_calibration_report_shape(client, auth_headers):
|
||||
response = client.get('/api/measuringtools/report/calibration')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
||||
assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
||||
# Buckets and counts agree.
|
||||
for key, rows in data['buckets'].items():
|
||||
assert data['counts'][key] == len(rows)
|
||||
assert 'statuscolors' in data
|
||||
|
||||
|
||||
# -- Core integration: asset serialization ------------------------------------
|
||||
|
||||
def _caliper_id(client):
|
||||
types = client.get('/api/measuringtools/types').get_json()['data']
|
||||
return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
|
||||
|
||||
|
||||
def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers):
|
||||
"""Asset.to_dict resolves the measuringtool extension (typedata + pluginid)."""
|
||||
from shopdb.core.models import Asset
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-TD-1', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client),
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
with mt_app.app_context():
|
||||
asset = _db.session.get(Asset, assetid)
|
||||
result = asset.to_dict(include_type_data=True)
|
||||
assert result['pluginid'] == tool_id
|
||||
assert result['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
assert result['typedata']['measuringtoolid'] == tool_id
|
||||
|
||||
|
||||
# -- Core integration: shop-floor map -----------------------------------------
|
||||
|
||||
def test_map_lists_measuringtool_subtypes(client, auth_headers):
|
||||
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
|
||||
response = client.get('/api/assets/map')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
subtypes = response.get_json()['data']['filters']['subtypes']
|
||||
assert 'Measuring Tool' in subtypes
|
||||
names = {s['name'] for s in subtypes['Measuring Tool']}
|
||||
assert 'Caliper' in names
|
||||
assert all('color' in s for s in subtypes['Measuring Tool'])
|
||||
|
||||
|
||||
def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
|
||||
"""?assettype=measuring_tool&subtype=<id> returns only that subtype's tools."""
|
||||
types = client.get('/api/measuringtools/types').get_json()['data']
|
||||
caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
|
||||
micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer')
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20})
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
|
||||
|
||||
response = client.get(
|
||||
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
|
||||
assert 'MT-MAP-CAL' in numbers
|
||||
assert 'MT-MAP-MIC' not in numbers
|
||||
|
||||
|
||||
def test_map_item_carries_measuringtool_typedata(client, auth_headers):
|
||||
"""A mapped tool's item carries the extension typedata for marker coloring."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
|
||||
response = client.get('/api/assets/map?assettype=measuring_tool')
|
||||
item = next(a for a in response.get_json()['data']['assets']
|
||||
if a['assetnumber'] == 'MT-MAP-TD')
|
||||
assert item['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
|
||||
|
||||
# -- Core integration: dashboard ----------------------------------------------
|
||||
|
||||
def test_dashboard_counts_include_measuringtools(client, auth_headers):
|
||||
"""Dashboard total and counts include active measuring tools."""
|
||||
before = client.get('/api/dashboard').get_json()['data']
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)})
|
||||
after = client.get('/api/dashboard').get_json()['data']
|
||||
assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1
|
||||
assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1
|
||||
assert after['counts']['total'] == before['counts']['total'] + 1
|
||||
|
||||
|
||||
# -- Map overlay endpoint (ADR-010) -------------------------------------------
|
||||
|
||||
def test_map_overlay_shape_and_derivation(client, auth_headers):
|
||||
"""map-overlay returns per-asset derived calibration status + color."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
response = client.get('/api/measuringtools/map-overlay')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = response.get_json()['data']
|
||||
row = next(r for r in rows if r['assetid'] == assetid)
|
||||
assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'}
|
||||
assert row['calibrationstatus'] == 'overdue'
|
||||
assert row['statuscolor'] == STATUS_COLORS['overdue']
|
||||
|
||||
|
||||
def test_map_overlay_excludes_inactive(client, auth_headers):
|
||||
"""A soft-deleted tool drops out of the overlay."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)})
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers)
|
||||
|
||||
rows = client.get('/api/measuringtools/map-overlay').get_json()['data']
|
||||
assert all(r['assetid'] != assetid for r in rows)
|
||||
|
||||
|
||||
# -- Presentation route token (ADR-010) ---------------------------------------
|
||||
|
||||
def test_asset_presentation_route_token():
|
||||
"""Presentation route links through the by-asset resolver (only {assetid})."""
|
||||
from plugins.measuringtools.plugin import MeasuringToolsPlugin
|
||||
entries = MeasuringToolsPlugin().get_asset_presentation()
|
||||
entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
|
||||
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
|
||||
100
tests/test_plugins/test_model_image_in_detail.py
Normal file
100
tests/test_plugins/test_model_image_in_detail.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Vendor-model photo surfaces in asset-extension detail payloads.
|
||||
|
||||
Machines, computers, and printers link a vendor Model via modelnumberid; when
|
||||
that Model carries an imageurl, the extension dict in the GET detail payload
|
||||
must expose it as 'imageurl' (the field name machines established, consumed by
|
||||
the detail-page heroes and the machine badge). When no model is linked, the
|
||||
field must be absent so the frontend v-if renders nothing.
|
||||
|
||||
Network devices and measuring tools have no model link, so they are out of
|
||||
scope here by design.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Model
|
||||
|
||||
from plugins.machines.models import Machine
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
IMAGE_URL = '/api/models/images/model-1.png'
|
||||
|
||||
|
||||
def _seed_asset(assetnumber, assettype):
|
||||
atype = AssetType.query.filter_by(assettype=assettype).first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype=assettype)
|
||||
_db.session.add(atype)
|
||||
_db.session.flush()
|
||||
asset = Asset(assetnumber=assetnumber, assettypeid=atype.assettypeid)
|
||||
_db.session.add(asset)
|
||||
_db.session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _seed_model(imageurl=IMAGE_URL):
|
||||
model = Model(modelnumber='IMG-MODEL-1', imageurl=imageurl)
|
||||
_db.session.add(model)
|
||||
_db.session.flush()
|
||||
return model
|
||||
|
||||
|
||||
def test_machine_detail_carries_model_imageurl(client, db):
|
||||
asset = _seed_asset('2001', 'machine')
|
||||
model = _seed_model()
|
||||
machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(machine)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/machines/{machine.machineid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_computer_detail_carries_model_imageurl(client, db):
|
||||
asset = _seed_asset('PC-2001', 'computer')
|
||||
model = _seed_model()
|
||||
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(computer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_printer_detail_carries_model_imageurl(client, db):
|
||||
asset = _seed_asset('PR-2001', 'printer')
|
||||
model = _seed_model()
|
||||
printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(printer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_detail_omits_imageurl_without_model(client, db):
|
||||
"""No model linked -> no imageurl key (frontend v-if shows nothing)."""
|
||||
asset = _seed_asset('PR-2002', 'printer')
|
||||
printer = Printer(assetid=asset.assetid)
|
||||
_db.session.add(printer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'imageurl' not in resp.get_json()['data']['printer']
|
||||
|
||||
|
||||
def test_detail_omits_imageurl_when_model_has_no_image(client, db):
|
||||
"""Model linked but no photo -> no imageurl key."""
|
||||
asset = _seed_asset('PC-2002', 'computer')
|
||||
model = _seed_model(imageurl=None)
|
||||
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(computer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'imageurl' not in resp.get_json()['data']['computer']
|
||||
121
tests/test_plugins/test_modelsupplies.py
Normal file
121
tests/test_plugins/test_modelsupplies.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Tests for the model-supplies (toner part-number) management API."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model(db):
|
||||
"""A vendor + printer model to attach supplies to."""
|
||||
from shopdb.core.models import Vendor, Model
|
||||
|
||||
vendor = Vendor(vendor='TestVendor')
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
|
||||
model = Model(modelnumber='TestModel C999', vendorid=vendor.vendorid)
|
||||
db.session.add(model)
|
||||
db.session.commit()
|
||||
return model
|
||||
|
||||
|
||||
def test_supplies_meta_lists_allowed_values(client, db):
|
||||
response = client.get('/api/printers/supplies/meta')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert 'toner' in data['supplytypes']
|
||||
assert 'black' in data['colors']
|
||||
assert 'metered' in data['capacitytiers']
|
||||
|
||||
|
||||
def test_create_and_list_model_supply(client, model, auth_headers):
|
||||
create = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={
|
||||
'supplytype': 'toner', 'color': 'black', 'capacitytier': 'standard',
|
||||
'partnumber': 'W2020A', 'marketingname': '414A Black', 'pageyield': 2400,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||
assert listing.status_code == 200
|
||||
supplies = listing.get_json()['data']['supplies']
|
||||
assert len(supplies) == 1
|
||||
assert supplies[0]['partnumber'] == 'W2020A'
|
||||
|
||||
|
||||
def test_duplicate_partnumber_rejected(client, model, auth_headers):
|
||||
payload = {'partnumber': 'W2020A', 'color': 'black'}
|
||||
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json=payload, headers=auth_headers)
|
||||
second = client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json=payload, headers=auth_headers)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_invalid_enum_rejected(client, model, auth_headers):
|
||||
response = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'X1', 'color': 'purple'},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_create_requires_auth(client, model):
|
||||
response = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'X1'},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_and_delete_supply(client, model, auth_headers):
|
||||
created = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'W2020A', 'color': 'black', 'capacitytier': 'standard'},
|
||||
headers=auth_headers,
|
||||
).get_json()['data']
|
||||
supplyid = created['modelsupplyid']
|
||||
|
||||
updated = client.put(
|
||||
f'/api/printers/supplies/{supplyid}',
|
||||
json={'capacitytier': 'high', 'marketingname': '414X Black'},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['capacitytier'] == 'high'
|
||||
|
||||
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||
assert listing.get_json()['data']['supplies'] == []
|
||||
|
||||
|
||||
def test_listmodels_reports_supplycount(client, model, auth_headers):
|
||||
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
|
||||
|
||||
response = client.get('/api/printers/models', query_string={'search': 'C999'})
|
||||
assert response.status_code == 200
|
||||
rows = response.get_json()['data']
|
||||
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
|
||||
assert match['supplycount'] == 1
|
||||
|
||||
|
||||
def test_seed_supplies_corrected_data(app, db):
|
||||
"""The seed loads corrected part numbers (C405 colors, no B405 waste)."""
|
||||
with app.app_context():
|
||||
from plugins.printers.services import seedsupplies, lookupsupplies
|
||||
from shopdb.core.models import Model
|
||||
|
||||
seedsupplies()
|
||||
|
||||
c405 = Model.query.filter(Model.modelnumber.ilike('%C405%')).first()
|
||||
yellow = lookupsupplies(c405.modelnumberid, 'yellow', 'toner')
|
||||
assert any(s['partnumber'] == '106R03501' for s in yellow)
|
||||
|
||||
b405 = Model.query.filter(Model.modelnumber.ilike('%B405%')).first()
|
||||
assert lookupsupplies(b405.modelnumberid, 'none', 'waste') == []
|
||||
68
tests/test_plugins/test_network_ipaddress.py
Normal file
68
tests/test_plugins/test_network_ipaddress.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Network device create/update attaches a primary IP Communication.
|
||||
|
||||
Classic network devices carry their IP in the communications table, and the
|
||||
legacy import needs an HTTP path to set it. The network create/update routes
|
||||
accept an `ipaddress` and materialize a primary Communication (mirroring the
|
||||
printer route); GET surfaces it back.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def network_setup(db):
|
||||
from shopdb.core.models import AssetType, CommunicationType
|
||||
db.session.add(AssetType(assettype='network_device', pluginname='network',
|
||||
tablename='networkdevices', description='Network devices'))
|
||||
db.session.add(CommunicationType(comtype='IP', description='IP'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_create_network_device_with_ipaddress(client, db, auth_headers, network_setup):
|
||||
"""POST /api/network with an ipaddress creates a primary Communication and
|
||||
echoes the IP back."""
|
||||
from shopdb.core.models import Communication
|
||||
resp = client.post('/api/network', json={
|
||||
'assetnumber': 'SW-CORE-01', 'name': 'Core switch',
|
||||
'hostname': 'sw-core-01', 'ipaddress': '10.129.22.101',
|
||||
}, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['ipaddress'] == '10.129.22.101'
|
||||
|
||||
assetid = data['assetid']
|
||||
comm = Communication.query.filter_by(assetid=assetid, isprimary=True).first()
|
||||
assert comm is not None and comm.ipaddress == '10.129.22.101'
|
||||
|
||||
# GET surfaces it too.
|
||||
got = client.get(f"/api/network/{data['networkdevice']['networkdeviceid']}",
|
||||
headers=auth_headers)
|
||||
assert got.get_json()['data']['ipaddress'] == '10.129.22.101'
|
||||
|
||||
|
||||
def test_update_network_device_ipaddress_upserts(client, db, auth_headers, network_setup):
|
||||
"""PUT changes the IP in place without creating a second Communication."""
|
||||
from shopdb.core.models import Communication
|
||||
created = client.post('/api/network', json={
|
||||
'assetnumber': 'AP-01', 'name': 'AP', 'ipaddress': '10.0.0.5',
|
||||
}, headers=auth_headers).get_json()['data']
|
||||
devid = created['networkdevice']['networkdeviceid']
|
||||
|
||||
updated = client.put(f'/api/network/{devid}', json={'ipaddress': '10.0.0.9'},
|
||||
headers=auth_headers)
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['ipaddress'] == '10.0.0.9'
|
||||
|
||||
comms = Communication.query.filter_by(assetid=created['assetid']).all()
|
||||
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
|
||||
|
||||
|
||||
def test_seed_reference_data_creates_ip_commtype(app, db):
|
||||
"""The reference-data seed now creates the IP communication type the asset
|
||||
import depends on."""
|
||||
from shopdb.core.models import CommunicationType
|
||||
runner = app.test_cli_runner()
|
||||
assert runner.invoke(args=['seed', 'reference-data']).exit_code in (0, None)
|
||||
with app.app_context():
|
||||
assert CommunicationType.query.filter_by(comtype='IP').first() is not None
|
||||
assert CommunicationType.query.count() >= 8
|
||||
69
tests/test_plugins/test_pc_default_printer.py
Normal file
69
tests/test_plugins/test_pc_default_printer.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for the per-PC default-printer endpoint (/api/printers/pc-default).
|
||||
|
||||
Parity with classic apipcdefaultprinter.asp: the signed printer-installer EXE
|
||||
preselects a PC's default printer by machine (asset) number. The link is a
|
||||
`defaultprinter` asset relationship (PC asset -> printer asset).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, AssetRelationship, RelationshipType
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
"""A PC asset, a printer asset, and the defaultprinter relationship type."""
|
||||
pc_type = AssetType(assettype='computer', pluginname='computers', tablename='computers')
|
||||
pr_type = AssetType(assettype='printer', pluginname='printers', tablename='printers')
|
||||
dp_type = RelationshipType(relationshiptype='defaultprinter', description='PC to default printer')
|
||||
db.session.add_all([pc_type, pr_type, dp_type])
|
||||
db.session.flush()
|
||||
|
||||
pc = Asset(assetnumber='3210', name='CSF04 PC', assettypeid=pc_type.assettypeid, isactive=True)
|
||||
pr_asset = Asset(assetnumber='PRN-01', name='Materials HP', assettypeid=pr_type.assettypeid, isactive=True)
|
||||
db.session.add_all([pc, pr_asset])
|
||||
db.session.flush()
|
||||
|
||||
printer = Printer(assetid=pr_asset.assetid, windowsname='HP-CSF04-Materials')
|
||||
db.session.add(printer)
|
||||
db.session.commit()
|
||||
|
||||
return {'pc': pc, 'printer_asset': pr_asset, 'printer': printer, 'dp_type': dp_type}
|
||||
|
||||
|
||||
def _link_default(db, pc, printer_asset, dp_type):
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pc.assetid,
|
||||
targetassetid=printer_asset.assetid,
|
||||
relationshiptypeid=dp_type.relationshiptypeid,
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_returns_default_printer_for_pc(client, db, scene):
|
||||
_link_default(db, scene['pc'], scene['printer_asset'], scene['dp_type'])
|
||||
|
||||
resp = client.get('/api/printers/pc-default?machine=3210')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert data['printerid'] == scene['printer'].printerid
|
||||
assert data['windowsname'] == 'HP-CSF04-Materials'
|
||||
|
||||
|
||||
def test_pc_without_default_returns_empty(client, db, scene):
|
||||
resp = client.get('/api/printers/pc-default?machine=3210')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == {}
|
||||
|
||||
|
||||
def test_unknown_machine_returns_empty(client, db, scene):
|
||||
resp = client.get('/api/printers/pc-default?machine=NOPE')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == {}
|
||||
|
||||
|
||||
def test_missing_machine_param_returns_empty(client, db, scene):
|
||||
resp = client.get('/api/printers/pc-default')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == {}
|
||||
48
tests/test_plugins/test_printer_install_list.py
Normal file
48
tests/test_plugins/test_printer_install_list.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for the printer installer endpoint (/api/printers/install-list).
|
||||
|
||||
The signed printer installer EXE pulls network-printer data + floor-map
|
||||
positions from here (shopfloor 2.0 cannot run unsigned .bat maps).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def printer_assettype(db):
|
||||
from shopdb.core.models import AssetType
|
||||
at = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers', description='Printers')
|
||||
db.session.add(at)
|
||||
db.session.commit()
|
||||
return at
|
||||
|
||||
|
||||
def test_install_list_returns_network_printer_with_map(client, db, auth_headers,
|
||||
printer_assettype):
|
||||
"""A network printer appears with flat installer fields incl map position."""
|
||||
created = client.post('/api/printers', json={
|
||||
'assetnumber': 'CSF01-Materials-HP',
|
||||
'hostname': 'wjprn01',
|
||||
'installpath': r'\\srv\drivers\hp',
|
||||
'mapx': 120, 'mapy': 240,
|
||||
}, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
|
||||
resp = client.get('/api/printers/install-list', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
rows = resp.get_json()['data']
|
||||
row = next((r for r in rows if r['machinenumber'] == 'CSF01-Materials-HP'), None)
|
||||
assert row is not None
|
||||
assert row['hostname'] == 'wjprn01'
|
||||
assert row['installpath'] == r'\\srv\drivers\hp'
|
||||
assert row['mapx'] == 120 and row['mapy'] == 240
|
||||
|
||||
|
||||
def test_install_list_excludes_usb_only_printer(client, db, auth_headers,
|
||||
printer_assettype):
|
||||
"""A printer with no hostname and no network IP is excluded."""
|
||||
client.post('/api/printers', json={'assetnumber': 'USB-LABELER'},
|
||||
headers=auth_headers)
|
||||
resp = client.get('/api/printers/install-list', headers=auth_headers)
|
||||
machinenumbers = [r['machinenumber'] for r in resp.get_json()['data']]
|
||||
assert 'USB-LABELER' not in machinenumbers
|
||||
72
tests/test_plugins/test_shopfloor_feed.py
Normal file
72
tests/test_plugins/test_shopfloor_feed.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for the shopfloor TV feed (/api/notifications/shopfloor).
|
||||
|
||||
Since the notification-type display rework, per-employee fan-out is driven by
|
||||
the type's splitperemployee column (seeded true for Recognition/Training):
|
||||
a note naming several comma-joined SSOs yields one card per employee; types
|
||||
without the flag stay a single card.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
|
||||
|
||||
def _make_type(db, typename, typecolor, splitperemployee=False):
|
||||
t = NotificationType(typename=typename, typecolor=typecolor,
|
||||
splitperemployee=splitperemployee, isactive=True)
|
||||
db.session.add(t)
|
||||
db.session.commit()
|
||||
return t
|
||||
|
||||
|
||||
def _make_shopfloor_note(db, ntype, employeesso, employeename):
|
||||
n = Notification(
|
||||
notificationtypeid=ntype.notificationtypeid,
|
||||
notification='msg',
|
||||
businessunitid=None,
|
||||
isactive=True,
|
||||
isshopfloor=True,
|
||||
employeesso=employeesso,
|
||||
employeename=employeename,
|
||||
)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.parametrize('typecolor', ['recognition', 'training'])
|
||||
def test_multi_employee_split_into_one_card_each(client, db, typecolor):
|
||||
"""A split-flagged note with two SSOs yields two current cards."""
|
||||
ntype = _make_type(db, typecolor.capitalize(), typecolor, splitperemployee=True)
|
||||
_make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
assert resp.status_code == 200
|
||||
current = resp.get_json()['data']['current']
|
||||
assert len(current) == 2
|
||||
assert {c['employeesso'] for c in current} == {'111', '222'}
|
||||
assert {c['employeename'] for c in current} == {'Alice', 'Bob'}
|
||||
|
||||
|
||||
def test_non_split_type_stays_single_card(client, db):
|
||||
"""A type without splitperemployee is not fanned out, even with many SSOs."""
|
||||
ntype = _make_type(db, 'Awareness', 'info')
|
||||
_make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
assert resp.status_code == 200
|
||||
current = resp.get_json()['data']['current']
|
||||
assert len(current) == 1
|
||||
assert current[0]['employeesso'] == '111,222'
|
||||
|
||||
|
||||
def test_single_employee_recognition_stays_single_card(client, db):
|
||||
"""One SSO produces one card (no spurious split on a lone employee)."""
|
||||
ntype = _make_type(db, 'Recognition', 'recognition', splitperemployee=True)
|
||||
_make_shopfloor_note(db, ntype, '111', 'Alice')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
assert resp.status_code == 200
|
||||
current = resp.get_json()['data']['current']
|
||||
assert len(current) == 1
|
||||
assert current[0]['employeesso'] == '111'
|
||||
90
tests/test_security_config.py
Normal file
90
tests/test_security_config.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Tests pinning production-config validation behavior."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from shopdb.config import ProductionConfig, ConfigError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
"""Clear all env vars that ProductionConfig.validate looks at."""
|
||||
for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL', 'CORS_ORIGINS'):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
def test_production_validate_raises_on_missing_secret_key(clean_env):
|
||||
"""Empty SECRET_KEY in production must fail loud at boot."""
|
||||
with pytest.raises(ConfigError, match='SECRET_KEY'):
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_production_validate_raises_on_dev_secret_key(clean_env):
|
||||
"""The dev fallback must not be accepted in production."""
|
||||
clean_env.setenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
with pytest.raises(ConfigError, match='SECRET_KEY'):
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_production_validate_raises_on_missing_jwt_secret(clean_env):
|
||||
"""Empty JWT_SECRET_KEY in production must fail loud at boot."""
|
||||
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
||||
with pytest.raises(ConfigError, match='JWT_SECRET_KEY'):
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_production_validate_raises_on_missing_database_url(clean_env):
|
||||
"""Production must not silently fall back to a localhost MySQL URL."""
|
||||
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
||||
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
||||
with pytest.raises(ConfigError, match='DATABASE_URL'):
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_production_validate_raises_on_wildcard_cors(clean_env):
|
||||
"""CORS wildcard is rejected in production."""
|
||||
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
||||
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
||||
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
||||
clean_env.setenv('CORS_ORIGINS', '*')
|
||||
with pytest.raises(ConfigError, match='CORS_ORIGINS'):
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_production_validate_raises_on_empty_cors(clean_env):
|
||||
"""Empty CORS allowlist is rejected in production."""
|
||||
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
||||
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
||||
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
||||
with pytest.raises(ConfigError, match='CORS_ORIGINS'):
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_production_validate_passes_with_complete_config(clean_env):
|
||||
"""All required env vars set with non-default values: validate passes."""
|
||||
clean_env.setenv('SECRET_KEY', 'a-real-strong-key')
|
||||
clean_env.setenv('JWT_SECRET_KEY', 'another-strong-key')
|
||||
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
||||
clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com')
|
||||
ProductionConfig.validate()
|
||||
|
||||
|
||||
def test_per_plugin_collector_key_loaded_from_env(monkeypatch):
|
||||
"""COLLECTOR_API_KEY_<PLUGIN> is a dynamic env var; create_app must load it.
|
||||
|
||||
from_object only copies class attributes, so per-plugin keys (ADR-006)
|
||||
would be invisible without the explicit env scan in create_app.
|
||||
"""
|
||||
from shopdb import create_app
|
||||
|
||||
monkeypatch.setenv('COLLECTOR_API_KEY_COMPUTERS', 'computers-secret')
|
||||
app = create_app('testing')
|
||||
assert app.config.get('COLLECTOR_API_KEY_COMPUTERS') == 'computers-secret'
|
||||
|
||||
|
||||
def test_employee_db_password_has_no_default():
|
||||
"""No safe default for the employee-DB password: unset means empty."""
|
||||
if 'EMPLOYEE_DB_PASSWORD' not in os.environ:
|
||||
from shopdb.config import Config
|
||||
assert Config.EMPLOYEE_DB_PASSWORD == ''
|
||||
113
tests/test_smoke.py
Normal file
113
tests/test_smoke.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Smoke tests pinning the baseline behavior of shopdb-flask.
|
||||
|
||||
These eight tests are the safety net required before any structural
|
||||
refactor proceeds.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_app_factory_creates_app(app):
|
||||
"""create_app('testing') returns a Flask app with TESTING=True."""
|
||||
assert app is not None
|
||||
assert app.config['TESTING'] is True
|
||||
assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']
|
||||
|
||||
|
||||
def test_login_with_valid_credentials_returns_tokens(client, admin_user):
|
||||
"""POST /api/auth/login with valid creds returns access and refresh tokens."""
|
||||
response = client.post(
|
||||
'/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert 'data' in payload
|
||||
data = payload['data']
|
||||
assert 'access_token' in data
|
||||
assert 'refresh_token' in data
|
||||
assert 'user' in data
|
||||
assert data['user']['username'] == 'testadmin'
|
||||
|
||||
|
||||
def test_login_with_invalid_credentials_returns_401(client, admin_user):
|
||||
"""Wrong password returns 401 with the documented error envelope.
|
||||
|
||||
Pins the current shape: error info nested under `data.error` (not at
|
||||
top level). The error_response docstring claims top-level `error` but
|
||||
the implementation puts it under `data`. Pinned as-is until that
|
||||
inconsistency is intentionally addressed.
|
||||
"""
|
||||
response = client.post(
|
||||
'/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'wrongpassword'},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
payload = response.get_json()
|
||||
assert payload['status'] == 'error'
|
||||
assert payload['data']['error']['code'] == 'UNAUTHORIZED'
|
||||
|
||||
|
||||
def test_login_with_missing_fields_returns_400(client):
|
||||
"""Missing username or password returns 400 validation error."""
|
||||
response = client.post('/api/auth/login', json={})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_protected_route_requires_authentication(client, admin_user):
|
||||
"""GET /api/users without a JWT returns 401."""
|
||||
response = client.get('/api/users')
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_protected_route_works_with_jwt(client, auth_headers):
|
||||
"""GET /api/users with a valid JWT returns 200."""
|
||||
response = client.get('/api/users', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_paginated_response_shape(client, auth_headers):
|
||||
"""A paginated list endpoint returns data plus pagination meta.
|
||||
|
||||
Uses /api/locations because it is a simple platform endpoint that
|
||||
uses paginated_response. Pagination meta keys follow the naming
|
||||
convention (lowercase concatenated): page, perpage, total,
|
||||
totalpages, hasnext, hasprev.
|
||||
"""
|
||||
response = client.get('/api/locations', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert 'data' in payload
|
||||
assert isinstance(payload['data'], list)
|
||||
assert 'meta' in payload
|
||||
assert 'pagination' in payload['meta']
|
||||
pagination = payload['meta']['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'perpage' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'totalpages' in pagination
|
||||
|
||||
|
||||
def test_plugin_loader_discovers_bundled_plugins(app):
|
||||
"""Plugin manager finds the bundled plugins."""
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
expected_plugins = {
|
||||
'computers',
|
||||
'employees',
|
||||
'knowledgebase',
|
||||
'machines',
|
||||
'network',
|
||||
'notifications',
|
||||
'printers',
|
||||
'slides',
|
||||
'usb',
|
||||
}
|
||||
|
||||
with app.app_context():
|
||||
loader = plugin_manager.loader
|
||||
discovered = set(loader.discover_plugins())
|
||||
|
||||
assert expected_plugins.issubset(discovered), (
|
||||
f'Missing bundled plugins: {expected_plugins - discovered}'
|
||||
)
|
||||
Reference in New Issue
Block a user