# Backend Testing
## Overview
The backend uses **pytest** with **pytest-asyncio** for all testing. Tests run against an in-memory SQLite database (via aiosqlite) so no PostgreSQL instance is required. The test suite is split into two layers:
| Layer | Location | Purpose |
|---|---|---|
| Functional | `tests/` | Happy-path and behavioural correctness |
| Security | `tests/security/` | OWASP ASVS control coverage + API Security Top 10 |
Current suite status, verified on **2026-03-30**:
- `122` tests collected
- `122` passed
---
## Prerequisites
Run backend tests from the `backend/` directory so `pytest.ini` is discovered and imports resolve the same way they do in CI:
```bash
cd backend
python -m pip install -r requirements.txt
```
Notes:
- Tests override `app.database.get_db`, so the PostgreSQL `DATABASE_URL` in `.env` is **not** used during test execution
- Required test dependencies already live in `requirements.txt` (`pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`, `aiosqlite`)
- Interactive API docs are disabled by default via `ENABLE_DOCS=false`; set `ENABLE_DOCS=true` locally if you need `/docs` or `/redoc`
---
## Running tests
```bash
cd backend
# All tests
python -m pytest
# Collect test names without executing them
python -m pytest --collect-only -q
# Security tests only
python -m pytest tests/security/
# Functional tests only (exclude security)
python -m pytest tests/ --ignore=tests/security/
# Stop on first failure
python -m pytest -x
# With coverage report
python -m pytest --cov=app --cov-report=term-missing
# Single file
python -m pytest tests/security/test_authn.py -v
# Single test
python -m pytest tests/security/test_authn.py::TestJWTSecurity::test_alg_none_attack_rejected -v
# Name filter within a file/class
python -m pytest tests/security/test_authn.py -k alg_none -v
```
Path-based selection is the reliable split today. `backend/pytest.ini` declares a `security` marker, but the current suite is organized primarily by directory rather than by applied markers.
---
## Pytest configuration
`backend/pytest.ini` currently sets:
| Setting | Value | Effect |
|---|---|---|
| `asyncio_mode` | `auto` | Async tests run without needing per-test event-loop boilerplate |
| `testpaths` | `tests` | Test discovery is rooted under `backend/tests/` |
| `addopts` | `-v --tb=short` | Verbose test names and shortened tracebacks by default |
| `markers` | `security` | Reserved marker for hardening/security tests |
---
## Test infrastructure
### `tests/conftest.py`
Shared fixtures available to all tests:
| Fixture | Scope | Description |
|---|---|---|
| `setup_database` | session | Creates all ORM tables once at the start of the session |
| `clean_tables` | function (autouse) | Deletes all rows between each test |
| `reset_rate_limiter` | function (autouse) | Clears slowapi's in-memory counters — prevents 429 cascades |
| `client` | function | `httpx.AsyncClient` wired to the FastAPI app via ASGITransport |
| `admin_user` | function | Creates a live `User` row in the test DB |
| `admin_token` | function | Logs in as `admin_user` and returns the Bearer access token |
| `db_session` | function | Yields a live `AsyncSession` for direct DB state manipulation |
| `TestSessionLocal` | module helper | Async sessionmaker imported directly by some tests for manual inserts/queries |
### Database
Tests use `sqlite+aiosqlite:///:memory:` — a fresh in-memory SQLite instance for the entire pytest session. Tables are created once (`setup_database`), and every row is deleted between tests (`clean_tables`). This makes each test fully isolated without the overhead of dropping and recreating schema.
The app's normal `get_db()` dependency is overridden inside the `client` fixture, so HTTP requests made during tests always talk to SQLite even if local development is configured for PostgreSQL.
### Rate limiter reset
slowapi stores rate-limit counters in memory. Without resetting them, the 5/min limit on `/auth/login` exhausts after 5 tests, causing every subsequent `admin_token` fixture to fail with 429. The `reset_rate_limiter` autouse fixture calls `limiter._storage.reset()` before each test.
---
## Functional test suite
Functional tests live directly under `tests/` and currently cover the main happy-path API behavior:
| File | Coverage |
|---|---|
| `test_auth.py` | Login, invalid credentials, refresh-token rotation |
| `test_pages.py` | Page CRUD, public visibility rules, auth requirements |
| `test_analytics.py` | Authenticated analytics summary aggregation |
| `test_analytics_ingest.py` | Public analytics ingest, anon cookie creation, metadata filtering |
---
## Security test suite
Security tests live in `tests/security/` and are organised by OWASP control domain. Each test references the relevant **ASVS requirement** and **API Security Top 10 category** in its docstring.
### OWASP alignment matrix
| File | ASVS chapters | API Top 10 categories |
|---|---|---|
| `test_authn.py` | V2 Authentication, V3 Session Management | API2 Broken Authentication |
| `test_authz.py` | V4 Access Control | API1 BOLA, API3 Object Property Auth, API5 Function Level Auth |
| `test_input.py` | V5 Input Validation & Sanitization | API4 Resource Consumption, API8 Security Misconfiguration |
| `test_config.py` | V9 Communication, V14 Configuration | API8 Security Misconfiguration, API9 Improper Inventory |
| `test_rate_limit.py` | V13 API & Web Service | API4 Resource Consumption, API6 Business Flow, API7 SSRF |
---
### `test_authn.py` — Authentication
**ASVS V2.1 Credential validation**
- Wrong password → 401
- Unknown email → 401 (same response as wrong password; prevents user enumeration)
- Empty / null password → 401 or 422
- Missing fields → 422
- Unicode password → 401 (no crash)
- 1 000-char password → 401 (bcrypt `ValueError` is caught and treated as invalid credentials)
- Inactive account → 401
**ASVS V3.5 JWT security**
- No Authorization header → 401/403
- Garbage Bearer value → 401/403
- JWT signed with wrong key → 401/403
- Expired JWT → 401/403
- `alg: none` attack (unsigned token) → 401/403
- Tampered payload with valid signature → 401/403
- Token in query string → 401/403 (URL leaks tokens to logs and Referer)
- HTTP Basic Auth scheme → 401/403
**ASVS V3.5 Refresh token rotation**
- Token is rotated; old token is revoked and returns 401 on reuse
- Forged refresh token → 401
- Empty refresh token → 401/422
- New access token issued after rotation is accepted
---
### `test_authz.py` — Authorization
**ASVS V4.1 | API5 Unauthenticated write access**
- Parametrized over all 8 write/protected endpoints
- Verifies 401/403 for: `POST /pages`, `PUT /pages/:slug`, `DELETE /pages/:slug`, `POST /posts`, `PUT /posts/:slug`, `DELETE /posts/:slug`, `PUT /settings`, `GET /analytics/summary`
**ASVS V4.2 | API1 Object-level authorization (BOLA)**
- Non-existent slug → 404 (not 403, not 500)
- Path-traversal slugs (`../admin`, `%2e%2e/secret`) → 404/422
- DELETE/PUT on absent resource → 404
**ASVS V4.3 | API3 Mass assignment**
- Injected fields (`is_admin`, `hashed_password`, `id`, `internal_field`) are stripped from create and update requests
- Caller-supplied `id` is ignored; DB assigns it
---
### `test_input.py` — Input validation and sanitization
**ASVS V5.1 Schema validation**
- Required fields enforced on pages and posts
- Null required fields → 422
- `event_type`, `page`, `element` field max-lengths enforced
- Malformed JSON body → 422 (not 500)
**ASVS V5.2 | API8 HTML sanitization (nh3/ammonia)**
All vectors are tested at create and update time:
- `` — stripped
- `onerror="..."`, `onclick="..."` event attributes — stripped
- `href="javascript:..."` — stripped
- `