Aug 5 - Update code to follow up ST - MT14c
This commit is contained in:
@@ -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'<SupportChatSession {self.id}>'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+36
-2
@@ -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(
|
||||
|
||||
@@ -81,6 +81,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── My Data & Privacy (phase51) ───────────────────────────── -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>My Data & Privacy</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Download a copy of the data tied to your account, or request its
|
||||
erasure.
|
||||
</p>
|
||||
<a href="{{ url_for('auth.export_my_data') }}"
|
||||
class="btn btn-outline-secondary btn-sm w-100 mb-2">
|
||||
<i class="bi bi-download me-1"></i>Export My Data
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm w-100"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteMyDataModal">
|
||||
<i class="bi bi-trash me-1"></i>Delete My Account & Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
||||
@@ -263,4 +284,38 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete My Data Modal (phase51) ──────────────────────────────────── -->
|
||||
<div class="modal fade" id="deleteMyDataModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Delete My Account & Data</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('auth.request_my_data_deletion') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
This action is <strong>permanent</strong> and logs you out immediately.
|
||||
</div>
|
||||
<p class="mb-0">
|
||||
If you have no inspection or issue history tied to your account, it will be
|
||||
<strong>permanently deleted</strong>. If you do have history (common for staff
|
||||
accounts), your name, username, and email will be replaced with a
|
||||
non-identifying placeholder and the account deactivated — historical records
|
||||
stay intact for audit continuity but will no longer identify you.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash me-1"></i>Confirm Deletion
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.'
|
||||
Reference in New Issue
Block a user