Files
JQC_multi_tenant/tests/test_external_inspector.py
T
2026-08-19 16:34:42 -04:00

353 lines
14 KiB
Python

"""
tests/test_external_inspector.py
--------------------------------
Behaviour tests for MT-15 — the 'external_inspector' role.
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
* the role predicates: is_inspector covers both roles, is_external_inspector
does not, and role_label renders the display name
* get_inspector_scope() applies the SAME InspectorAssignment scoping to an
external inspector as to an internal one — the regression this phase exists
to prevent is an external inspector falling into the privileged branch
(scope None = unrestricted) and seeing every contract
* an unassigned external inspector sees nothing (strict mode)
* facility / inspection / issue list routes stay scoped for the new role
* the notification matrix exposes an External Inspector column whose defaults
mirror the Inspector column
* an invited Customer Inspector is created with password_set False and a
set-password token, and cannot log in until they use it
* creating any other role still requires a password
* creating a Customer Inspector goes through Customer Management, and
User Management no longer offers the role (phase51)
* the staff assign-contracts page redirects a customer-side account to
the page that owns it, and still serves our own inspectors
The regression guard that matters most is
test_external_inspector_scope_is_not_unrestricted: if a future edit reverts a
membership test back to `role == 'inspector'`, an external inspector silently
gains org-wide visibility, which is a cross-customer data leak rather than a
cosmetic bug.
"""
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
# get_inspector_scope() imports this model lazily, so the mapper is not
# registered at create_all() time and the table is missing when a
# logged-in inspector hits the dashboard. Import it up front.
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, **kw):
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, **kw)
u.set_password('pw-correct1')
db.session.add(u)
db.session.commit()
return u
def _seed():
"""Two contracts, one facility each, and the three inspector-ish accounts."""
from app import db
from app.models.facility import Facility
from app.models.inspection import InspectionTemplate
from app.models.project import Project
proj_a = Project(name='Contract A', active=True)
proj_b = Project(name='Contract B', active=True)
tmpl = 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_all([proj_a, proj_b, tmpl])
db.session.commit()
fac_a = Facility(name='Client A Site', active=True, project_id=proj_a.id)
fac_b = Facility(name='Client B Site', active=True, project_id=proj_b.id)
db.session.add_all([fac_a, fac_b])
db.session.commit()
internal = _user('ivy', 'inspector')
external = _user('xan', 'external_inspector')
admin = _user('ada', 'admin')
return dict(tmpl=tmpl, proj_a=proj_a, proj_b=proj_b,
fac_a=fac_a, fac_b=fac_b,
internal=internal, external=external, admin=admin)
def _assign(user, project):
from app import db
from app.models.inspector_assignment import InspectorAssignment
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
db.session.commit()
def _login(client, user):
return client.post('/auth/login',
data={'username': user.username, 'password': 'pw-correct1'},
follow_redirects=True)
# ── Role predicates ──────────────────────────────────────────────────────────
def test_is_inspector_covers_both_inspector_roles(client):
env = _seed()
assert env['internal'].is_inspector is True
assert env['external'].is_inspector is True
assert env['admin'].is_inspector is False
def test_is_external_inspector_distinguishes_the_two(client):
env = _seed()
assert env['external'].is_external_inspector is True
assert env['internal'].is_external_inspector is False
def test_role_label_renders_display_name(client):
env = _seed()
# phase51 renamed the LABEL only — the role value is still
# 'external_inspector' (see User.ROLE_LABELS).
assert env['external'].role_label == 'Customer Inspector'
assert env['internal'].role_label == 'Inspector'
def test_inspector_roles_tuple_contains_both(client):
from app.models.user import User
assert set(User.INSPECTOR_ROLES) == {'inspector', 'external_inspector'}
# ── Scoping — the security-critical behaviour ────────────────────────────────
def test_external_inspector_scope_is_not_unrestricted(client):
"""An external inspector must NEVER get scope None (= see everything).
This is the regression this phase exists to prevent. If a membership test
is ever reverted to `role == 'inspector'`, get_inspector_scope() returns
None for the external role and every downstream query drops its facility
filter — a cross-customer leak, not a cosmetic bug.
"""
from app.utils.scope import get_inspector_scope
env = _seed()
_assign(env['external'], env['proj_a'])
scope = get_inspector_scope(env['external'])
assert scope is not None, 'external inspector fell into the unrestricted branch'
assert scope == [env['fac_a'].id]
assert env['fac_b'].id not in scope
def test_external_and_internal_inspectors_scope_identically(client):
from app.utils.scope import get_inspector_scope
env = _seed()
_assign(env['internal'], env['proj_a'])
_assign(env['external'], env['proj_a'])
assert (get_inspector_scope(env['external'])
== get_inspector_scope(env['internal'])
== [env['fac_a'].id])
def test_unassigned_external_inspector_sees_nothing(client):
"""Strict mode: no assignments means an empty list, not unrestricted."""
from app.utils.scope import get_inspector_scope
env = _seed()
assert get_inspector_scope(env['external']) == []
def test_non_inspector_roles_still_unrestricted(client):
from app.utils.scope import get_inspector_scope
env = _seed()
assert get_inspector_scope(env['admin']) is None
# ── Route-level scoping ──────────────────────────────────────────────────────
def test_facility_list_is_scoped_for_external_inspector(client):
env = _seed()
_assign(env['external'], env['proj_a'])
_login(client, env['external'])
resp = client.get('/facilities/')
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert 'Client A Site' in body
assert 'Client B Site' not in body
def test_inspection_and_issue_lists_load_for_external_inspector(client):
"""The new role must not 403 or 500 on the core scoped list routes."""
env = _seed()
_assign(env['external'], env['proj_a'])
_login(client, env['external'])
for path in ('/inspections/', '/issues/'):
resp = client.get(path)
assert resp.status_code == 200, f'{path} returned {resp.status_code}'
# ── Notification matrix ──────────────────────────────────────────────────────
def test_matrix_exposes_external_inspector_column(client):
from app.models.notification_matrix import MATRIX_ROLES
keys = [k for k, _ in MATRIX_ROLES]
assert 'external_inspector' in keys
def test_matrix_defaults_mirror_the_inspector_column(client):
from app.models.notification_matrix import MATRIX_DEFAULTS, MATRIX_EVENTS
mirrored = 0
for event in MATRIX_EVENTS:
if (event, 'inspector') in MATRIX_DEFAULTS:
assert (event, 'external_inspector') in MATRIX_DEFAULTS, event
assert (MATRIX_DEFAULTS[(event, 'external_inspector')]
== MATRIX_DEFAULTS[(event, 'inspector')]), event
mirrored += 1
assert mirrored > 0, 'no inspector defaults found to mirror'
# ── Invitation flow ──────────────────────────────────────────────────────────
def test_creating_external_inspector_invites_instead_of_setting_password(client):
"""phase51 moved this account type to Customer Management.
The invariant is unchanged and is what this test guards: a Customer
Inspector is INVITED, never given a password we chose. Only the door
changed — /customers/new instead of /auth/users/new — because both
customer-side roles are now owned by /customers.
"""
from app.models.user import User
env = _seed()
_login(client, env['admin'])
resp = client.post('/customers/new', data={
'full_name': 'New Xan',
'email': 'newxan@example.com',
'role': 'external_inspector',
}, follow_redirects=True)
assert resp.status_code == 200
created = User.query.filter_by(email='newxan@example.com').first()
assert created is not None, 'customer inspector was not created'
assert created.role == 'external_inspector'
# Invited, not password-set: login is blocked until they use the link.
assert created.password_set is False
assert created.set_password_token is not None
assert created.set_password_token_expires is not None
def test_user_management_no_longer_creates_customer_side_accounts(client):
"""The other half of the move: User Management must not mint one.
UserForm stopped offering 'external_inspector', so a crafted POST hits
SelectField validation and nothing is created. Without this, a second
creation path could quietly reappear and skip the invitation flow.
"""
from app.models.user import User
env = _seed()
_login(client, env['admin'])
client.post('/auth/users/new', data={
'username': 'sneaky',
'full_name': 'Sneaky Xan',
'email': 'sneaky@example.com',
'role': 'external_inspector',
'password': '',
'confirm_password': '',
}, follow_redirects=True)
assert User.query.filter_by(username='sneaky').first() is None
def test_invited_external_inspector_cannot_log_in_until_setup(client):
from app.models.user import User
env = _seed()
_login(client, env['admin'])
client.post('/customers/new', data={
'full_name': 'New Xan',
'email': 'newxan@example.com',
'role': 'external_inspector',
}, follow_redirects=True)
client.get('/auth/logout', follow_redirects=True)
created = User.query.filter_by(email='newxan@example.com').first()
# The placeholder hash is random, so no password can work; assert the
# account is in the blocked state rather than guessing a credential.
assert created.password_set is False
def test_creating_a_normal_role_still_requires_a_password(client):
from app.models.user import User
env = _seed()
_login(client, env['admin'])
resp = client.post('/auth/users/new', data={
'username': 'nopw',
'full_name': 'No Password',
'email': 'nopw@example.com',
'role': 'inspector',
'password': '',
'confirm_password': '',
}, follow_redirects=True)
assert resp.status_code == 200
assert User.query.filter_by(username='nopw').first() is None
# ── Assign-contracts page ────────────────────────────────────────────────────
def test_assign_contracts_page_redirects_customer_side_to_customer_management(client):
"""phase51: one editor per account, not two.
The staff assign-contracts URL still exists for our own inspectors, but a
customer-side account is redirected to the page that now owns it. Editing
one through UserForm would fail anyway — 'external_inspector' is no longer
an offered role choice, so SelectField would reject the stored value.
"""
env = _seed()
_login(client, env['admin'])
resp = client.get(f"/auth/users/{env['external'].id}/assign-contracts")
assert resp.status_code == 302
assert f"/customers/{env['external'].id}" in resp.headers['Location']
# …and the page it redirects to is the real editor.
assert client.get(f"/customers/{env['external'].id}").status_code == 200
def test_assign_contracts_page_still_accepts_our_own_inspector(client):
"""The staff path must not have been broken by the redirect guard."""
env = _seed()
_login(client, env['admin'])
resp = client.get(f"/auth/users/{env['internal'].id}/assign-contracts")
assert resp.status_code == 200
def test_assign_contracts_page_still_rejects_non_inspectors(client):
env = _seed()
_login(client, env['admin'])
resp = client.get(f"/auth/users/{env['admin'].id}/assign-contracts")
assert resp.status_code == 404