Add email sending (service + 3 flows) and a general asset label generator
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:
270
tests/test_core/test_email.py
Normal file
270
tests/test_core/test_email.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Tests for the email service, forced password change, welcome email, and
|
||||
on-demand report/alert delivery.
|
||||
|
||||
smtplib is always patched so tests never touch the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_smtp_env(monkeypatch):
|
||||
"""Remove any SMTP_* env so env-fallback cannot enable email unexpectedly."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith('SMTP_'):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _enable_smtp(db):
|
||||
"""Seed the minimum settings to make SMTP 'configured'."""
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
rows = {
|
||||
'smtp_enabled': ('true', 'boolean'),
|
||||
'smtp_host': ('smtp.test.local', 'string'),
|
||||
'smtp_port': ('587', 'integer'),
|
||||
'smtp_use_tls': ('true', 'boolean'),
|
||||
'smtp_from_address': ('noreply@test.local', 'string'),
|
||||
'smtp_from_name': ('ShopDB', 'string'),
|
||||
'alert_recipients': ('ops@test.local', 'string'),
|
||||
}
|
||||
for key, (value, valuetype) in rows.items():
|
||||
db.session.add(Setting(key=key, value=value, valuetype=valuetype,
|
||||
category='email'))
|
||||
db.session.commit()
|
||||
invalidate_settings_cache()
|
||||
|
||||
|
||||
def _make_user(db, username, password='temppass1', mustchange=False,
|
||||
failedlogins=0):
|
||||
from shopdb.core.models import User
|
||||
user = User(
|
||||
username=username,
|
||||
email=f'{username}@test.local',
|
||||
passwordhash=generate_password_hash(password),
|
||||
mustchangepassword=mustchange,
|
||||
failedlogins=failedlogins,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _login(client, username, password):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': password})
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mailer no-op behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_mailer_noop_when_disabled(app, db):
|
||||
"""send_email is a no-op returning False when SMTP is disabled; no connect."""
|
||||
from shopdb.utils import mailer
|
||||
with app.app_context():
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
result = mailer.send_email('a@test.local', 'Hi', '<p>Hi</p>')
|
||||
assert result is False
|
||||
smtp.assert_not_called()
|
||||
|
||||
|
||||
def test_mailer_sends_when_configured(app, db):
|
||||
"""send_email connects and sends when SMTP is configured (patched)."""
|
||||
from shopdb.utils import mailer
|
||||
with app.app_context():
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
result = mailer.send_email('a@test.local', 'Hi', '<p>Hi</p>')
|
||||
assert result is True
|
||||
instance.sendmail.assert_called_once()
|
||||
|
||||
|
||||
def test_mailer_never_logs_password(app, db):
|
||||
"""A send failure carrying the password is scrubbed in the returned error."""
|
||||
from shopdb.utils import mailer
|
||||
with app.app_context():
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
_enable_smtp(db)
|
||||
db.session.add(Setting(key='smtp_password', value='s3cret',
|
||||
valuetype='string', category='email'))
|
||||
db.session.commit()
|
||||
invalidate_settings_cache()
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
instance.sendmail.side_effect = RuntimeError('auth failed s3cret')
|
||||
smtp.return_value = instance
|
||||
ok, error = mailer.try_send('a@test.local', 'Hi', '<p>Hi</p>')
|
||||
assert ok is False
|
||||
assert 's3cret' not in error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-email endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_test_email_requires_admin(client, db, member_headers):
|
||||
response = client.post('/api/settings/test-email',
|
||||
json={'to': 'x@test.local'}, headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_test_email_graceful_when_unconfigured(client, db, auth_headers):
|
||||
response = client.post('/api/settings/test-email',
|
||||
json={'to': 'x@test.local'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is False
|
||||
|
||||
|
||||
def test_test_email_sends_when_configured(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
smtp.return_value = MagicMock()
|
||||
response = client.post('/api/settings/test-email',
|
||||
json={'to': 'x@test.local'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forced password change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_login_surfaces_mustchangepassword(client, db):
|
||||
_make_user(db, 'forceduser', password='temppass1', mustchange=True)
|
||||
response = _login(client, 'forceduser', 'temppass1')
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['user']['mustchangepassword'] is True
|
||||
|
||||
|
||||
def test_forced_change_clears_flag_and_lockout(client, db):
|
||||
user = _make_user(db, 'forceduser', password='temppass1', mustchange=True,
|
||||
failedlogins=3)
|
||||
token = _login(client, 'forceduser', 'temppass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
# Forced case: new_password only, no current required.
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'new_password': 'brandnew123'}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
from shopdb.core.models import User
|
||||
refreshed = db.session.get(User, user.userid)
|
||||
assert refreshed.mustchangepassword is False
|
||||
assert refreshed.failedlogins == 0
|
||||
assert refreshed.lockeduntil is None
|
||||
# New password works.
|
||||
assert _login(client, 'forceduser', 'brandnew123').status_code == 200
|
||||
|
||||
|
||||
def test_selfservice_change_rejects_wrong_current(client, db):
|
||||
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
|
||||
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'current_password': 'wrongpass',
|
||||
'new_password': 'brandnew123'}, headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_selfservice_change_succeeds_with_correct_current(client, db):
|
||||
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
|
||||
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'current_password': 'rightpass1',
|
||||
'new_password': 'brandnew123'}, headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert _login(client, 'normaluser', 'brandnew123').status_code == 200
|
||||
|
||||
|
||||
def test_change_password_rejects_short_password(client, db):
|
||||
_make_user(db, 'normaluser', password='rightpass1', mustchange=False)
|
||||
token = _login(client, 'normaluser', 'rightpass1').get_json()['data']['access_token']
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
response = client.post('/api/auth/change-password',
|
||||
json={'current_password': 'rightpass1',
|
||||
'new_password': 'short'}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New-user welcome
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_user_sets_mustchangepassword_and_sends(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/users',
|
||||
json={'username': 'newhire',
|
||||
'email': 'newhire@test.local',
|
||||
'password': 'temppass1'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
assert response.get_json()['data']['mustchangepassword'] is True
|
||||
instance.sendmail.assert_called_once()
|
||||
|
||||
|
||||
def test_create_user_survives_mail_failure(client, db, auth_headers):
|
||||
"""User is still created (with a warning) when the welcome email fails."""
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
instance.sendmail.side_effect = RuntimeError('relay down')
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/users',
|
||||
json={'username': 'newhire2',
|
||||
'email': 'newhire2@test.local',
|
||||
'password': 'temppass1'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
assert 'warning' in response.get_json()['data']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-demand report/alert delivery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_email_report_requires_permission(client, db, member_headers):
|
||||
response = client.post('/api/reports/email',
|
||||
json={'subject': 'X', 'columns': [], 'rows': []},
|
||||
headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_email_report_renders_and_sends(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/reports/email',
|
||||
json={'subject': 'Warranty Report',
|
||||
'columns': [{'key': 'vendor', 'label': 'Vendor'}],
|
||||
'rows': [{'vendor': 'Dell'}],
|
||||
'to': 'boss@test.local'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is True
|
||||
instance.sendmail.assert_called_once()
|
||||
|
||||
|
||||
def test_email_report_defaults_to_alert_recipients(client, db, auth_headers):
|
||||
_enable_smtp(db)
|
||||
with patch('shopdb.utils.mailer.smtplib.SMTP') as smtp:
|
||||
instance = MagicMock()
|
||||
smtp.return_value = instance
|
||||
response = client.post('/api/reports/email',
|
||||
json={'subject': 'Toner', 'columns': [], 'rows': []},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['sent'] is True
|
||||
27
tests/test_core/test_location_code.py
Normal file
27
tests/test_core/test_location_code.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Unit tests for the location operation-code derivation.
|
||||
|
||||
Locations carry no dedicated code column, so the operation code is the leading
|
||||
whitespace-delimited token of the location name (e.g. '0615 Blisk Inspection'
|
||||
-> '0615'). Printed asset labels for measuring tools encode this so every tool
|
||||
at one operation shares one code.
|
||||
"""
|
||||
|
||||
from shopdb.core.models.location import derive_locationcode
|
||||
|
||||
|
||||
def test_derive_leading_numeric_token():
|
||||
assert derive_locationcode('0615 Blisk Inspection') == '0615'
|
||||
|
||||
|
||||
def test_derive_single_token_name():
|
||||
assert derive_locationcode('Lab') == 'Lab'
|
||||
|
||||
|
||||
def test_derive_strips_surrounding_whitespace():
|
||||
assert derive_locationcode(' 0700 Final ') == '0700'
|
||||
|
||||
|
||||
def test_derive_none_when_blank():
|
||||
assert derive_locationcode('') is None
|
||||
assert derive_locationcode(' ') is None
|
||||
assert derive_locationcode(None) is None
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user