Testing¶
How the test suite works in this repo - what we check, how it's set up, and how to run or add tests. No MXroute, Cloudflare, or NPM credentials needed; everything hits a throwaway SQLite DB and mocked APIs.
What we're trying to catch¶
Tests focus on stuff that would be embarrassing or dangerous if it broke:
- Who can access which domain (auth + delegations)
- Mailbox and recovery-email handling
- DNS setup being safe to re-run (no duplicate records)
- Password-reset portals staying isolated
We're not chasing 100% line coverage. External APIs are mocked; SQLite is real but thrown away after each run.
Running tests¶
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
pytest
That single command also runs JavaScript unit tests (static/js/*.test.js via Node's built-in node --test). You need Node 18+ on your machine; CI installs it automatically.
JS only (explicit test files - portable across Node versions):
node --test static/js/*.test.js
Coverage report (same as CI):
pytest --cov=services --cov=models --cov=utils --cov-report=term-missing
One file or one test:
pytest tests/test_emails.py -v
pytest tests/test_emails.py::test_create_email_saves_recovery_on_success -v
GitHub Actions runs this on every push and PR - see .github/workflows/test.yml.
Three layers (fast → thorough)¶
Layer 1 - Pure logic¶
Files: test_validators.py, test_auth_helpers.py, static/js/*.test.js
Plain functions, plain asserts. No DB, no HTTP, no mocks.
Good for validation rules and permission math - when something fails, you know exactly which function misbehaved.
Frontend helpers live in static/js/ as ES modules (permissions.js, utils.js, cache.js). browser-entry.js loads them onto window.Mxm before the classic static/js/app/ scripts run. Load order is documented in frontend-app-scripts.md and enforced in templates/index.html. App scripts keep thin wrappers so onclick handlers still work. Tests use Node's built-in runner - no npm install, no Vitest.
Layer 2 - Services with mocks¶
Files: test_cloudflare_idempotent.py, test_reset_portal_dns.py, etc.
Real service code runs; cf_request, mx_request_raw, DNS lookups get patched out.
Good for "read before write" DNS logic and deploy orchestration without hitting the network.
with patch("services.cloudflare.cf_request") as mock_cf:
result = cf_upsert_txt(...)
mock_cf.assert_not_called() # already correct - skip create
Layer 3 - HTTP through Flask¶
Files: test_auth_delegation.py, test_emails.py, test_forwarders.py, test_spam.py, test_reset_portal.py
test_client() sends real requests through auth, CSRF, decorators, and route handlers. MXroute/Cloudflare still mocked; SQLite is real.
Good for wiring bugs - wrong decorator, CSRF forgotten, session shape wrong, DB side effect missing after a successful API call.
Shared plumbing¶
tests/conftest.py¶
Runs before anything else:
- Creates a temp
.dband setsDATABASE_FILEbeforemodels.dbimports (it reads that path at import time). - Sets dummy Cloudflare/NPM env vars so deploy code doesn't complain.
- Provides fixtures:
fresh_db,client,db_connection.
tests/helpers.py¶
| Helper | What it does |
|---|---|
insert_user_with_grants() |
User + delegation rows in SQLite |
prime_authenticated_session() |
Log in via session (matches real login shape) |
auth_post_headers() |
CSRF + JSON for POST/PATCH/DELETE |
mx_json_response() |
Fake MXroute response tuple for mocks |
csrf_token_from_response() |
Pull CSRF cookie from a GET |
Cleaning up between tests¶
Route tests often use an autouse fixture that wipes users, delegations, etc. before each test. We share one session DB file - without cleanup you get UNIQUE constraint explosions and weird cross-test pollution.
Mocking externals¶
Patch where the route imports the function:
| Service | Patch target |
|---|---|
| MXroute | routes.emails.*, routes.spam.*, routes.domains.* |
| OIDC token/userinfo | routes.auth.requests.post, routes.auth.requests.get (or patch get_oidc_config for discovery) |
| Cloudflare | services.cloudflare.cf_request, routes.cloudflare.* |
| Reverse proxy / portal deploy | services.reset_portal_deploy.*, services.reverse_proxy.* |
| Public DNS | dns.resolver.Resolver, etc. |
CI should never need real API keys.
Fake MXroute responses¶
from tests.helpers import mx_json_response
with patch("routes.emails.audited_mx", return_value=mx_json_response({"success": True}, 201)):
response = client.post("/api/domains/example.com/email-accounts", ...)
mx_json_response builds a real Flask Response inside an app context - same shape production returns.
Auth in tests¶
Protected routes need:
- A session user (
prime_authenticated_sessionafter inserting the user in SQLite), or anAuthorization: Bearer mxm_…header for API token routes - CSRF header on session-based mutating requests (not required for Bearer tokens)
Delegated users go in via insert_user_with_grants() - permissions should match what you'd set in the Access Control UI.
OIDC tests use enable_oidc_settings() plus patch_oidc_http() to fake the token and userinfo endpoints. prime_oidc_state() seeds the CSRF state the callback expects in session.
What's covered¶
| Area | Tests | Layer |
|---|---|---|
| Validators | test_validators.py |
1 |
| Permission helpers | test_auth_helpers.py |
1 |
| Login, delegations, admin API | test_auth_delegation.py |
3 |
| API tokens (Bearer auth) | test_api_tokens.py |
3 |
| API docs routes | test_api_docs.py |
3 |
| Mail client settings | test_mail_client.py, test_emails.py |
2-3 |
| DNS health monitor | test_dns_monitor.py |
2 |
| Cloudflare bulk DNS fix | test_cloudflare_bulk.py, test_cloudflare_bulk_route.py |
2-3 |
| Health endpoint | test_health.py |
3 |
| OIDC redirect + callback | test_oidc.py |
3 |
| Mailboxes + recovery email | test_emails.py |
3 |
| Forwarders, catch-all, pointers | test_forwarders.py |
3 |
| Spam settings, lists | test_spam.py |
3 |
| Password reset tokens (DB) | test_password_reset.py |
1-2 |
| Public password-reset API | test_password_reset_api.py |
3 |
| DNS health comparison | test_dns_health.py |
6 |
| Per-domain DMARC policy | test_domain_dmarc.py |
4 |
| Domain admin routes | test_domains.py |
3 |
| Settings cache | test_settings_cache.py |
2 |
| Cloudflare idempotency | test_cloudflare_idempotent.py |
2 |
Cloudflare DNS fix (deploy_missing_dns_to_cf) |
test_cloudflare_dns_fix.py |
2 |
| Cloudflare DNS wizard (HTTP) | test_cloudflare_wizard.py |
3 |
| Reset portal routing / CSRF | test_reset_portal.py |
3 |
| Portal DNS checks | test_reset_portal_dns.py |
2 |
| Portal deploy | test_reset_portal_deploy.py, test_reverse_proxy.py |
2 |
| Frontend JS (permissions, utils, cache) | static/js/*.test.js + test_javascript.py |
1 |
What's not covered yet¶
Being upfront about gaps:
- Most of
static/js/app/(DOM wiring, API orchestration - only extracted pure helpers are tested) - Load / concurrency stress
PRs welcome in those areas; same patterns as above.
Adding a test¶
- Pick the layer - don't use Flask if you don't need it.
- Use
conftestfixtures +tests/helpers.py. - Clean up DB state if your test writes rows.
- Mock externals; use
assert_not_called()when validation should block before MXroute. pytest your_file.py -vbefore you push.
Example pattern¶
def test_create_email_rejects_invalid_username(fresh_db, client, emails_token):
with patch("routes.emails.audited_mx") as mock_mx:
response = client.post(
"/api/domains/example.com/email-accounts",
headers=auth_post_headers(emails_token),
json={"username": "bad name", "password": "Abcd123!"},
)
assert response.status_code == 400
mock_mx.assert_not_called()
Status code → side effects → mock called or not. That's the usual shape.
Config files¶
| File | Role |
|---|---|
requirements-dev.txt |
pytest + pytest-cov |
pytest.ini |
finds tests/test_*.py |
static/js/*.test.js |
Node node --test frontend unit tests |
.github/workflows/test.yml |
CI (Python + Node) |
Keeping this doc honest¶
If you add tests in a new area, tweak this file in the same PR so it still matches reality. Future-you (and anyone else poking around) will thank you.
Related guides¶
| Guide | Topic |
|---|---|
| HTTP API | Token auth behaviour under test |
| Getting started | Local dev setup without Docker |
| Access control | Delegation behaviour under test |
| Password reset | Reset API and portal test coverage |
| Configuration | Env vars used by test fixtures |