Add email sending (service + 3 flows) and a general asset label generator
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:58:30 -04:00
parent 7d309aabeb
commit a846587f39
34 changed files with 1819 additions and 12 deletions

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

@@ -158,6 +158,28 @@ def test_defaults_contain_new_site_keys():
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()