Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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:
cproudlock
2026-07-10 15:02:07 -04:00
parent bf9e60e607
commit b8c22244a1
96 changed files with 3818 additions and 1942 deletions

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

@@ -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):

View File

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

View File

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

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

View File

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

View File

@@ -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')