""" tests/test_enrollment.py ------------------------ Behaviour tests for MT-17 — the enrollment intake form. Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers: * the public form renders without a login and carries no authenticated nav * a submission is stored, is readable back, and the customer's own answers are immutable afterwards (only the office block and status are writable) * malformed submission ids are rejected before touching the filesystem (path traversal) * the admin views require a login * TENANT ISOLATION: with multi-tenancy enabled, submissions are filed under a per-tenant directory, one tenant's admin list never contains another's, and an unresolved tenant raises rather than falling back to the shared root The isolation tests are the reason this module exists. ST keeps every submission in one flat directory, which in MT would put the names, emails and phone numbers of one organisation's staff in front of another organisation's admin. `test_unresolved_tenant_refuses_rather_than_sharing` pins the fail-closed behaviour specifically, because a fallback to the root directory would be a silent cross-tenant leak rather than a visible error. """ import os import pytest @pytest.fixture def client(app, tmp_path): """Fresh schema + test client, with enrollment storage in a temp dir.""" with app.app_context(): from app import db from app.models import inspector_assignment # noqa: F401 db.drop_all() db.create_all() app.config['ENROLLMENT_DIR'] = str(tmp_path / 'enrollments') os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True) 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 _login(client, user): resp = client.post('/auth/login', data={'username': user.username, 'password': 'pw-correct1'}, follow_redirects=True) assert 'Login - ' not in resp.get_data(as_text=True), 'login failed' return resp def _record(app, project_name='Acme Tower'): """Build and save one submission through the real storage layer. Mirrors the payload routes.submit() actually writes — the admin templates read every one of these keys, so a minimal stub renders as UndefinedError rather than exercising the page. """ from datetime import datetime from app.enrollment import storage, schema now = datetime.now() rec = { 'project_name': project_name, 'request_by': 'Reception', 'requester_email': 'reception@example.com', 'date_requested': now.strftime('%Y-%m-%d'), 'notes': '', 'people': [], 'matrix': {}, 'mobile_app': {}, 'id': storage.new_id(now), 'submitted_at': now.isoformat(timespec='seconds'), 'office': {k: '' for k, _ in schema.OFFICE_FIELDS}, 'status': 'new', 'meta': {'ip': '127.0.0.1', 'user_agent': 'pytest'}, } storage.save(rec) return rec # ── Public form ────────────────────────────────────────────────────────────── def test_public_form_renders_without_login(client): resp = client.get('/enrollment') assert resp.status_code == 200 body = resp.get_data(as_text=True) assert 'Login - ' not in body # Login-free page: it must not carry the authenticated portal nav. assert 'jqc-sidebar' not in body def test_admin_list_requires_login(client): resp = client.get('/enrollment/admin') assert resp.status_code == 302 assert '/auth/login' in resp.headers.get('Location', '') def test_admin_list_renders_for_admin(client, app): admin = _user('ada', 'admin') _login(client, admin) with app.test_request_context(): _record(app, 'Listed Site') resp = client.get('/enrollment/admin') assert resp.status_code == 200 assert 'Listed Site' in resp.get_data(as_text=True) # ── Storage round-trip ─────────────────────────────────────────────────────── def test_submission_round_trips(client, app): from app.enrollment import storage with app.test_request_context(): rec = _record(app) back = storage.load(rec['id']) assert back is not None assert back['project_name'] == 'Acme Tower' def test_customer_answers_are_immutable_after_submission(client, app): """Only the office block and status are writable — the file must stay an accurate record of what the customer actually asked for.""" from app.enrollment import storage with app.test_request_context(): rec = _record(app, project_name='Original Name') storage.update_office(rec['id'], {'assigned_to': 'ops'}, 'in_review') back = storage.load(rec['id']) assert back['project_name'] == 'Original Name' # untouched assert back['office']['assigned_to'] == 'ops' assert back['status'] == 'in_review' @pytest.mark.parametrize('bad_id', [ '../../etc/passwd', '..%2f..%2fetc', 'not-an-id', '', '20260101-120000-ZZZZZZZZ', # non-hex suffix ]) def test_malformed_ids_are_rejected(client, app, bad_id): """A crafted id must never be interpolated into a filesystem path.""" from app.enrollment import storage with app.test_request_context(): assert storage.load(bad_id) is None # ── Tenant isolation ───────────────────────────────────────────────────────── class _FakeTenant: def __init__(self, tid): self.id = tid def test_single_tenant_mode_uses_the_root_directory(client, app): """With MT off, behaviour matches ST exactly — no per-tenant subdirectory.""" from app.enrollment import storage with app.test_request_context(): assert app.config.get('MULTI_TENANT_ENABLED') is False assert storage.enrollment_dir() == app.config['ENROLLMENT_DIR'] def test_submissions_are_filed_per_tenant(client, app): from flask import g from app.enrollment import storage root = app.config['ENROLLMENT_DIR'] app.config['MULTI_TENANT_ENABLED'] = True try: with app.test_request_context(): g.tenant = _FakeTenant(7) assert storage.enrollment_dir() == os.path.join(root, 't7') _record(app, 'Tenant Seven Site') with app.test_request_context(): g.tenant = _FakeTenant(9) assert storage.enrollment_dir() == os.path.join(root, 't9') finally: app.config['MULTI_TENANT_ENABLED'] = False assert os.path.isdir(os.path.join(root, 't7')) def test_one_tenant_never_sees_anothers_submissions(client, app): """The leak this phase exists to prevent.""" from flask import g from app.enrollment import storage app.config['MULTI_TENANT_ENABLED'] = True try: with app.test_request_context(): g.tenant = _FakeTenant(7) _record(app, 'Seven Confidential') with app.test_request_context(): g.tenant = _FakeTenant(9) others = storage.load_all() names = [r.get('project_name') for r in others] assert 'Seven Confidential' not in names assert others == [] finally: app.config['MULTI_TENANT_ENABLED'] = False def test_unresolved_tenant_refuses_rather_than_sharing(client, app): """Fail closed. Falling back to the shared root would be a silent cross-tenant leak; an exception is loud and safe.""" from flask import g from app.enrollment import storage app.config['MULTI_TENANT_ENABLED'] = True try: with app.test_request_context(): g.tenant = None with pytest.raises(storage.TenantUnresolved): storage.enrollment_dir() finally: app.config['MULTI_TENANT_ENABLED'] = False # ── Branding ───────────────────────────────────────────────────────────────── def test_no_hardcoded_upstream_branding_reaches_a_tenant(client): """ST hardcodes its own company name and a personal address in this module. Neither may ever be shown to another tenant's customers.""" import pathlib root = pathlib.Path('app/enrollment') for path in root.rglob('*'): if path.is_file() and path.suffix in ('.py', '.html'): text = path.read_text(encoding='utf-8') assert 'L.T' not in text, f'{path} still carries upstream branding' assert 'da.nguyen8744' not in text, f'{path} still carries a personal address'