Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.
Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
prefixes, enable toggle. Defaults point at the current
geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
ships as the map default and sites upload their own blueprint.
Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).
Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.
Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
(naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
plugin contract purity) and the USB label page field mapping (both usb
modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.
248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
70
tests/test_core/test_auth_ratelimit.py
Normal file
70
tests/test_core/test_auth_ratelimit.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for IP-based login rate limiting.
|
||||
|
||||
The limiter is disabled in TestingConfig so login-heavy fixtures do not trip
|
||||
it. These tests flip it on via app.config overrides and clear the shared cache
|
||||
so counters do not bleed between cases.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.extensions import cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ratelimit_on(app):
|
||||
"""Enable a tight login rate limit for the duration of one test."""
|
||||
saved = {
|
||||
'enabled': app.config.get('AUTH_RATELIMIT_ENABLED'),
|
||||
'max': app.config.get('AUTH_RATELIMIT_MAX'),
|
||||
'window': app.config.get('AUTH_RATELIMIT_WINDOW_SECONDS'),
|
||||
}
|
||||
app.config['AUTH_RATELIMIT_ENABLED'] = True
|
||||
app.config['AUTH_RATELIMIT_MAX'] = 3
|
||||
app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = 300
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
yield
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
app.config['AUTH_RATELIMIT_ENABLED'] = saved['enabled']
|
||||
app.config['AUTH_RATELIMIT_MAX'] = saved['max']
|
||||
app.config['AUTH_RATELIMIT_WINDOW_SECONDS'] = saved['window']
|
||||
|
||||
|
||||
def _bad_login(client, ip):
|
||||
"""Attempt a login for a non-existent user from a given source IP."""
|
||||
return client.post(
|
||||
'/api/auth/login',
|
||||
json={'username': 'ghost', 'password': 'wrong'},
|
||||
headers={'X-Forwarded-For': ip},
|
||||
)
|
||||
|
||||
|
||||
def test_login_flood_from_one_ip_is_rate_limited(client, db, ratelimit_on):
|
||||
"""After the per-window budget is spent, further attempts get 429."""
|
||||
ip = '203.0.113.10'
|
||||
for _ in range(3):
|
||||
assert _bad_login(client, ip).status_code == 401
|
||||
# Fourth attempt in the same window is over budget.
|
||||
blocked = _bad_login(client, ip)
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.get_json()['data']['error']['code'] == 'RATE_LIMITED'
|
||||
|
||||
|
||||
def test_different_ip_not_penalized(client, db, ratelimit_on):
|
||||
"""One flooding IP does not lock out a different caller's IP."""
|
||||
flooder = '203.0.113.20'
|
||||
for _ in range(4):
|
||||
_bad_login(client, flooder)
|
||||
assert _bad_login(client, flooder).status_code == 429
|
||||
|
||||
# A separate IP still gets the normal 401, not a 429.
|
||||
other = _bad_login(client, '203.0.113.21')
|
||||
assert other.status_code == 401
|
||||
|
||||
|
||||
def test_limiter_disabled_by_default_in_testing(client, db):
|
||||
"""With TestingConfig defaults the limiter is off; repeated logins stay 401."""
|
||||
for _ in range(10):
|
||||
resp = _bad_login(client, '203.0.113.30')
|
||||
assert resp.status_code == 401
|
||||
@@ -56,6 +56,49 @@ def test_member_cannot_create_business_unit(client, db, 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):
|
||||
|
||||
@@ -165,3 +165,46 @@ def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db,
|
||||
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
|
||||
|
||||
@@ -42,3 +42,58 @@ def test_duplicate_ip_rejected(client, db, auth_headers, businessunit):
|
||||
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
|
||||
|
||||
105
tests/test_core/test_search_integrations.py
Normal file
105
tests/test_core/test_search_integrations.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Settings-driven search integrations (ServiceNow + employee-ID pattern).
|
||||
|
||||
The ServiceNow ticket prefixes, the ServiceNow search URL/enabled flag, and the
|
||||
employee-ID recognition pattern all ship as GE defaults but are configurable
|
||||
per-site via Settings. These tests pin that the search endpoint honors those
|
||||
settings and that a bad employeeid_pattern regex falls back rather than 500ing.
|
||||
|
||||
Settings are cached, so every case seeds its rows and calls
|
||||
invalidate_settings_cache() before exercising search.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
from shopdb.core.api.search import _get_search_integrations, _classify_query
|
||||
|
||||
|
||||
def _redirect(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data'].get('redirect')
|
||||
|
||||
|
||||
def test_default_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
"""With no override, a GE-prefixed ticket redirects to ServiceNow."""
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is not None
|
||||
assert redirect['type'] == 'servicenow'
|
||||
|
||||
|
||||
def test_custom_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
"""A site-configured prefix classifies its tickets as ServiceNow."""
|
||||
Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'ACMEINC123')
|
||||
assert redirect is not None
|
||||
assert redirect['type'] == 'servicenow'
|
||||
# Ticket is url-encoded into the {ticket} placeholder.
|
||||
assert 'ACMEINC123' in redirect['url']
|
||||
|
||||
|
||||
def test_custom_prefix_ignores_default_prefix(client, db, auth_headers):
|
||||
"""Overriding the prefixes drops the built-in GE prefixes."""
|
||||
Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
# GEINC is no longer a configured prefix, so no ServiceNow redirect.
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_disabled_servicenow_yields_no_redirect(client, db, auth_headers):
|
||||
"""servicenow_enabled=false suppresses the ServiceNow redirect entirely."""
|
||||
Setting.set('servicenow_enabled', False, valuetype='boolean',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_blank_search_url_yields_no_redirect(client, db, auth_headers):
|
||||
"""An empty servicenow_search_url disables the redirect."""
|
||||
Setting.set('servicenow_search_url', '', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_custom_employeeid_pattern_recognizes_six_digits(client, db, auth_headers):
|
||||
"""A ^\\d{6}$ pattern makes six-digit IDs classify as employee queries."""
|
||||
Setting.set('employeeid_pattern', r'^\d{6}$', valuetype='string',
|
||||
category='site')
|
||||
invalidate_settings_cache()
|
||||
integrations = _get_search_integrations()
|
||||
assert _classify_query('123456', integrations)['is_sso'] is True
|
||||
# Anchored pattern rejects the old nine-digit shape.
|
||||
assert _classify_query('123456789', integrations)['is_sso'] is False
|
||||
|
||||
|
||||
def test_default_employeeid_pattern_recognizes_nine_digits(client, db, auth_headers):
|
||||
"""The shipped default recognizes nine-digit SSO IDs."""
|
||||
invalidate_settings_cache()
|
||||
integrations = _get_search_integrations()
|
||||
assert _classify_query('123456789', integrations)['is_sso'] is True
|
||||
assert _classify_query('123456', integrations)['is_sso'] is False
|
||||
|
||||
|
||||
def test_invalid_employeeid_pattern_falls_back(client, db, auth_headers):
|
||||
"""A malformed regex falls back to nine-digit matching without erroring."""
|
||||
Setting.set('employeeid_pattern', '[', valuetype='string', category='site')
|
||||
invalidate_settings_cache()
|
||||
# Must not raise on compile of the bad pattern.
|
||||
integrations = _get_search_integrations()
|
||||
assert _classify_query('123456789', integrations)['is_sso'] is True
|
||||
assert _classify_query('12345', integrations)['is_sso'] is False
|
||||
|
||||
|
||||
def test_invalid_pattern_search_does_not_500(client, db, auth_headers):
|
||||
"""End-to-end: a bad employeeid_pattern must not break the search endpoint."""
|
||||
Setting.set('employeeid_pattern', '(', valuetype='string', category='site')
|
||||
invalidate_settings_cache()
|
||||
resp = client.get('/api/search?q=hello', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
56
tests/test_core/test_setting_model.py
Normal file
56
tests/test_core/test_setting_model.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for the Setting key-value model, incl. the create-race recovery."""
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
def test_set_creates_then_updates(db):
|
||||
"""set() creates a missing key, then updates the existing row in place."""
|
||||
created = Setting.set('greeting', 'hello', category='site')
|
||||
assert created.settingid is not None
|
||||
assert Setting.get('greeting') == 'hello'
|
||||
|
||||
Setting.set('greeting', 'goodbye')
|
||||
assert Setting.get('greeting') == 'goodbye'
|
||||
|
||||
# Only one row for the key (update, not a second insert).
|
||||
assert Setting.query.filter_by(key='greeting').count() == 1
|
||||
|
||||
|
||||
def test_set_boolean_roundtrip(db):
|
||||
"""Booleans store as canonical strings and read back typed."""
|
||||
Setting.set('flag', True, valuetype='boolean')
|
||||
assert Setting.get('flag') is True
|
||||
Setting.set('flag', False)
|
||||
assert Setting.get('flag') is False
|
||||
|
||||
|
||||
def test_set_recovers_from_create_race(db, monkeypatch):
|
||||
"""A concurrent insert of the same key makes our commit raise IntegrityError;
|
||||
set() rolls back, re-fetches the winner's row, and applies our value."""
|
||||
real_commit = _db.session.commit
|
||||
calls = {'n': 0}
|
||||
|
||||
def flaky_commit():
|
||||
calls['n'] += 1
|
||||
if calls['n'] == 1:
|
||||
# Drop our own pending insert, then persist the "winner" row exactly
|
||||
# as a racing transaction would have, and simulate the unique-key
|
||||
# violation our losing INSERT would raise.
|
||||
_db.session.rollback()
|
||||
winner = Setting(key='racekey', value='winner', valuetype='string')
|
||||
_db.session.add(winner)
|
||||
real_commit()
|
||||
raise IntegrityError('INSERT INTO settings', {}, Exception('duplicate key'))
|
||||
return real_commit()
|
||||
|
||||
monkeypatch.setattr(_db.session, 'commit', flaky_commit)
|
||||
|
||||
result = Setting.set('racekey', 'mine')
|
||||
|
||||
# Recovery path ran: the row exists once and carries our value.
|
||||
assert result is not None
|
||||
assert Setting.get('racekey') == 'mine'
|
||||
assert Setting.query.filter_by(key='racekey').count() == 1
|
||||
166
tests/test_core/test_settings_branding.py
Normal file
166
tests/test_core/test_settings_branding.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""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_masks_secrets(client, db):
|
||||
"""Anonymous GET /api/settings never returns password/token/secret values."""
|
||||
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 non-secret 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']}
|
||||
|
||||
assert by_key['smtp_password'] == '********'
|
||||
assert by_key['zabbix_token'] == '********'
|
||||
assert by_key['warranty_dell_clientsecret'] == '********'
|
||||
# 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)
|
||||
# Non-secret value 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'
|
||||
|
||||
|
||||
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']
|
||||
@@ -1,14 +1,25 @@
|
||||
"""Characterization test for the slides (TV slideshow) endpoint.
|
||||
"""Characterization test for the slides (TV slideshow) plugin feed.
|
||||
|
||||
Written before extracting slides into a plugin: /api/slides must behave
|
||||
identically as a plugin blueprint (same prefix, same response shape).
|
||||
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_endpoint_shape(client, db):
|
||||
"""GET /api/slides returns a slides list and a basepath (no auth required)."""
|
||||
resp = client.get('/api/slides')
|
||||
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()['data']
|
||||
data = resp.get_json()
|
||||
assert data['success'] is True
|
||||
assert data['surface'] == 'lobby'
|
||||
assert isinstance(data['slides'], list)
|
||||
assert 'basepath' in data
|
||||
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'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for the shopfloor TV feed (/api/notifications/shopfloor).
|
||||
|
||||
Recognition AND training notifications that name several comma-joined SSOs fan
|
||||
out into one card per employee; every other type stays a single card. Mirrors
|
||||
classic apishopfloor.asp, which splits both types.
|
||||
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
|
||||
@@ -10,8 +11,9 @@ import pytest
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
|
||||
|
||||
def _make_type(db, typename, typecolor):
|
||||
t = NotificationType(typename=typename, typecolor=typecolor, isactive=True)
|
||||
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
|
||||
@@ -34,8 +36,8 @@ def _make_shopfloor_note(db, ntype, employeesso, employeename):
|
||||
|
||||
@pytest.mark.parametrize('typecolor', ['recognition', 'training'])
|
||||
def test_multi_employee_split_into_one_card_each(client, db, typecolor):
|
||||
"""A recognition/training note with two SSOs yields two current cards."""
|
||||
ntype = _make_type(db, typecolor.capitalize(), 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')
|
||||
@@ -47,7 +49,7 @@ def test_multi_employee_split_into_one_card_each(client, db, typecolor):
|
||||
|
||||
|
||||
def test_non_split_type_stays_single_card(client, db):
|
||||
"""A non recognition/training type is not fanned out, even with many SSOs."""
|
||||
"""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')
|
||||
|
||||
@@ -60,7 +62,7 @@ def test_non_split_type_stays_single_card(client, db):
|
||||
|
||||
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')
|
||||
ntype = _make_type(db, 'Recognition', 'recognition', splitperemployee=True)
|
||||
_make_shopfloor_note(db, ntype, '111', 'Alice')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
|
||||
Reference in New Issue
Block a user