From 9bd6b364b734f42c3195986034d20f96acff11fa Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 5 Aug 2026 10:02:12 -0400 Subject: [PATCH] Aug 5 - Update code to follow up ST - MT14c --- app/models/support.py | 23 ++ app/routes/auth.py | 146 ++++++++++++ app/routes/support.py | 38 ++- app/templates/auth/profile.html | 55 +++++ app/utils/notifications.py | 35 ++- tests/test_privacy_and_routing.py | 375 ++++++++++++++++++++++++++++++ 6 files changed, 667 insertions(+), 5 deletions(-) create mode 100644 tests/test_privacy_and_routing.py diff --git a/app/models/support.py b/app/models/support.py index 706a7cb..cf694df 100644 --- a/app/models/support.py +++ b/app/models/support.py @@ -18,6 +18,29 @@ class SupportChatSession(db.Model): order_by='SupportChatMessage.created_at', ) + @property + def message_count(self): + """Number of turns in this session. + + `messages` is a plain list relationship here (ST's is lazy='dynamic'), + so this is len() rather than .count(). It is already loaded whenever the + session is, so this costs no extra query. + """ + return len(self.messages) + + @property + def preview(self): + """First user message, for list views. + + Scans the loaded list instead of ST's .filter_by(role='user').first(), + for the same reason. `messages` is ordered by created_at, so the first + match is the opening question. + """ + for m in self.messages: + if m.role == 'user': + return m.content + return '(no messages)' + def __repr__(self): return f'' diff --git a/app/routes/auth.py b/app/routes/auth.py index d832f82..cec5abd 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -241,6 +241,152 @@ def profile(): ) +# ── Self-service data export (GDPR Art. 15/20, CCPA right-to-know) ──────────── + +@bp.route('/my-data/export') +@login_required +def export_my_data(): + """Download a JSON snapshot of everything this account's own records hold: + profile fields, inspections performed, issues reported/assigned/commented + on, and the audit log entries recorded against this user id. + + Read-only, and scoped to the caller. Records that merely *reference* this + user are included only as the user's own row — related entities are NOT + expanded, so an issue this user commented on contributes the comment, not + the facility details or the other participants. That keeps a subject-access + request from becoming a data leak about everyone else. + + Multi-tenant note: this runs against the caller's own tenant DB via the + normal request routing, so it can only ever see that tenant's data. + """ + from flask import Response + import json + from app.models.inspection import Inspection + from app.models.issue import Issue, IssueComment + from app.models.audit import AuditLog + from app.utils.time_utils import now_eastern + + user = current_user + + payload = { + 'exported_at': now_eastern().isoformat(), + 'profile': { + 'id': user.id, + 'username': user.username, + 'full_name': user.full_name, + 'email': user.email, + 'role': user.role, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'active': user.active, + }, + 'inspections_performed': [ + {'id': i.id, 'facility_id': i.facility_id, + 'inspection_date': i.inspection_date.isoformat() if i.inspection_date else None, + 'overall_score': i.overall_score, 'status': i.status} + for i in Inspection.query.filter_by(inspector_id=user.id).all() + ], + 'issues_reported': [ + {'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description, + 'status': iss.status, 'severity': iss.severity, + 'reported_at': iss.reported_at.isoformat() if iss.reported_at else None} + for iss in Issue.query.filter_by(reported_by=user.id).all() + ], + 'issues_assigned': [ + {'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description, + 'status': iss.status, 'severity': iss.severity} + for iss in Issue.query.filter_by(assigned_to=user.id).all() + ], + 'issue_comments_authored': [ + {'id': c.id, 'issue_id': c.issue_id, 'body': c.body, + 'created_at': c.created_at.isoformat() if c.created_at else None} + for c in IssueComment.query.filter_by(user_id=user.id).all() + ], + 'audit_log_entries': [ + {'id': a.id, 'action': a.action, 'entity_type': a.entity_type, + 'entity_id': a.entity_id, 'entity_label': a.entity_label, + 'created_at': a.created_at.isoformat() if a.created_at else None} + for a in AuditLog.query.filter_by(user_id=user.id).all() + ], + } + + log_action(ACTION_UPDATE, 'User', user.id, user.username, 'self-service data export') + logger.info('AUTH | export_my_data | user_id=%s username=%s', user.id, user.username) + + body = json.dumps(payload, indent=2, default=str) + return Response( + body, + mimetype='application/json', + headers={'Content-Disposition': f'attachment; filename=jqc_my_data_{user.id}.json'}, + ) + + +# ── Self-service erasure request (GDPR Art. 17, CCPA right-to-delete) ───────── + +@bp.route('/my-data/delete-request', methods=['POST']) +@login_required +def request_my_data_deletion(): + """Erase this account's PII on request. + + Two outcomes, chosen automatically: + + * No records that a hard delete would orphan (same guard rails as the admin + delete_user route) → the account is deleted outright. + * Otherwise — the common case, since staff usually have inspection or issue + history that must be kept for business and audit continuity — the account + is ANONYMIZED in place: name/email/username replaced with a + non-identifying placeholder, the password hash invalidated so nobody can + ever log in as it again, and the account deactivated. + + Historical records reference the user *id*, not the PII, so they survive the + anonymization unchanged and the audit trail stays intact. This is the + balance the regulations expect: erase the identity, keep the ledger. + """ + import secrets + from app.models.issue import Issue as _Issue, IssueComment as _IssueComment + from app.models.inspection import InspectionTemplate as _InspectionTemplate + + user = current_user + + blocking = ( + user.inspections.count() > 0 + or _Issue.query.filter_by(assigned_to=user.id).count() > 0 + or _IssueComment.query.filter_by(user_id=user.id).count() > 0 + or _InspectionTemplate.query.filter_by(created_by=user.id).count() > 0 + ) + + username = user.username + user_id = user.id + + if not blocking: + db.session.delete(user) + db.session.commit() + logout_user() + logger.info('AUTH | self_delete | user_id=%s username=%s', user_id, username) + log_action(ACTION_DELETE, 'User', user_id, username, + 'self-service account deletion') + flash('Your account and data have been permanently deleted.', 'success') + return redirect(url_for('auth.login')) + + placeholder = f'deleted_user_{user_id}' + user.full_name = None + user.email = f'{placeholder}@deleted.local' + user.username = placeholder + # Random hash nobody holds — the account can never be logged into again. + user.set_password(secrets.token_hex(32)) + user.active = False + db.session.commit() + logger.info('AUTH | self_anonymize | user_id=%s ' + '(had blocking records, hard delete not possible)', user_id) + log_action(ACTION_UPDATE, 'User', user_id, placeholder, + 'self-service erasure request — anonymized ' + '(blocking records retained for audit/business continuity)') + logout_user() + flash('Your personal information has been removed and your account ' + 'deactivated. Historical records tied to your account id are retained ' + 'for audit continuity but no longer identify you.', 'success') + return redirect(url_for('auth.login')) + + @bp.route('/users') @login_required @admin_required diff --git a/app/routes/support.py b/app/routes/support.py index 8594c7b..59c438b 100644 --- a/app/routes/support.py +++ b/app/routes/support.py @@ -20,6 +20,38 @@ from app.utils.notifications import notify bp = Blueprint('support', __name__, url_prefix='/support') logger = logging.getLogger(__name__) + +# ── Outbound PII redaction ──────────────────────────────────────────────────── +# Groq is a THIRD PARTY. Customers routinely paste contact details (their own, +# or a coworker's) into a support question, and none of that needs to leave the +# app to get a helpful, generic answer. This scrubs a best-effort set of PII +# patterns from the copy of the text sent to Groq ONLY — the original is still +# stored verbatim in support_chat_messages, so the customer's own conversation +# history reads normally in the app and staff see what was actually said. +# +# Best-effort by design: over-redacting a support question costs nothing, while +# under-redacting leaks a real address. Order matters — the 13–19 digit card +# pattern runs before the phone pattern so a card number is not partly consumed +# as a phone number first. +import re as _re + +_PII_PATTERNS = [ + (_re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'), '[redacted-email]'), + (_re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[redacted-ssn]'), + (_re.compile(r'\b(?:\d[ -]?){13,19}\b'), '[redacted-number]'), + (_re.compile(r'\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b'), '[redacted-phone]'), +] + + +def _redact_pii(text): + """Best-effort scrub of email/phone/SSN/card-like sequences from outbound text.""" + if not text: + return text + redacted = text + for pattern, placeholder in _PII_PATTERNS: + redacted = pattern.sub(placeholder, redacted) + return redacted + # ── Groq system prompt ──────────────────────────────────────────────────────── _SYSTEM_PROMPT = """\ @@ -159,9 +191,11 @@ def chat_message(): client = Groq(api_key=api_key) messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] + # Redact before the text leaves the app for Groq. The unredacted + # originals are persisted below, so nothing is lost in-app. for m in prior[-20:]: - messages.append({'role': m.role, 'content': m.content}) - messages.append({'role': 'user', 'content': user_message}) + messages.append({'role': m.role, 'content': _redact_pii(m.content)}) + messages.append({'role': 'user', 'content': _redact_pii(user_message)}) model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') completion = client.chat.completions.create( diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index fc0a964..9741ca3 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -81,6 +81,27 @@ + +
+
+
My Data & Privacy
+
+
+

+ Download a copy of the data tied to your account, or request its + erasure. +

+ + Export My Data + + +
+
+ @@ -263,4 +284,38 @@ + + + {% endblock %} \ No newline at end of file diff --git a/app/utils/notifications.py b/app/utils/notifications.py index 48a792e..d399dc7 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -595,6 +595,23 @@ def notify_by_matrix( logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s', event_type, role_key, [u.username for u in users]) + # Scope the inspector role for "inspection_completed" to the inspection's + # OWN inspector — the person who did the work — not the whole inspector + # pool. Without this, switching the Inspector column on for this event + # notifies EVERY active inspector on EVERY submitted inspection, which on + # a tenant with a dozen inspectors is a mail storm and trains people to + # ignore notifications. Falls back to notifying nobody when the + # inspection cannot be resolved, rather than notifying everybody. + if role_key == 'inspector' and event_type == 'inspection_completed': + target_id = None + if inspection_id: + from app.models.inspection import Inspection + insp = db.session.get(Inspection, inspection_id) + target_id = insp.inspector_id if insp else None + users = [u for u in users if u.id == target_id] if target_id else [] + logger.info('MATRIX NOTIFY | event=%s | role=inspector scoped to ' + 'submitting inspector_id=%s', event_type, target_id) + # Scope customer role to facility if provided if role_key == 'customer' and facility_id: from app.utils.notifications import notify_customers_for_facility @@ -625,10 +642,22 @@ def notify_by_matrix( ) notified.add(user.id) - # ── Custom email recipients ─────────────────────────────────────────── + # ── Custom email recipients (global, per-event) ─────────────────────── + # Deduplicated on the normalised address. The matrix stores this list as + # free text, so the same person can appear twice with different casing or + # stray whitespace ("Ops@x.com" and "ops@x.com "), which previously produced + # two identical emails. `sent_emails` is built AS WE SEND, so it reflects + # what actually went out — blank entries are skipped rather than being + # counted as sent — and is then handed to the per-contract pass below so a + # recipient listed both globally and on the contract is contacted once. custom_emails = get_custom_emails_for(event_type) + sent_emails = set() for email in custom_emails: + norm = (email or '').strip().lower() + if not norm or norm in sent_emails: + continue _send_custom_email(email, title, body, link) + sent_emails.add(norm) # ── Per-contract additional recipients (phase37) ────────────────────── _notify_project_recipients( @@ -641,12 +670,12 @@ def notify_by_matrix( facility_id = facility_id, exclude_user_ids = exclude, already_notified = notified, - already_emailed = {e.strip().lower() for e in custom_emails}, + already_emailed = sent_emails, ) logger.info( 'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s', - event_type, len(notified), len(custom_emails), + event_type, len(notified), len(sent_emails), ) diff --git a/tests/test_privacy_and_routing.py b/tests/test_privacy_and_routing.py new file mode 100644 index 0000000..9e19efb --- /dev/null +++ b/tests/test_privacy_and_routing.py @@ -0,0 +1,375 @@ +""" +tests/test_privacy_and_routing.py +---------------------------------- +Behaviour tests for MT-14c — the non-schema ST catch-ups. No migrations. + +Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers: + + * notify_by_matrix() scopes the INSPECTOR role for 'inspection_completed' to + the inspection's own inspector, instead of the whole inspector pool + * custom-email recipients are deduplicated on the normalised address, and + blank entries are skipped + * support._redact_pii() scrubs email/phone/SSN/card patterns + * /auth/my-data/export returns the caller's own records and nobody else's + * /auth/my-data/delete-request hard-deletes a clean account, and ANONYMIZES + one with history rather than orphaning records + * SupportChatSession.message_count / .preview +""" + +import pytest + + +@pytest.fixture +def client(app): + """Fresh schema + test client for each test (isolated in-memory DB).""" + with app.app_context(): + from app import db + from app.models import inspector_assignment # noqa: F401 + db.drop_all() + db.create_all() + yield app.test_client() + db.session.remove() + + +def _user(username, role): + from app import db + from app.models.user import User + u = User(username=username, full_name=username.title(), role=role, + email=f'{username}@example.com', active=True) + u.set_password('pw-correct1') + db.session.add(u) + db.session.commit() + return u + + +def _facility(name='Main Office'): + from app import db + from app.models.facility import Facility + f = Facility(name=name, active=True) + db.session.add(f) + db.session.commit() + return f + + +def _template(): + from app import db + from app.models.inspection import InspectionTemplate + t = InspectionTemplate(name='Restroom Check', active=True, + form_schema=[{'id': 'f1', 'type': 'rating_5', + 'label': 'Clean', 'row': 0, 'col': 0, + 'rowSpan': 1, 'colSpan': 1}]) + db.session.add(t) + db.session.commit() + return t + + +def _login(client, user): + return client.post('/auth/login', + data={'username': user.username, 'password': 'pw-correct1'}, + follow_redirects=True) + + +# ── notify_by_matrix inspector scoping ─────────────────────────────────────── + +def test_inspection_completed_notifies_only_the_submitting_inspector(app, client): + """Without the scoping, switching the Inspector column on for this event + mails EVERY active inspector on EVERY submitted inspection.""" + from app import db + from app.models.inspection import Inspection + from app.models.notification import Notification + from app.models.notification_matrix import NotificationMatrix + from app.utils.notifications import notify_by_matrix + from app.utils.time_utils import now_eastern + + tmpl = _template() + fac = _facility() + doer = _user('ivy', 'inspector') + other1 = _user('otto', 'inspector') + other2 = _user('opal', 'inspector') + + db.session.add(NotificationMatrix(event_type='inspection_completed', + role_key='inspector', enabled=True)) + insp = Inspection(template_id=tmpl.id, facility_id=fac.id, + inspector_id=doer.id, inspection_date=now_eastern(), + status='completed', completed_at=now_eastern()) + db.session.add(insp) + db.session.commit() + + notify_by_matrix(event_type='inspection_completed', title='Done', + body='An inspection was submitted.', + inspection_id=insp.id, facility_id=fac.id) + + recipients = {n.user_id for n in + Notification.query.filter_by(event_type='inspection_completed').all()} + assert doer.id in recipients + assert other1.id not in recipients + assert other2.id not in recipients + + +def test_unresolvable_inspection_notifies_no_inspector(app, client): + """Fail closed: notify nobody rather than everybody.""" + from app import db + from app.models.notification import Notification + from app.models.notification_matrix import NotificationMatrix + from app.utils.notifications import notify_by_matrix + + _user('ivy', 'inspector') + _user('otto', 'inspector') + db.session.add(NotificationMatrix(event_type='inspection_completed', + role_key='inspector', enabled=True)) + db.session.commit() + + # No inspection_id at all. + notify_by_matrix(event_type='inspection_completed', title='Done', + body='An inspection was submitted.') + assert Notification.query.filter_by(event_type='inspection_completed').count() == 0 + + +def test_other_events_still_notify_the_whole_inspector_role(app, client): + """The scoping is specific to inspection_completed — it must not silently + narrow every other event.""" + from app import db + from app.models.notification import Notification + from app.models.notification_matrix import NotificationMatrix + from app.utils.notifications import notify_by_matrix + + a = _user('ivy', 'inspector') + b = _user('otto', 'inspector') + db.session.add(NotificationMatrix(event_type='issue_created', + role_key='inspector', enabled=True)) + db.session.commit() + + notify_by_matrix(event_type='issue_created', title='New issue', + body='Something broke.') + recipients = {n.user_id for n in + Notification.query.filter_by(event_type='issue_created').all()} + assert {a.id, b.id} <= recipients + + +# ── Custom-email dedupe ────────────────────────────────────────────────────── + +def test_custom_emails_are_deduplicated_and_blanks_skipped(app, client, monkeypatch): + """The matrix stores this list as free text, so the same person can appear + twice with different casing or stray whitespace.""" + from app import db + from app.models.notification_matrix import NotificationMatrix + from app.utils import notifications as notif + + import json + row = NotificationMatrix( + event_type='issue_created', role_key='custom', enabled=True, + # Stored as a JSON list; MT has no setter, only get_custom_emails(). + custom_emails=json.dumps(['Ops@x.com', 'ops@x.com ', ' ', 'other@x.com']), + ) + db.session.add(row) + db.session.commit() + + sent = [] + monkeypatch.setattr(notif, '_send_custom_email', + lambda email, *a, **kw: sent.append(email)) + + notif.notify_by_matrix(event_type='issue_created', title='T', body='B') + + assert len(sent) == 2 + assert {e.strip().lower() for e in sent} == {'ops@x.com', 'other@x.com'} + + +# ── PII redaction ──────────────────────────────────────────────────────────── + +@pytest.mark.parametrize('raw, expected_marker', [ + ('reach me at ops@lts.com', '[redacted-email]'), + ('call 703-555-0142 please', '[redacted-phone]'), + ('ssn is 123-45-6789', '[redacted-ssn]'), + ('card 4111 1111 1111 1111 on file', '[redacted-number]'), +]) +def test_redact_pii_scrubs_known_shapes(raw, expected_marker): + from app.routes.support import _redact_pii + out = _redact_pii(raw) + assert expected_marker in out + + +def test_redact_pii_leaves_ordinary_text_alone(): + from app.routes.support import _redact_pii + text = 'The third floor restroom needs restocking before Monday.' + assert _redact_pii(text) == text + + +def test_redact_pii_handles_empty_input(): + from app.routes.support import _redact_pii + assert _redact_pii('') == '' + assert _redact_pii(None) is None + + +# ── Self-service data export ───────────────────────────────────────────────── + +def test_export_returns_only_the_callers_own_records(app, client): + import json + from app import db + from app.models.inspection import Inspection + from app.utils.time_utils import now_eastern + + tmpl = _template() + fac = _facility() + me = _user('ivy', 'inspector') + other = _user('otto', 'inspector') + + mine = Inspection(template_id=tmpl.id, facility_id=fac.id, + inspector_id=me.id, inspection_date=now_eastern(), + status='completed') + theirs = Inspection(template_id=tmpl.id, facility_id=fac.id, + inspector_id=other.id, inspection_date=now_eastern(), + status='completed') + db.session.add_all([mine, theirs]) + db.session.commit() + mine_id, theirs_id = mine.id, theirs.id + + _login(client, me) + resp = client.get('/auth/my-data/export') + assert resp.status_code == 200 + assert 'application/json' in resp.headers['Content-Type'] + assert 'attachment' in resp.headers['Content-Disposition'] + + data = json.loads(resp.get_data(as_text=True)) + assert data['profile']['username'] == 'ivy' + ids = {i['id'] for i in data['inspections_performed']} + assert mine_id in ids + assert theirs_id not in ids # nobody else's work + + +def test_export_requires_login(client): + resp = client.get('/auth/my-data/export', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + +# ── Self-service erasure ───────────────────────────────────────────────────── + +def test_clean_account_is_hard_deleted(app, client): + from app import db + from app.models.user import User + + me = _user('ivy', 'inspector') + uid = me.id + + _login(client, me) + resp = client.post('/auth/my-data/delete-request', follow_redirects=False) + assert resp.status_code == 302 + + db.session.expire_all() + assert db.session.get(User, uid) is None + + +def test_account_with_history_is_anonymized_not_deleted(app, client): + """Hard-deleting would orphan inspection history that must be kept for + audit continuity — so erase the identity and keep the ledger.""" + from app import db + from app.models.user import User + from app.models.inspection import Inspection + from app.utils.time_utils import now_eastern + + tmpl = _template() + fac = _facility() + me = _user('ivy', 'inspector') + uid = me.id + + insp = Inspection(template_id=tmpl.id, facility_id=fac.id, + inspector_id=me.id, inspection_date=now_eastern(), + status='completed') + db.session.add(insp) + db.session.commit() + insp_id = insp.id + + _login(client, me) + client.post('/auth/my-data/delete-request', follow_redirects=False) + + db.session.expire_all() + u = db.session.get(User, uid) + assert u is not None # kept, so the FK is not orphaned + assert u.username == f'deleted_user_{uid}' + assert u.full_name is None + assert u.email == f'deleted_user_{uid}@deleted.local' + assert u.active is False + # The old password must no longer work — the hash was replaced with a + # random secret nobody holds. + assert u.check_password('pw-correct1') is False + # The history survives intact. + assert db.session.get(Inspection, insp_id) is not None + + +def test_anonymized_account_cannot_log_back_in(app, client): + from app import db + + tmpl = _template() + fac = _facility() + me = _user('ivy', 'inspector') + + from app.models.inspection import Inspection + from app.utils.time_utils import now_eastern + db.session.add(Inspection(template_id=tmpl.id, facility_id=fac.id, + inspector_id=me.id, inspection_date=now_eastern(), + status='completed')) + db.session.commit() + + _login(client, me) + client.post('/auth/my-data/delete-request', follow_redirects=True) + + resp = client.post('/auth/login', + data={'username': 'ivy', 'password': 'pw-correct1'}, + follow_redirects=False) + assert resp.status_code == 200 # re-rendered form, not a redirect + assert client.get('/dashboard', follow_redirects=False).status_code == 302 + + +def test_deletion_requires_login(client): + resp = client.post('/auth/my-data/delete-request', follow_redirects=False) + assert resp.status_code == 302 + assert '/auth/login' in resp.headers['Location'] + + +# ── SupportChatSession helpers ─────────────────────────────────────────────── + +def test_support_session_count_and_preview(app, client): + from app import db + from app.models.support import SupportChatSession, SupportChatMessage + + cust = _user('cara', 'customer') + sess = SupportChatSession(customer_id=cust.id) + db.session.add(sess) + db.session.commit() + + assert sess.message_count == 0 + assert sess.preview == '(no messages)' + + db.session.add_all([ + SupportChatMessage(session_id=sess.id, role='user', + content='How do I export a report?'), + SupportChatMessage(session_id=sess.id, role='assistant', + content='Open Reports, then Export.'), + ]) + db.session.commit() + db.session.refresh(sess) + + assert sess.message_count == 2 + # The opening question, not the assistant's reply. + assert sess.preview == 'How do I export a report?' + + +def test_preview_skips_a_leading_assistant_message(app, client): + from app import db + from app.models.support import SupportChatSession, SupportChatMessage + + cust = _user('cara', 'customer') + sess = SupportChatSession(customer_id=cust.id) + db.session.add(sess) + db.session.commit() + + db.session.add_all([ + SupportChatMessage(session_id=sess.id, role='assistant', + content='Hi! How can I help?'), + SupportChatMessage(session_id=sess.id, role='user', + content='My badge scanner is broken.'), + ]) + db.session.commit() + db.session.refresh(sess) + + assert sess.preview == 'My badge scanner is broken.'