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:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests package."""

148
tests/conftest.py Normal file
View 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
View 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'

View File

View 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']

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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'

View 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)

View 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

View 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

View 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

View 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

View 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

View 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

View 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}

View 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)

View 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

View 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

View 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') == []

View 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') == []

View 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()

View 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

View 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

View 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']

View 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'

View 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

View 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()

View 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
View 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

View 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

View 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'

View 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,
)

View File

View 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

View 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'

View 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']

View 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

View 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'

View 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())

View 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']

View 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

View 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

View 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

View 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}'

View 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']

View 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') == []

View 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

View 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'] == {}

View 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

View 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'

View 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
View 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}'
)