This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
View File
+139
View File
@@ -0,0 +1,139 @@
"""
Test configuration and shared fixtures.
Uses an in-memory SQLite async database to avoid needing PostgreSQL in CI.
The get_db dependency is overridden so all tests use the test database.
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from typing import AsyncGenerator
from app.main import app
from app.database import get_db
from app.models.base import Base
from app.models import User, Page, BlogPost, SiteSettings, RefreshToken, Experiment, ExperimentVariant, ExperimentEvent # noqa: F401 register models
from app.models import Member, MemberVerificationCode, MemberRefreshToken, Walk, Booking, AdminMessage # noqa: F401 register member models
from app.models import ContactLead # noqa: F401 register contact lead model
from app.auth.password import hash_password
from app.services.experiments import sync_experiment_registry
# pytest-asyncio settings
pytest_plugins = ["pytest_asyncio"]
# In-memory SQLite for tests
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False,
)
TestSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with TestSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_database():
"""Create all tables once per test session."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionLocal() as session:
await sync_experiment_registry(session)
await session.commit()
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await test_engine.dispose()
@pytest_asyncio.fixture(autouse=True)
async def clean_tables():
"""Truncate tables between tests for isolation."""
yield
async with test_engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
await conn.execute(table.delete())
async with TestSessionLocal() as session:
await sync_experiment_registry(session)
await session.commit()
@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""Reset slowapi's in-memory rate-limit counters before each test.
Without this, rapid sequential test runs exhaust the per-IP limits
(e.g. 5/min on /auth/login) and cause cascading 429 errors that mask
the actual behaviour under test.
"""
from app.middleware.rate_limit import limiter
limiter._storage.reset()
yield
@pytest_asyncio.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def admin_user():
"""Create an admin user in the test database and return it."""
async with TestSessionLocal() as session:
user = User(
email="admin@example.com",
hashed_password=hash_password("testpassword"),
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def admin_token(client: AsyncClient, admin_user: User) -> str:
"""Log in as the admin user and return the Bearer access token."""
response = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert response.status_code == 200, f"Login failed: {response.text}"
return response.json()["access_token"]
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Yield a live test-database session for direct state manipulation in tests.
Useful when a test needs to insert or update rows outside the HTTP layer
(e.g. marking a user inactive before testing the login rejection).
Changes must be committed explicitly by the caller.
"""
async with TestSessionLocal() as session:
yield session
+328
View File
@@ -0,0 +1,328 @@
"""
Authentication security tests.
Control coverage
────────────────
OWASP ASVS v4.0 V2 Authentication Verification
V3 Session Management Verification
OWASP API Top 10 API2:2023 Broken Authentication
"""
import base64
import json
from datetime import timedelta
import pytest
from httpx import AsyncClient
from jose import jwt as jose_jwt
from sqlalchemy import update as sa_update
from app.auth.jwt import create_access_token
from app.models.user import User
pytestmark = pytest.mark.asyncio
# ── V2.1 Password security ───────────────────────────────────────────────────
class TestCredentialValidation:
"""ASVS V2.1 — Credential acceptance and rejection rules."""
async def test_wrong_password_returns_401(self, client: AsyncClient, admin_user):
"""ASVS 2.1.1 | API2 — Incorrect password is rejected with 401."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "wrongpassword"},
)
assert resp.status_code == 401
async def test_unknown_email_returns_401(self, client: AsyncClient):
"""ASVS 2.1.1 | API2 — Unregistered email returns 401, not 404.
Returning 404 for an unknown email would allow attackers to enumerate
registered accounts.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "ghost@example.com", "password": "anything"},
)
assert resp.status_code == 401
async def test_empty_password_rejected(self, client: AsyncClient, admin_user):
"""ASVS 2.1.1 — Empty password string is rejected."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": ""},
)
assert resp.status_code in (401, 422)
async def test_null_password_rejected(self, client: AsyncClient, admin_user):
"""ASVS 2.1.1 — Null password field fails schema validation."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": None},
)
assert resp.status_code == 422
async def test_missing_fields_rejected(self, client: AsyncClient):
"""ASVS 2.1.1 — Requests missing required auth fields return 422."""
resp = await client.post("/api/v1/auth/login", json={})
assert resp.status_code == 422
async def test_very_long_password_handled_safely(self, client: AsyncClient, admin_user):
"""ASVS 2.1.7 — Passwords of 1 000+ characters must not cause a 500.
bcrypt >= 4.0 raises ValueError('Password must be 72 bytes or fewer') when
checkpw() is called with an oversized password. The app must catch this and
return 401 rather than propagating a 500.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "x" * 1000},
)
assert resp.status_code == 401
async def test_unicode_password_handled_safely(self, client: AsyncClient, admin_user):
"""ASVS 2.1.4 — Multi-byte / emoji passwords do not cause a 500."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "пароль🔑emoji"},
)
assert resp.status_code == 401
async def test_inactive_account_rejected(
self, client: AsyncClient, admin_user, db_session
):
"""ASVS 2.1.10 — Deactivated accounts cannot authenticate.
is_active=False is the soft-disable mechanism; the login handler checks
this after verifying the password.
"""
await db_session.execute(
sa_update(User)
.where(User.id == admin_user.id)
.values(is_active=False)
)
await db_session.commit()
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert resp.status_code == 401
async def test_error_response_does_not_enumerate_users(
self, client: AsyncClient, admin_user
):
"""ASVS 2.2.2 | API2 — Bad password and unknown email return identical status codes.
A differing status code (e.g. 404 vs 401) or error message leaks
whether an address is registered, enabling user enumeration.
"""
wrong_pass = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "wrong"},
)
no_user = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "wrong"},
)
assert wrong_pass.status_code == no_user.status_code == 401
# ── V3.5 Token-based session management ─────────────────────────────────────
class TestJWTSecurity:
"""ASVS V3.5 | API2 — JWT access token validation controls."""
async def test_no_auth_header_rejected(self, client: AsyncClient):
"""ASVS 3.5.1 | API2 — Write endpoint with no Authorization header is denied."""
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
)
assert resp.status_code in (401, 403)
async def test_garbage_bearer_token_rejected(self, client: AsyncClient):
"""ASVS 3.5.1 | API2 — Arbitrary string in Bearer position is rejected."""
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert resp.status_code in (401, 403)
async def test_wrong_signing_key_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — JWT signed with a different secret is rejected.
Tokens signed with a different key have a valid structure but fail
signature verification against the server's SECRET_KEY.
"""
fake_token = jose_jwt.encode(
{"sub": str(admin_user.id)}, "wrong-secret", algorithm="HS256"
)
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {fake_token}"},
)
assert resp.status_code in (401, 403)
async def test_expired_access_token_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.2 | API2 — Expired JWT is rejected even if the signature is valid.
An expired token is issued with expires_delta in the past (-1 s), so
the 'exp' claim is already exceeded at the time of the request.
"""
expired = create_access_token(
data={"sub": str(admin_user.id)},
expires_delta=timedelta(seconds=-1),
)
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {expired}"},
)
assert resp.status_code in (401, 403)
async def test_alg_none_attack_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.3 | API2 — JWT with algorithm 'none' (unsigned) is rejected.
The 'alg: none' attack tricks vulnerable verifiers into accepting
unsigned tokens. python-jose rejects them when a key is expected.
The unsigned token is constructed manually to avoid library restrictions.
"""
header_b64 = base64.urlsafe_b64encode(
b'{"alg":"none","typ":"JWT"}'
).rstrip(b"=").decode()
payload_b64 = base64.urlsafe_b64encode(
json.dumps({"sub": str(admin_user.id)}).encode()
).rstrip(b"=").decode()
none_token = f"{header_b64}.{payload_b64}."
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {none_token}"},
)
assert resp.status_code in (401, 403)
async def test_tampered_payload_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — JWT with a replaced payload but original signature is rejected.
The signature covers the original header+payload. Swapping the payload
invalidates the signature, so the token must be rejected even though
the signature portion itself is a valid HMAC.
"""
valid_token = create_access_token(data={"sub": str(admin_user.id)})
header, _, signature = valid_token.split(".")
fake_payload = base64.urlsafe_b64encode(
json.dumps({"sub": "00000000-0000-0000-0000-000000000000"}).encode()
).rstrip(b"=").decode()
tampered = f"{header}.{fake_payload}.{signature}"
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Bearer {tampered}"},
)
assert resp.status_code in (401, 403)
async def test_token_in_query_string_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — Bearer token passed as a query parameter is rejected.
Tokens in URLs appear in server logs, browser history, and Referer
headers, making them trivially leakable. Only the Authorization header
is accepted.
"""
valid_token = create_access_token(data={"sub": str(admin_user.id)})
resp = await client.post(
f"/api/v1/pages?token={valid_token}",
json={"title": "x", "slug": "x", "body": "x"},
)
assert resp.status_code in (401, 403)
async def test_basic_auth_scheme_rejected(self, client: AsyncClient, admin_user):
"""ASVS 3.5.1 | API2 — HTTP Basic Auth scheme is not accepted; Bearer is required."""
import base64 as b64
credentials = b64.b64encode(b"admin@example.com:testpassword").decode()
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": f"Basic {credentials}"},
)
assert resp.status_code in (401, 403)
# ── V3.5 Refresh token rotation and revocation ───────────────────────────────
class TestRefreshTokenSecurity:
"""ASVS V3.5 | API2 — Refresh token single-use rotation and revocation."""
async def test_refresh_token_is_rotated_and_old_token_revoked(
self, client: AsyncClient, admin_user
):
"""ASVS 3.5.2 — After rotation the original refresh token cannot be reused.
The server performs one-time-use rotation: on each /auth/refresh call the
presented token is revoked atomically and a new pair is issued. Presenting
the old token a second time must return 401.
"""
login = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert login.status_code == 200
original_refresh = login.json()["refresh_token"]
rotate = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
assert rotate.status_code == 200
assert rotate.json()["refresh_token"] != original_refresh
reuse = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
assert reuse.status_code == 401
async def test_forged_refresh_token_rejected(self, client: AsyncClient):
"""ASVS 3.5.2 | API2 — A randomly-generated string is not a valid refresh token."""
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "totally-made-up-random-value"},
)
assert resp.status_code == 401
async def test_empty_refresh_token_rejected(self, client: AsyncClient):
"""ASVS 3.5.2 — Empty refresh token string is rejected."""
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": ""},
)
assert resp.status_code in (401, 422)
async def test_new_access_token_is_functional(
self, client: AsyncClient, admin_user
):
"""ASVS 3.5.2 — Access token issued after a refresh is accepted by protected endpoints."""
login = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
original_refresh = login.json()["refresh_token"]
rotate = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
new_access = rotate.json()["access_token"]
resp = await client.post(
"/api/v1/pages",
json={"title": "Post-refresh", "slug": "post-refresh", "body": "<p>ok</p>"},
headers={"Authorization": f"Bearer {new_access}"},
)
assert resp.status_code == 201
+195
View File
@@ -0,0 +1,195 @@
"""
Authorization and access-control security tests.
Control coverage
────────────────
OWASP ASVS v4.0 V4 Access Control Verification
OWASP API Top 10 API1:2023 Broken Object Level Authorization (BOLA)
API3:2023 Broken Object Property Level Authorization
API5:2023 Broken Function Level Authorization
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V4.1 General access control ─────────────────────────────────────────────
class TestUnauthenticatedWriteAccess:
"""ASVS V4.1 | API5 — Every write/mutate endpoint denies unauthenticated callers."""
@pytest.mark.parametrize("method,path,body", [
("POST", "/api/v1/pages", {"title": "T", "slug": "s", "body": "b"}),
("PUT", "/api/v1/pages/any-slug", {"title": "T"}),
("DELETE", "/api/v1/pages/any-slug", None),
("POST", "/api/v1/posts", {"title": "T", "slug": "s", "body": "b"}),
("PUT", "/api/v1/posts/any-slug", {"title": "T"}),
("DELETE", "/api/v1/posts/any-slug", None),
("PUT", "/api/v1/settings", {"site_name": "X"}),
("GET", "/api/v1/analytics/summary", None),
])
async def test_endpoint_requires_auth(
self, client: AsyncClient, method: str, path: str, body: dict | None
):
"""ASVS 4.1.1 | API5 — {method} {path} returns 401/403 without credentials."""
fn = getattr(client, method.lower())
kwargs = {"json": body} if body else {}
resp = await fn(path, **kwargs)
assert resp.status_code in (401, 403), (
f"{method} {path}: expected 401/403 without auth, got {resp.status_code}"
)
async def test_malformed_auth_scheme_rejected(self, client: AsyncClient):
"""ASVS 4.1.1 | API2 — Non-Bearer Authorization schemes are denied."""
bad_headers = [
"Basic dXNlcjpwYXNz", # HTTP Basic
"Token some-api-key", # Token scheme
"Bearer", # Missing credential
]
for auth in bad_headers:
resp = await client.post(
"/api/v1/pages",
json={"title": "x", "slug": "x", "body": "x"},
headers={"Authorization": auth},
)
assert resp.status_code in (401, 403), (
f"Expected 401/403 for Authorization: {auth!r}"
)
# ── V4.2 Object-level authorization (BOLA) ──────────────────────────────────
class TestObjectLevelAuthorization:
"""ASVS V4.2 | API1 BOLA — Objects cannot be accessed or mutated without authorization."""
async def test_nonexistent_page_returns_404_not_403(self, client: AsyncClient):
"""ASVS 4.2.1 | API1 — Missing resource returns 404, not 403 or 500.
Returning 403 for non-existent resources would reveal that the resource
exists but is protected; 404 is the correct public response.
"""
resp = await client.get("/api/v1/pages/this-slug-does-not-exist-999")
assert resp.status_code == 404
async def test_path_traversal_in_slug_is_rejected(self, client: AsyncClient):
"""ASVS 4.2.1 | API1 — Path-traversal sequences in slug parameters are rejected.
URL-encoded and plain traversal strings must not resolve to real resources
or cause server errors.
"""
traversal_slugs = [
"../admin",
"..%2fadmin",
"%2e%2e/secret",
"../../etc/passwd",
]
for slug in traversal_slugs:
resp = await client.get(f"/api/v1/pages/{slug}")
assert resp.status_code in (404, 422), (
f"Unexpected {resp.status_code} for slug {slug!r}"
)
async def test_delete_nonexistent_resource_returns_404(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.2.1 | API1 — DELETE on an absent resource returns 404, not 204.
Returning 204 for missing resources would silently confirm that the
operation succeeded, masking business-logic gaps.
"""
resp = await client.delete(
"/api/v1/pages/genuinely-does-not-exist",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
async def test_update_nonexistent_resource_returns_404(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.2.1 | API1 — PUT on a missing slug returns 404."""
resp = await client.put(
"/api/v1/pages/genuinely-does-not-exist",
json={"title": "Updated"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
# ── V4.3 Mass assignment (object property authorization) ─────────────────────
class TestMassAssignment:
"""ASVS V4.3 | API3 — Server-side models reject undeclared or privileged fields."""
async def test_extra_fields_in_create_are_silently_dropped(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.3.1 | API3 — Injected undeclared fields are not stored or echoed.
Pydantic's schema strips fields not declared in PageCreate.
The response must not contain 'is_admin', 'hashed_password', or any
caller-supplied 'id'.
"""
resp = await client.post(
"/api/v1/pages",
json={
"title": "Mass-assign test",
"slug": "mass-assign-create",
"body": "<p>body</p>",
"published": True,
# Injected fields that must be dropped
"is_admin": True,
"hashed_password": "injected",
"id": "00000000-0000-0000-0000-000000000001",
"internal_field": "evil",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
data = resp.json()
assert "is_admin" not in data
assert "hashed_password" not in data
assert "internal_field" not in data
assert data.get("id") != "00000000-0000-0000-0000-000000000001"
async def test_extra_fields_in_update_are_silently_dropped(
self, client: AsyncClient, admin_token: str
):
"""ASVS 4.3.1 | API3 — Injected fields in PUT body are stripped by the schema."""
await client.post(
"/api/v1/pages",
json={"title": "Base", "slug": "mass-assign-update", "body": "<p>b</p>"},
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = await client.put(
"/api/v1/pages/mass-assign-update",
json={"title": "Updated", "hacked_field": "injected", "is_admin": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "hacked_field" not in data
assert "is_admin" not in data
async def test_published_flag_is_controlled_by_caller(
self, client: AsyncClient, admin_token: str
):
"""API3 — The 'published' field is an intentional caller-controlled property.
This test documents that any authenticated user can publish content.
There is no role separation between 'editor' and 'publisher' roles.
If RBAC is added in future, this test should be updated to reflect
the intended access model.
"""
resp = await client.post(
"/api/v1/pages",
json={
"title": "Published Page",
"slug": "published-by-caller",
"body": "<p>visible</p>",
"published": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
assert resp.json()["published"] is True
+205
View File
@@ -0,0 +1,205 @@
"""
Security configuration, HTTP headers, CORS, and error-handling tests.
Control coverage
────────────────
OWASP ASVS v4.0 V9 Communication Security
V14 Configuration Verification
OWASP API Top 10 API8:2023 Security Misconfiguration
API9:2023 Improper Inventory Management
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V14.4 HTTP security response headers ────────────────────────────────────
class TestSecurityHeaders:
"""ASVS V14.4 | API8 — Security response headers harden browser behaviour.
These headers are typically applied by a middleware class or a reverse-proxy
(e.g. nginx).
"""
async def test_x_content_type_options_nosniff(self, client: AsyncClient):
"""ASVS 14.4.3 | API8 — X-Content-Type-Options: nosniff must be present."""
resp = await client.get("/health")
assert resp.headers.get("x-content-type-options") == "nosniff"
async def test_x_frame_options_present(self, client: AsyncClient):
"""ASVS 14.4.5 | API8 — X-Frame-Options: DENY must be present."""
resp = await client.get("/health")
assert resp.headers.get("x-frame-options") is not None
async def test_content_security_policy_present(self, client: AsyncClient):
"""ASVS 14.4.6 | API8 — A Content-Security-Policy header must be present."""
resp = await client.get("/health")
assert "content-security-policy" in resp.headers
async def test_strict_transport_security_present(self, client: AsyncClient):
"""ASVS 9.2.2 | API8 — Strict-Transport-Security must be present."""
resp = await client.get("/health")
assert "strict-transport-security" in resp.headers
async def test_referrer_policy_present(self, client: AsyncClient):
"""ASVS 14.4.4 | API8 — Referrer-Policy must be present."""
resp = await client.get("/health")
assert "referrer-policy" in resp.headers
# ── V14.3 Error handling ─────────────────────────────────────────────────────
class TestErrorHandling:
"""ASVS V14.3 | API8 — Error responses contain no internal implementation details."""
async def test_404_does_not_expose_internals(self, client: AsyncClient):
"""ASVS 14.3.2 — 404 for an unknown route contains no stack trace or file paths."""
resp = await client.get("/api/v1/this-endpoint-does-not-exist-xyz")
assert resp.status_code == 404
body = resp.text
assert "Traceback" not in body
assert "site-packages" not in body
assert "File /" not in body
async def test_422_validation_error_returns_clean_json(
self, client: AsyncClient, admin_token: str
):
"""ASVS 14.3.2 | API8 — Validation failures return Pydantic's structured JSON, no stack trace."""
resp = await client.post(
"/api/v1/pages",
json={"title": 99, "slug": [], "body": None},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
data = resp.json()
assert "detail" in data
assert "Traceback" not in resp.text
assert "site-packages" not in resp.text
async def test_401_response_includes_www_authenticate(self, client: AsyncClient):
"""ASVS 3.5.1 | API2 — 401 from the auth layer includes WWW-Authenticate: Bearer."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "wrong"},
)
assert resp.status_code == 401
# WWW-Authenticate is required by RFC 7235 for 401 responses
assert "www-authenticate" in resp.headers
async def test_malformed_json_returns_422_not_500(
self, client: AsyncClient, admin_token: str
):
"""ASVS 14.3.2 — Syntactically invalid JSON body returns 422, not 500."""
resp = await client.post(
"/api/v1/pages",
content=b"{{not valid json at all{{",
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
)
assert resp.status_code == 422
assert resp.status_code != 500
async def test_unexpected_content_type_handled(
self, client: AsyncClient, admin_token: str
):
"""ASVS 14.3.2 — Sending plain text to a JSON endpoint returns 422, not 500."""
resp = await client.post(
"/api/v1/pages",
content=b"title=Test&slug=test&body=body",
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "text/plain",
},
)
assert resp.status_code in (415, 422)
assert resp.status_code != 500
# ── V14.5 CORS policy ────────────────────────────────────────────────────────
class TestCORSPolicy:
"""ASVS V14.5 | API8 — Cross-Origin Resource Sharing is restricted to declared origins."""
async def test_allowed_origin_receives_acao_header(self, client: AsyncClient):
"""ASVS 14.5.2 — Preflight from an allowed origin gets the correct ACAO header."""
resp = await client.options(
"/api/v1/pages",
headers={
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "GET",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao == "http://localhost:5173"
async def test_disallowed_origin_does_not_receive_acao_header(
self, client: AsyncClient
):
"""ASVS 14.5.2 | API8 — Preflight from an unknown origin is not granted cross-origin access.
The ACAO header must not be echoed back for arbitrary origins, and must
not be the wildcard '*', since credentials are enabled.
"""
resp = await client.options(
"/api/v1/pages",
headers={
"Origin": "https://evil.example.com",
"Access-Control-Request-Method": "POST",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao != "https://evil.example.com"
assert acao != "*"
async def test_production_origin_receives_acao_header(self, client: AsyncClient):
"""ASVS 14.5.2 — The production domain is in the CORS allowlist."""
resp = await client.options(
"/api/v1/pages",
headers={
"Origin": "https://www.goodwalk.co.nz",
"Access-Control-Request-Method": "GET",
},
)
acao = resp.headers.get("access-control-allow-origin", "")
assert acao == "https://www.goodwalk.co.nz"
# ── API9 API inventory and documentation exposure ────────────────────────────
class TestAPIInventory:
"""ASVS V14 | API9:2023 — The API surface is intentional and known."""
async def test_health_endpoint_returns_ok(self, client: AsyncClient):
"""Health check endpoint is reachable and returns structured JSON."""
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
async def test_openapi_schema_is_accessible(self, client: AsyncClient):
"""API9:2023 — OpenAPI schema is reachable (intentional; document as known exposure).
In production this endpoint should be removed or IP-restricted.
This test records the current state: it is publicly accessible.
The schema must not contain connection strings or private server details.
"""
resp = await client.get("/openapi.json")
assert resp.status_code == 200
schema = resp.json()
assert "paths" in schema
schema_str = resp.text.lower()
# Connection string or internal host details must not appear
assert "postgresql" not in schema_str
assert "asyncpg" not in schema_str
assert "localhost:5432" not in schema_str
async def test_swagger_ui_not_publicly_accessible_in_production(
self, client: AsyncClient
):
"""API9:2023 — Interactive API documentation should not be public in production."""
resp = await client.get("/docs")
assert resp.status_code in (403, 404)
+328
View File
@@ -0,0 +1,328 @@
"""
Input validation and output sanitization security tests.
Control coverage
────────────────
OWASP ASVS v4.0 V5 Input Validation, Sanitization and Encoding
OWASP API Top 10 API4:2023 Unrestricted Resource Consumption (payload size)
API8:2023 Security Misconfiguration (missing sanitization)
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V5.1 Input validation ────────────────────────────────────────────────────
class TestSchemaValidation:
"""ASVS V5.1 — All inputs are validated against declared schemas before processing."""
async def test_required_fields_enforced_on_page_create(
self, client: AsyncClient, admin_token: str
):
"""ASVS 5.1.1 — Missing required fields return 422 Unprocessable Entity."""
resp = await client.post(
"/api/v1/pages",
json={"title": "No slug or body"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
async def test_required_fields_enforced_on_post_create(
self, client: AsyncClient, admin_token: str
):
"""ASVS 5.1.1 — Blog post creation also enforces required fields."""
resp = await client.post(
"/api/v1/posts",
json={"title": "No body or slug"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
async def test_null_body_field_rejected(self, client: AsyncClient, admin_token: str):
"""ASVS 5.1.1 — Explicit null for a required string field returns 422."""
resp = await client.post(
"/api/v1/pages",
json={"title": "Test", "slug": "test-null-body", "body": None},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
async def test_event_type_max_length_enforced(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Analytics event_type over 64 chars returns 422."""
resp = await client.post(
"/api/web/event",
json={"event_type": "x" * 65, "page": "/", "session_id": "s1"},
)
assert resp.status_code == 422
async def test_event_page_max_length_enforced(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Analytics page field over 255 chars returns 422."""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "x" * 256, "session_id": "s1"},
)
assert resp.status_code == 422
async def test_event_element_max_length_enforced(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Analytics element field over 255 chars returns 422."""
resp = await client.post(
"/api/web/event",
json={
"event_type": "click",
"page": "/",
"element": "x" * 256,
"session_id": "s1",
},
)
assert resp.status_code == 422
async def test_malformed_json_body_returns_422_not_500(
self, client: AsyncClient, admin_token: str
):
"""ASVS 5.1.1 — Malformed JSON body returns 422, not 500 Internal Server Error."""
resp = await client.post(
"/api/v1/pages",
content=b"{not valid json{{",
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
)
assert resp.status_code == 422
assert resp.status_code != 500
# ── V5.2 Sanitization — stored XSS via HTML body ────────────────────────────
class TestHTMLSanitization:
"""ASVS V5.2 | API8 — HTML body is sanitized by nh3 before storage.
Pages and blog posts accept rich HTML. nh3 (Rust/ammonia) strips disallowed
elements and attributes before the content reaches the database. All XSS
vectors tested here must be absent from the stored body.
"""
async def _create_page_body(
self, client: AsyncClient, token: str, slug: str, body: str
) -> str:
resp = await client.post(
"/api/v1/pages",
json={"title": "XSS test", "slug": slug, "body": body, "published": True},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, f"Page create failed: {resp.text}"
return resp.json()["body"]
async def test_script_tag_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — <script> tags are removed from stored HTML."""
body = await self._create_page_body(
client, admin_token, "xss-script",
'<p>Hello</p><script>alert("xss")</script>',
)
assert "<script" not in body
assert "alert" not in body
async def test_onerror_event_handler_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — onerror and other on* event attributes are removed."""
body = await self._create_page_body(
client, admin_token, "xss-onerror",
'<img src="x" onerror="alert(1)">',
)
assert "onerror" not in body
async def test_onclick_attribute_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — onclick event attribute is removed from anchor tags."""
body = await self._create_page_body(
client, admin_token, "xss-onclick",
'<a href="/page" onclick="stealCookies()">Click</a>',
)
assert "onclick" not in body
async def test_javascript_href_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — javascript: URI scheme in href is sanitized."""
body = await self._create_page_body(
client, admin_token, "xss-js-href",
'<a href="javascript:alert(document.cookie)">Click</a>',
)
assert "javascript:" not in body
async def test_iframe_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — <iframe> elements are removed entirely."""
body = await self._create_page_body(
client, admin_token, "xss-iframe",
'<p>Content</p><iframe src="https://evil.example.com"></iframe>',
)
assert "<iframe" not in body
async def test_object_tag_stripped(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — <object> elements (legacy plugin vector) are removed."""
body = await self._create_page_body(
client, admin_token, "xss-object",
'<object data="data:text/html,<script>alert(1)</script>"></object>',
)
assert "<object" not in body
async def test_safe_html_is_preserved(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — Legitimate formatting tags survive sanitization intact.
Sanitization must not strip safe elements like <p>, <strong>, <em>,
<ul>, <li>, or ordinary <a href> links.
"""
safe = (
"<p>Hello <strong>world</strong>. "
"<a href='/about'>Learn more</a>.</p>"
"<ul><li>Item one</li><li>Item two</li></ul>"
)
body = await self._create_page_body(client, admin_token, "xss-safe", safe)
assert "<p>" in body
assert "<strong>" in body
assert "<a" in body
assert "<ul>" in body
async def test_blog_post_body_sanitized(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — Blog post bodies go through the same nh3 sanitization."""
resp = await client.post(
"/api/v1/posts",
json={
"title": "XSS Post",
"slug": "xss-post-sanitize",
"body": '<script>document.cookie="stolen"</script><p>Content</p>',
"published": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
assert "<script" not in resp.json()["body"]
async def test_xss_in_update_also_sanitized(self, client: AsyncClient, admin_token: str):
"""ASVS 5.2.1 — XSS payload submitted via PUT update is also sanitized."""
await client.post(
"/api/v1/pages",
json={"title": "Initial", "slug": "xss-update", "body": "<p>Safe</p>"},
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = await client.put(
"/api/v1/pages/xss-update",
json={"body": '<script>evil()</script><p>updated</p>'},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert "<script" not in resp.json()["body"]
# ── V5.3 SQL injection prevention ───────────────────────────────────────────
class TestSQLInjection:
"""ASVS V5.3 — Parameterized queries prevent SQL injection at every input boundary."""
@pytest.mark.parametrize("injection", [
"' OR '1'='1",
"1; DROP TABLE pages; --",
"' UNION SELECT email,hashed_password,1,1,1,1,1 FROM users --",
"admin'--",
"'; INSERT INTO users(email) VALUES('pwned@evil.com'); --",
])
async def test_sql_injection_in_slug_does_not_500(
self, client: AsyncClient, injection: str
):
"""ASVS 5.3.4 — SQL injection strings in slug path parameters return 404, not 500.
SQLAlchemy passes slug as a bind parameter; the string is never
interpolated into a query. A 404 means the slug simply wasn't found.
"""
resp = await client.get(f"/api/v1/pages/{injection}")
assert resp.status_code != 500, (
f"500 for SQL injection slug {injection!r} — possible unparameterised query"
)
@pytest.mark.parametrize("email", [
"admin@example.com' OR '1'='1' --",
"' OR 1=1; --",
"admin@example.com'/*",
"'; DROP TABLE users; --",
])
async def test_sql_injection_in_login_email_does_not_bypass_auth(
self, client: AsyncClient, email: str
):
"""ASVS 5.3.4 | API2 — SQL injection in the login email field returns 401, not 200.
A vulnerable query like "WHERE email = '{email}'" would return all rows
with a crafted OR clause, bypassing authentication.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": "password"},
)
assert resp.status_code == 401, (
f"Expected 401 for injected email {email!r}, got {resp.status_code}"
)
assert resp.status_code != 500
# ── Analytics metadata sanitization ──────────────────────────────────────────
class TestAnalyticsMetadataSanitization:
"""ASVS V5.1 | API8 — Analytics event metadata is whitelist-sanitized server-side.
Only 9 pre-approved keys are persisted. Values are capped at 120 chars.
Nested objects and unknown keys are silently dropped.
"""
async def _post_event(self, client: AsyncClient, metadata: dict) -> int:
resp = await client.post(
"/api/web/event",
json={
"event_type": "page_view",
"page": "/",
"session_id": "meta-test",
"metadata": metadata,
},
)
return resp.status_code
async def test_unknown_metadata_key_is_dropped(self, client: AsyncClient):
"""ASVS 5.1.1 | API8 — Keys outside the allowlist are silently removed."""
status = await self._post_event(
client, {"evil_key": "bad value", "plan": "dog-walks"}
)
assert status == 201
async def test_nested_object_in_metadata_dropped(self, client: AsyncClient):
"""ASVS 5.1.1 | API8 — Nested dict values are dropped (no recursive storage)."""
status = await self._post_event(
client, {"plan": {"deeply": {"nested": "object"}}}
)
assert status == 201
async def test_prototype_pollution_keys_dropped(self, client: AsyncClient):
"""ASVS 5.1.1 | API8 — __proto__ and constructor keys are rejected by the allowlist."""
status = await self._post_event(
client,
{
"__proto__": {"isAdmin": True},
"constructor": {"name": "attack"},
"plan": "safe-value",
},
)
assert status == 201
async def test_oversized_string_value_is_accepted(self, client: AsyncClient):
"""ASVS 5.1.3 — Metadata string values longer than 120 chars are truncated, not errored."""
status = await self._post_event(client, {"plan": "x" * 500})
assert status == 201
async def test_null_metadata_accepted(self, client: AsyncClient):
"""ASVS 5.1.1 — Null metadata field is valid and accepted."""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "null-meta", "metadata": None},
)
assert resp.status_code == 201
async def test_large_metadata_object_does_not_crash(self, client: AsyncClient):
"""ASVS 5.1.3 | API4 — Metadata with many keys (mostly unknown) is handled safely."""
big_meta = {f"key_{i}": f"value_{i}" for i in range(200)}
status = await self._post_event(client, big_meta)
assert status != 500
+251
View File
@@ -0,0 +1,251 @@
"""
Rate limiting, resource consumption, and SSRF mitigation tests.
Control coverage
────────────────
OWASP ASVS v4.0 V13 API and Web Service Verification
OWASP API Top 10 API4:2023 Unrestricted Resource Consumption
API6:2023 Unrestricted Access to Sensitive Business Flows
API7:2023 Server Side Request Forgery (SSRF)
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
# ── V13.2 / API4 Rate limit presence ────────────────────────────────────────
class TestRateLimitHeaders:
"""ASVS V13.2 | API4 — Sensitive endpoints advertise rate-limit headers.
slowapi can emit X-RateLimit-* headers when headers_enabled=True is passed
to the Limiter constructor in app/middleware/rate_limit.py.
"""
async def test_login_endpoint_exposes_rate_limit_headers(
self, client: AsyncClient, admin_user
):
"""ASVS 13.2.1 | API4 — /auth/login returns X-RateLimit-* response headers.
Advertising limits allows legitimate clients to back off gracefully.
The configured limit is 5 requests/minute.
"""
resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert resp.status_code == 200
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "x-ratelimit-limit" in headers_lower
assert "x-ratelimit-remaining" in headers_lower
async def test_analytics_ingest_exposes_rate_limit_headers(
self, client: AsyncClient
):
"""ASVS 13.2.1 | API4 — Analytics ingest endpoint returns X-RateLimit-* headers.
The analytics endpoint is public and rate-limited to 60 requests/minute.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "rl-test"},
)
assert resp.status_code == 201
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "x-ratelimit-limit" in headers_lower
async def test_refresh_endpoint_exposes_rate_limit_headers(
self, client: AsyncClient, admin_user
):
"""ASVS 13.2.1 | API4 — /auth/refresh returns rate-limit headers (5/minute)."""
login = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
refresh_token = login.json()["refresh_token"]
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "x-ratelimit-limit" in headers_lower
# ── API4 Payload size limits ────────────────────────────────────────────────
class TestPayloadSizeLimits:
"""API4:2023 — Oversized payloads are rejected without crashing the server."""
async def test_oversized_event_page_path_rejected(self, client: AsyncClient):
"""API4:2023 — Analytics page field exceeding max_length returns 422."""
resp = await client.post(
"/api/web/event",
json={
"event_type": "page_view",
"page": "/" + "x" * 10_000,
"session_id": "size-test",
},
)
assert resp.status_code == 422
assert resp.status_code != 500
async def test_large_page_body_does_not_500(
self, client: AsyncClient, admin_token: str
):
"""API4:2023 — A 100 KB page body does not crash the server.
FastAPI / Starlette has a default body size limit. Large payloads should
either be accepted (nh3 can handle them) or rejected with 413/422.
A 500 would indicate unhandled processing failure.
"""
large_body = "<p>" + "A" * 100_000 + "</p>"
resp = await client.post(
"/api/v1/pages",
json={
"title": "Big Page",
"slug": "big-page-payload",
"body": large_body,
"published": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code != 500
async def test_deeply_nested_json_does_not_500(
self, client: AsyncClient, admin_token: str
):
"""API4:2023 — Highly nested JSON body (potential stack-overflow vector) is handled."""
# Build a deeply nested dict: {"a": {"a": {"a": ... }}}
nested: dict = {}
node = nested
for _ in range(50):
node["a"] = {}
node = node["a"]
resp = await client.post(
"/api/v1/pages",
json={"title": "Nested", "slug": "nested-json", "body": str(nested)},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code != 500
# ── API7 SSRF — private IP suppression in geo-lookup ────────────────────────
class TestSSRFMitigation:
"""API7:2023 SSRF — The analytics geo-lookup must not forward private IPs externally.
_geo_lookup() in app/routers/analytics.py checks for private IP prefixes
and returns (None, None) immediately, preventing the server from making
outbound requests to ip-api.com with internal addresses.
"""
@pytest.mark.parametrize("private_ip", [
"127.0.0.1",
"10.0.0.1",
"10.255.255.255",
"192.168.1.100",
"172.16.0.1",
"172.31.255.255",
"::1",
"localhost",
])
async def test_private_ip_in_xff_does_not_cause_error(
self, client: AsyncClient, private_ip: str
):
"""API7:2023 — Private/loopback IP in X-Forwarded-For is handled safely.
The event is still recorded (201); geo fields will be null. The server
must not error or make an outbound call for private addresses.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "ssrf-test"},
headers={"X-Forwarded-For": private_ip},
)
assert resp.status_code == 201, (
f"Expected 201 for private IP {private_ip!r}, got {resp.status_code}"
)
@pytest.mark.parametrize("xff", [
"not-an-ip",
"999.999.999.999",
",,,",
"127.0.0.1, 10.0.0.1, attacker.example.com",
"",
])
async def test_malformed_xff_does_not_cause_500(
self, client: AsyncClient, xff: str
):
"""API7:2023 — Malformed X-Forwarded-For header is handled without crashing."""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": "xff-malform"},
headers={"X-Forwarded-For": xff},
)
assert resp.status_code != 500, (
f"500 for X-Forwarded-For: {xff!r}"
)
# ── API6 Sensitive business-flow controls ────────────────────────────────────
class TestBusinessFlowProtection:
"""API6:2023 — Sensitive or high-volume flows have appropriate access controls."""
async def test_analytics_ingest_is_intentionally_public(self, client: AsyncClient):
"""API6:2023 — Anonymous event ingestion is by design; rate limiting is the control.
This test documents the intentional decision: any browser can POST to
/api/web/event without credentials. The 60 req/min rate limit and
metadata whitelist are the primary abuse-prevention controls.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/about", "session_id": "anon"},
)
assert resp.status_code == 201
async def test_analytics_read_requires_authentication(self, client: AsyncClient):
"""API6:2023 | API5 — Aggregated analytics data (business intelligence) is auth-gated.
Public write / authenticated read is the intended access pattern.
"""
resp = await client.get("/api/v1/analytics/summary")
assert resp.status_code in (401, 403)
async def test_session_cookie_is_httponly(self, client: AsyncClient):
"""ASVS 3.4.2 | API6 — The anonymous session cookie is HttpOnly.
HttpOnly prevents JavaScript from reading the cookie, mitigating
session hijacking via XSS.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": None},
)
assert resp.status_code == 201
set_cookie = resp.headers.get("set-cookie", "")
if set_cookie:
assert "httponly" in set_cookie.lower(), (
"Session cookie must be HttpOnly"
)
async def test_session_cookie_is_samesite_lax(self, client: AsyncClient):
"""ASVS 3.4.3 | API6 — The anonymous session cookie has SameSite=Lax.
SameSite=Lax blocks the cookie from being sent in cross-site POST
requests, protecting against CSRF on cookie-authenticated flows.
"""
resp = await client.post(
"/api/web/event",
json={"event_type": "page_view", "page": "/", "session_id": None},
)
assert resp.status_code == 201
set_cookie = resp.headers.get("set-cookie", "")
if set_cookie:
assert "samesite=lax" in set_cookie.lower(), (
"Session cookie must be SameSite=Lax"
)
@@ -0,0 +1,773 @@
"""
Extended tests for admin-facing members and booking endpoints.
Covers endpoints not exercised in test_members.py:
- GET /settings/features fetch global member feature flags
- PUT /settings/features update global member feature flags
- GET /admin/members/{member_id} fetch a single member record
- GET /admin/members/{member_id}/walks admin view of a member's walks
- GET /admin/members/{member_id}/bookings admin view of a member's bookings
- GET /admin/bookings list all bookings across members
- POST /admin/bookings create a booking on behalf of a member
- PUT /admin/bookings/{booking_id} update booking status / notes
- GET /admin/messages message history with read status
- GET /admin/notifications actionable admin notification feed
- GET /admin/notifications/settings fetch notification config
- PUT /admin/notifications/settings update notification config
- POST /admin/notifications/run manually trigger notification run
"""
import pytest
from datetime import datetime, timezone
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
from app.models.contact_lead import ContactLead
from app.models.member import Member, Walk, Booking, AdminMessage
from app.models.settings import SiteSettings
from app.auth.password import hash_password
pytestmark = [pytest.mark.asyncio, pytest.mark.members_admin]
# ── Helpers ────────────────────────────────────────────────────────────────────
async def _member(
db: AsyncSession,
email: str = "m@example.com",
claimed: bool = True,
status: str = "active",
) -> Member:
m = Member(
email=email,
first_name="Jane",
last_name="Doe",
phone="021 000 0000",
is_claimed=claimed,
is_active=True,
member_status=status,
hashed_password=hash_password("Password1!") if claimed else None,
onboarding_data={"dog_name": "Rex"},
)
db.add(m)
await db.commit()
await db.refresh(m)
return m
async def _walk(db: AsyncSession, member_id) -> Walk:
w = Walk(
member_id=member_id,
walked_at=datetime(2026, 3, 15, 9, 0, tzinfo=timezone.utc),
service_type="pack_walk",
duration_minutes=60,
notes="Test walk",
recorded_by="admin@example.com",
)
db.add(w)
await db.commit()
await db.refresh(w)
return w
async def _booking(db: AsyncSession, member_id) -> Booking:
b = Booking(
member_id=member_id,
service_type="pack_walk",
status="pending",
notes="Morning preferred",
)
db.add(b)
await db.commit()
await db.refresh(b)
return b
async def _lead(db: AsyncSession, email: str = "lead@example.com") -> ContactLead:
lead = ContactLead(
full_name="Alex Prospect",
email=email,
phone="021 222 2222",
suburb="Devonport",
pet_name="Milo",
status="invite",
)
db.add(lead)
await db.commit()
await db.refresh(lead)
return lead
# ── GET /admin/members/{member_id} ─────────────────────────────────────────────
async def test_admin_get_member(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "getme@example.com")
resp = await client.get(
f"/api/v1/admin/members/{member.id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == str(member.id)
assert data["email"] == "getme@example.com"
assert data["first_name"] == "Jane"
async def test_admin_get_member_not_found(client: AsyncClient, admin_token: str):
import uuid
resp = await client.get(
f"/api/v1/admin/members/{uuid.uuid4()}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
async def test_admin_get_member_requires_auth(client: AsyncClient, db_session: AsyncSession):
member = await _member(db_session, "noauth@example.com")
resp = await client.get(f"/api/v1/admin/members/{member.id}")
assert resp.status_code in (401, 403)
# ── GET /admin/members/{member_id}/walks ───────────────────────────────────────
async def test_admin_get_member_walks_empty(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "nowalks@example.com")
resp = await client.get(
f"/api/v1/admin/members/{member.id}/walks",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json() == []
async def test_admin_get_member_walks_with_data(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "haswalks@example.com")
await _walk(db_session, member.id)
await _walk(db_session, member.id)
resp = await client.get(
f"/api/v1/admin/members/{member.id}/walks",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert len(resp.json()) == 2
async def test_admin_get_member_walks_only_own(client: AsyncClient, admin_token: str, db_session: AsyncSession):
"""Walks returned belong only to the requested member, not all members."""
m1 = await _member(db_session, "walker1@example.com")
m2 = await _member(db_session, "walker2@example.com")
await _walk(db_session, m1.id)
await _walk(db_session, m2.id)
await _walk(db_session, m2.id)
resp = await client.get(
f"/api/v1/admin/members/{m1.id}/walks",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# ── GET /admin/members/{member_id}/bookings ────────────────────────────────────
async def test_admin_get_member_bookings_empty(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "nobookings@example.com")
resp = await client.get(
f"/api/v1/admin/members/{member.id}/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json() == []
async def test_admin_get_member_bookings_with_data(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "hasbookings@example.com")
await _booking(db_session, member.id)
resp = await client.get(
f"/api/v1/admin/members/{member.id}/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["service_type"] == "pack_walk"
assert data[0]["status"] == "pending"
async def test_admin_get_member_bookings_only_own(client: AsyncClient, admin_token: str, db_session: AsyncSession):
"""Bookings returned belong only to the requested member."""
m1 = await _member(db_session, "bk1@example.com")
m2 = await _member(db_session, "bk2@example.com")
await _booking(db_session, m1.id)
await _booking(db_session, m2.id)
resp = await client.get(
f"/api/v1/admin/members/{m2.id}/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# ── GET /admin/bookings ────────────────────────────────────────────────────────
async def test_admin_list_bookings_empty(client: AsyncClient, admin_token: str):
resp = await client.get(
"/api/v1/admin/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json() == []
async def test_admin_list_bookings_includes_member_details(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
"""Each booking in the admin list includes the member's name and email."""
member = await _member(db_session, "listbooking@example.com")
await _booking(db_session, member.id)
resp = await client.get(
"/api/v1/admin/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["member_email"] == "listbooking@example.com"
assert data[0]["member_first_name"] == "Jane"
assert data[0]["member_last_name"] == "Doe"
async def test_admin_list_bookings_multiple_members(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
m1 = await _member(db_session, "mb1@example.com")
m2 = await _member(db_session, "mb2@example.com")
await _booking(db_session, m1.id)
await _booking(db_session, m2.id)
resp = await client.get(
"/api/v1/admin/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert len(resp.json()) == 2
async def test_admin_list_bookings_requires_auth(client: AsyncClient):
resp = await client.get("/api/v1/admin/bookings")
assert resp.status_code in (401, 403)
async def test_admin_bookings_feature_can_be_disabled(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
member = await _member(db_session, "disabledbookings@example.com")
await _booking(db_session, member.id)
db_session.add(SiteSettings(site_name="", bookings_enabled=False))
await db_session.commit()
resp = await client.get(
"/api/v1/admin/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
# ── PUT /admin/bookings/{booking_id} ──────────────────────────────────────────
async def test_admin_create_booking(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
member = await _member(db_session, "createbooking@example.com")
resp = await client.post(
"/api/v1/admin/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"member_id": str(member.id),
"service_type": "1_1_walk",
"requested_date": "2026-04-09T07:00:00Z",
"status": "confirmed",
"admin_notes": "Created from mobile planner",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["member_id"] == str(member.id)
assert data["service_type"] == "1_1_walk"
assert data["status"] == "confirmed"
assert data["member_email"] == "createbooking@example.com"
assert data["member_dog_name"] == "Rex"
async def test_admin_create_booking_requires_auth(client: AsyncClient, db_session: AsyncSession):
member = await _member(db_session, "createbooking-noauth@example.com")
resp = await client.post(
"/api/v1/admin/bookings",
json={
"member_id": str(member.id),
"service_type": "pack_walk",
},
)
assert resp.status_code in (401, 403)
async def test_admin_create_booking_feature_can_be_disabled(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
member = await _member(db_session, "createbooking-disabled@example.com")
db_session.add(SiteSettings(site_name="", bookings_enabled=False))
await db_session.commit()
resp = await client.post(
"/api/v1/admin/bookings",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"member_id": str(member.id),
"service_type": "pack_walk",
},
)
assert resp.status_code == 404
# ── PUT /admin/bookings/{booking_id} ──────────────────────────────────────────
async def test_admin_update_booking_status(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
member = await _member(db_session, "updatebooking@example.com")
booking = await _booking(db_session, member.id)
resp = await client.put(
f"/api/v1/admin/bookings/{booking.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"status": "confirmed"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "confirmed"
assert data["id"] == str(booking.id)
async def test_admin_update_booking_admin_notes(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
member = await _member(db_session, "booknotes@example.com")
booking = await _booking(db_session, member.id)
resp = await client.put(
f"/api/v1/admin/bookings/{booking.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"admin_notes": "Revised to afternoon slot"},
)
assert resp.status_code == 200
assert resp.json()["admin_notes"] == "Revised to afternoon slot"
async def test_admin_update_booking_requested_date(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
member = await _member(db_session, "bookmove@example.com")
booking = await _booking(db_session, member.id)
moved_to = "2026-04-09T13:00:00Z"
resp = await client.put(
f"/api/v1/admin/bookings/{booking.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"requested_date": moved_to, "admin_notes": "Moved to PM route"},
)
assert resp.status_code == 200
data = resp.json()
assert data["requested_date"] == moved_to
assert data["admin_notes"] == "Moved to PM route"
async def test_admin_update_booking_not_found(client: AsyncClient, admin_token: str):
import uuid
resp = await client.put(
f"/api/v1/admin/bookings/{uuid.uuid4()}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"status": "confirmed"},
)
assert resp.status_code == 404
async def test_admin_update_booking_includes_member_details(
client: AsyncClient, admin_token: str, db_session: AsyncSession
):
"""Response includes member name and email even after update."""
member = await _member(db_session, "updatebk2@example.com")
booking = await _booking(db_session, member.id)
resp = await client.put(
f"/api/v1/admin/bookings/{booking.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"status": "cancelled"},
)
assert resp.status_code == 200
data = resp.json()
assert data["member_email"] == "updatebk2@example.com"
assert data["status"] == "cancelled"
async def test_admin_can_archive_member(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "archive-me@example.com")
resp = await client.post(
f"/api/v1/admin/members/{member.id}/archive",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["member_status"] == "archived"
assert data["is_active"] is False
async def test_admin_can_deactivate_member(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "deactivate-me@example.com")
resp = await client.post(
f"/api/v1/admin/members/{member.id}/deactivate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
async def test_admin_can_toggle_member_force_two_factor(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "force-toggle@example.com")
resp = await client.post(
f"/api/v1/admin/members/{member.id}/force-2fa",
headers={"Authorization": f"Bearer {admin_token}"},
json={"enabled": True},
)
assert resp.status_code == 200
assert resp.json()["force_two_factor"] is True
async def test_admin_can_reset_member_password(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "reset-password@example.com")
resp = await client.post(
f"/api/v1/admin/members/{member.id}/reset-password",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json()["is_claimed"] is False
await db_session.refresh(member)
assert member.hashed_password is None
# ── GET /admin/notifications/settings ─────────────────────────────────────────
async def test_admin_list_messages_history(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "history@example.com")
message = AdminMessage(
member_id=member.id,
subject="Walk update",
body="Tomorrow's slot is confirmed.",
sent_by="admin@example.com",
read_at=datetime(2026, 4, 1, 9, 30, tzinfo=timezone.utc),
)
db_session.add(message)
await db_session.commit()
resp = await client.get(
"/api/v1/admin/messages",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["member_email"] == "history@example.com"
assert data[0]["subject"] == "Walk update"
assert data[0]["read_at"] is not None
async def test_admin_list_messages_requires_auth(client: AsyncClient):
resp = await client.get("/api/v1/admin/messages")
assert resp.status_code in (401, 403)
async def test_admin_list_notifications(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "notify@example.com", status="pending_review")
booking = await _booking(db_session, member.id)
lead = await _lead(db_session, "notifylead@example.com")
db_session.add_all(
[
AuditLog(
member_id=member.id,
member_email=member.email,
action_type="login",
area="members/login",
description="Member logged in successfully.",
status="success",
timestamp=datetime.now(timezone.utc),
),
AuditLog(
member_id=member.id,
member_email=member.email,
action_type="logout",
area="members/logout",
description="Member ended their session.",
status="success",
timestamp=datetime.now(timezone.utc),
),
]
)
await db_session.commit()
resp = await client.get(
"/api/v1/admin/notifications",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "settings" in data
assert data["total"] >= 3
item_types = {item["type"] for item in data["items"]}
assert "pending_booking" in item_types
assert "new_lead" in item_types
assert "pending_review" in item_types
assert "member_login" in item_types
assert "member_logout" in item_types
hrefs = {item["id"]: item["href"] for item in data["items"]}
assert hrefs[str(booking.id)] == "/admin/bookings"
assert hrefs[str(lead.id)] == "/admin/leads"
assert hrefs[str(member.id)] == f"/admin/members/{member.id}"
session_hrefs = {item["type"]: item["href"] for item in data["items"] if item["type"] in {"member_login", "member_logout"}}
assert session_hrefs["member_login"] == f"/admin/members/{member.id}"
assert session_hrefs["member_logout"] == f"/admin/members/{member.id}"
async def test_admin_list_notifications_requires_auth(client: AsyncClient):
resp = await client.get("/api/v1/admin/notifications")
assert resp.status_code in (401, 403)
async def test_admin_get_notification_settings(client: AsyncClient, admin_token: str):
resp = await client.get(
"/api/v1/admin/notifications/settings",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "automatic_member_notifications_enabled" in data
assert "nz_public_holiday_notifications_enabled" in data
assert "invoice_reminder_notifications_enabled" in data
assert "invoice_day_of_week" in data
assert isinstance(data["invoice_day_of_week"], int)
async def test_admin_get_notification_settings_requires_auth(client: AsyncClient):
resp = await client.get("/api/v1/admin/notifications/settings")
assert resp.status_code in (401, 403)
# ── GET /settings/features ────────────────────────────────────────────────────
async def test_get_feature_settings_defaults(client: AsyncClient):
resp = await client.get("/api/v1/settings/features")
assert resp.status_code == 200
assert resp.json() == {
"bookings_enabled": True,
"walks_enabled": True,
"messages_enabled": True,
"two_factor_enabled": True,
"audit_history_enabled": True,
"experiments_enabled": True,
}
# ── PUT /settings/features ────────────────────────────────────────────────────
async def test_update_feature_settings(client: AsyncClient, admin_token: str):
resp = await client.put(
"/api/v1/settings/features",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"bookings_enabled": False,
"walks_enabled": True,
"messages_enabled": False,
"two_factor_enabled": False,
"audit_history_enabled": False,
"experiments_enabled": False,
},
)
assert resp.status_code == 200
assert resp.json() == {
"bookings_enabled": False,
"walks_enabled": True,
"messages_enabled": False,
"two_factor_enabled": False,
"audit_history_enabled": False,
"experiments_enabled": False,
}
async def test_update_feature_settings_requires_auth(client: AsyncClient):
resp = await client.put(
"/api/v1/settings/features",
json={"bookings_enabled": False},
)
assert resp.status_code in (401, 403)
async def test_get_service_pricing_defaults(client: AsyncClient):
resp = await client.get("/api/v1/settings/pricing")
assert resp.status_code == 200
data = resp.json()["service_pricing"]
assert data["pack_walk"]["amount"] == 58.0
assert data["1_1_walk"]["amount"] == 45.0
assert data["puppy_visit"]["amount"] == 39.0
async def test_update_service_pricing(client: AsyncClient, admin_token: str):
resp = await client.put(
"/api/v1/settings/pricing",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"service_pricing": {
"pack_walk": {"amount": 61, "label": "Pack Walk", "unit": "per walk"},
"1_1_walk": {"amount": 52, "label": "1-1 Walk", "unit": "per walk"},
"puppy_visit": {"amount": 44, "label": "Puppy Visit", "unit": "per visit"},
}
},
)
assert resp.status_code == 200
data = resp.json()["service_pricing"]
assert data["pack_walk"]["amount"] == 61.0
assert data["1_1_walk"]["amount"] == 52.0
assert data["puppy_visit"]["amount"] == 44.0
async def test_update_service_pricing_requires_auth(client: AsyncClient):
resp = await client.put(
"/api/v1/settings/pricing",
json={"service_pricing": {"pack_walk": {"amount": 62}}},
)
assert resp.status_code in (401, 403)
async def test_admin_audit_history_returns_404_when_disabled(
client: AsyncClient,
admin_token: str,
db_session: AsyncSession,
):
db_session.add(SiteSettings(site_name="", audit_history_enabled=False))
await db_session.commit()
resp = await client.get(
"/api/v1/admin/audit",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 404
# ── PUT /admin/notifications/settings ─────────────────────────────────────────
async def test_admin_update_notification_settings_toggle(client: AsyncClient, admin_token: str):
# Read current state
get_resp = await client.get(
"/api/v1/admin/notifications/settings",
headers={"Authorization": f"Bearer {admin_token}"},
)
current = get_resp.json()["automatic_member_notifications_enabled"]
# Flip the flag
put_resp = await client.put(
"/api/v1/admin/notifications/settings",
headers={"Authorization": f"Bearer {admin_token}"},
json={"automatic_member_notifications_enabled": not current},
)
assert put_resp.status_code == 200
assert put_resp.json()["automatic_member_notifications_enabled"] is not current
async def test_admin_update_invoice_day_of_week(client: AsyncClient, admin_token: str):
resp = await client.put(
"/api/v1/admin/notifications/settings",
headers={"Authorization": f"Bearer {admin_token}"},
json={"invoice_day_of_week": 4}, # Friday
)
assert resp.status_code == 200
assert resp.json()["invoice_day_of_week"] == 4
async def test_admin_update_invoice_day_invalid(client: AsyncClient, admin_token: str):
"""Day of week must be 06; out-of-range values are rejected."""
resp = await client.put(
"/api/v1/admin/notifications/settings",
headers={"Authorization": f"Bearer {admin_token}"},
json={"invoice_day_of_week": 7},
)
assert resp.status_code == 422
# ── POST /admin/notifications/run ─────────────────────────────────────────────
async def test_admin_run_notifications(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/admin/notifications/run",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "automatic_member_notifications_enabled" in data
assert "public_holiday_messages_sent" in data
assert "invoice_reminders_sent" in data
assert isinstance(data["public_holiday_messages_sent"], int)
assert isinstance(data["invoice_reminders_sent"], int)
async def test_admin_run_notifications_requires_auth(client: AsyncClient):
resp = await client.post("/api/v1/admin/notifications/run")
assert resp.status_code in (401, 403)
async def test_admin_can_clear_notifications(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _member(db_session, "clearable@example.com", status="pending_review")
await _booking(db_session, member.id)
db_session.add(
AuditLog(
member_id=member.id,
member_email=member.email,
action_type="login",
area="members/login",
description="Member logged in successfully.",
status="success",
timestamp=datetime.now(timezone.utc),
)
)
await db_session.commit()
clear_resp = await client.post(
"/api/v1/admin/notifications/clear",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert clear_resp.status_code == 200
cleared = clear_resp.json()
assert cleared["items"] == []
assert cleared["total"] == 0
settings_row = (await db_session.execute(select(SiteSettings))).scalars().first()
assert settings_row is not None
assert settings_row.admin_notifications_cleared_before is not None
feed_resp = await client.get(
"/api/v1/admin/notifications",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert feed_resp.status_code == 200
assert feed_resp.json()["items"] == []
+76
View File
@@ -0,0 +1,76 @@
from datetime import datetime, timedelta, timezone
import pytest
from app.models.analytics import AnalyticsEvent
from tests.conftest import TestSessionLocal
@pytest.mark.asyncio
async def test_analytics_summary_includes_top_journeys(client, admin_token):
now = datetime.now(timezone.utc)
async with TestSessionLocal() as session:
session.add_all([
AnalyticsEvent(
event_type="page_view",
page="/",
element=None,
metadata_={},
session_id="session-1",
created_at=now - timedelta(minutes=5),
),
AnalyticsEvent(
event_type="page_view",
page="/pack-walks",
element=None,
metadata_={},
session_id="session-1",
created_at=now - timedelta(minutes=4),
),
AnalyticsEvent(
event_type="page_view",
page="/contact",
element=None,
metadata_={},
session_id="session-1",
created_at=now - timedelta(minutes=3),
),
AnalyticsEvent(
event_type="page_view",
page="/",
element=None,
metadata_={},
session_id="session-2",
created_at=now - timedelta(minutes=2),
),
AnalyticsEvent(
event_type="page_view",
page="/pack-walks",
element=None,
metadata_={},
session_id="session-2",
created_at=now - timedelta(minutes=1),
),
AnalyticsEvent(
event_type="page_view",
page="/pack-walks",
element=None,
metadata_={},
session_id="session-2",
created_at=now,
),
])
await session.commit()
response = await client.get(
"/api/v1/analytics/summary",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200, response.text
body = response.json()
assert "top_journeys" in body
assert body["top_journeys"][0] == {"label": "/ -> /pack-walks", "count": 2}
assert all(item["label"] != "/pack-walks -> /pack-walks" for item in body["top_journeys"])
+41
View File
@@ -0,0 +1,41 @@
import pytest
from sqlalchemy import select
from app.models.analytics import AnalyticsEvent
from tests.conftest import TestSessionLocal
@pytest.mark.asyncio
async def test_ingest_event_sets_anon_cookie_and_derives_session_server_side(client):
response = await client.post(
"/api/web/event",
json={
"event_type": "cta_click",
"page": "/pack-walks",
"element": "Book Now [pricing-card]",
"metadata": {
"variant": "pricing-card-weekly",
"destination": "/contact",
"screen": "1920x1080",
"nested": {"drop": True},
},
},
headers={"referer": "https://goodwalk.example/pack-walks"},
)
assert response.status_code == 201, response.text
assert "__gw_anon=" in response.headers.get("set-cookie", "")
assert "HttpOnly" in response.headers.get("set-cookie", "")
async with TestSessionLocal() as session:
result = await session.execute(
select(AnalyticsEvent).order_by(AnalyticsEvent.created_at.desc()).limit(1)
)
event = result.scalar_one()
assert event.session_id
assert event.metadata_["variant"] == "pricing-card-weekly"
assert event.metadata_["destination"] == "/contact"
assert event.metadata_["referrer"] == "https://goodwalk.example/pack-walks"
assert "screen" not in event.metadata_
assert "nested" not in event.metadata_
+81
View File
@@ -0,0 +1,81 @@
"""
Tests for the /api/v1/auth/* endpoints.
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_login_valid_credentials(client: AsyncClient, admin_user):
"""Login with correct credentials returns 200 and both tokens."""
response = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert len(data["access_token"]) > 10
assert len(data["refresh_token"]) > 10
async def test_login_invalid_password(client: AsyncClient, admin_user):
"""Login with wrong password returns 401."""
response = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "wrongpassword"},
)
assert response.status_code == 401
assert "Invalid" in response.json()["detail"]
async def test_login_unknown_email(client: AsyncClient):
"""Login with unknown email returns 401."""
response = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "whatever"},
)
assert response.status_code == 401
async def test_refresh_token_flow(client: AsyncClient, admin_user):
"""Valid refresh token returns a new token pair; old token is revoked."""
# Login to get initial tokens
login_resp = await client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "testpassword"},
)
assert login_resp.status_code == 200
tokens = login_resp.json()
original_refresh = tokens["refresh_token"]
# Use the refresh token to get a new pair
refresh_resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
assert refresh_resp.status_code == 200
new_tokens = refresh_resp.json()
assert "access_token" in new_tokens
assert "refresh_token" in new_tokens
# New refresh token should be different
assert new_tokens["refresh_token"] != original_refresh
# Using the old refresh token should now fail (revoked)
reuse_resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": original_refresh},
)
assert reuse_resp.status_code == 401
async def test_refresh_invalid_token(client: AsyncClient):
"""Passing a made-up refresh token returns 401."""
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "not-a-real-token"},
)
assert response.status_code == 401
+147
View File
@@ -0,0 +1,147 @@
import hashlib
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.contact_lead import ContactLead
from app.models.member import MemberVerificationCode
pytestmark = pytest.mark.asyncio
async def test_public_contact_submission_creates_lead(client: AsyncClient, db_session: AsyncSession):
response = await client.post(
"/api/contact",
json={
"name": "Jamie Smith",
"email": "jamie@example.com",
"phone": "021 333 4444",
"services": ["Pack Walks", "Puppy Visits"],
"petName": "Buddy",
"petBreed": "Shih Tzu",
"location": "Ponsonby",
"serviceAreaStatus": "in_area",
"message": "Interested in a meet and greet.",
},
)
assert response.status_code == 201
body = response.json()
assert body["status"] == "invite"
assert body["email"] == "jamie@example.com"
result = await db_session.execute(select(ContactLead).where(ContactLead.email == "jamie@example.com"))
lead = result.scalars().first()
assert lead is not None
assert lead.requested_services == "Pack Walks, Puppy Visits"
assert lead.pet_name == "Buddy"
async def test_admin_can_invite_lead_into_onboarding(client: AsyncClient, admin_token: str):
create_response = await client.post(
"/api/contact",
json={
"name": "Jamie Smith",
"email": "jamie@example.com",
"phone": "021 333 4444",
"services": ["Pack Walks"],
"petName": "Buddy",
"petBreed": "Shih Tzu",
"location": "Ponsonby",
"message": "Interested in a meet and greet.",
},
)
lead_id = create_response.json()["id"]
invite_response = await client.post(
f"/api/v1/admin/leads/{lead_id}/invite",
headers={"Authorization": f"Bearer {admin_token}"},
json={"send_email": False},
)
assert invite_response.status_code == 200
body = invite_response.json()
assert body["lead"]["status"] == "invited"
assert body["member_status"] == "invited"
assert body["member_id"]
members_response = await client.get(
"/api/v1/admin/members",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert any(member["email"] == "jamie@example.com" for member in members_response.json())
async def test_lead_becomes_converted_after_member_activation(
client: AsyncClient,
admin_token: str,
db_session: AsyncSession,
):
create_response = await client.post(
"/api/contact",
json={
"name": "Jamie Smith",
"email": "jamie@example.com",
"services": ["Pack Walks"],
"petName": "Buddy",
},
)
lead_id = create_response.json()["id"]
invite_response = await client.post(
f"/api/v1/admin/leads/{lead_id}/invite",
headers={"Authorization": f"Bearer {admin_token}"},
json={"send_email": False},
)
member_id = invite_response.json()["member_id"]
await client.post("/api/v1/members/claim/request", json={"email": "jamie@example.com"})
await db_session.execute(
MemberVerificationCode.__table__.update()
.values(code_hash=hashlib.sha256("AABBCC".encode()).hexdigest())
)
await db_session.commit()
await client.post(
"/api/v1/members/claim/complete",
json={"email": "jamie@example.com", "code": "AABBCC", "password": "NewPass99!"},
)
await client.post(
"/api/v1/members/auth/login",
json={"email": "jamie@example.com", "password": "NewPass99!"},
)
await db_session.execute(
MemberVerificationCode.__table__.update()
.where(MemberVerificationCode.purpose == "login_2fa")
.values(code_hash=hashlib.sha256("112233".encode()).hexdigest())
)
await db_session.commit()
verify_response = await client.post(
"/api/v1/members/auth/login/verify",
json={"email": "jamie@example.com", "code": "112233"},
)
access_token = verify_response.json()["access_token"]
await client.put(
"/api/v1/members/onboarding",
headers={"Authorization": f"Bearer {access_token}"},
json={"onboarding_data": {"dog_name": "Buddy"}, "complete_onboarding": True},
)
await client.post(
"/api/v1/members/onboarding/contract",
headers={"Authorization": f"Bearer {access_token}"},
json={"signer_name": "Jamie Smith", "agreed": True},
)
activate_response = await client.post(
f"/api/v1/admin/members/{member_id}/activate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert activate_response.status_code == 200
leads_response = await client.get(
"/api/v1/admin/leads",
headers={"Authorization": f"Bearer {admin_token}"},
)
matching_lead = next(lead for lead in leads_response.json() if lead["id"] == lead_id)
assert matching_lead["status"] == "converted"
+252
View File
@@ -0,0 +1,252 @@
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.models.experiment import ExperimentEvent
from app.models.settings import SiteSettings
from tests.conftest import TestSessionLocal
@pytest.mark.asyncio
async def test_list_experiments_returns_seeded_registry(client):
response = await client.get("/api/experiments")
assert response.status_code == 200, response.text
body = response.json()
assert [item["experiment_key"] for item in body] == [
"homepage_hero_test",
"pricing_cta_test",
]
assert body[0]["cookie_name"] == "exp_homepage_hero"
assert body[0]["variants"][0]["variant_key"] == "control"
@pytest.mark.asyncio
async def test_ingest_experiment_event_persists_event(client):
now = datetime.now(timezone.utc).isoformat()
response = await client.post(
"/api/experiments/event",
json={
"experiment_key": "homepage_hero_test",
"variant_key": "control",
"session_id": "session_abcd1234",
"path": "/",
"event_name": "cta_click",
"timestamp": now,
"metadata": {
"element": "Explore Pack Walks",
"slot": "primary",
},
},
headers={"user-agent": "Mozilla/5.0"},
)
assert response.status_code == 202, response.text
assert response.json() == {"ok": True, "accepted": True}
async with TestSessionLocal() as session:
result = await session.execute(select(ExperimentEvent))
event = result.scalar_one()
assert event.experiment_key == "homepage_hero_test"
assert event.variant_key == "control"
assert event.event_type == "cta_click"
assert event.metadata_["element"] == "Explore Pack Walks"
@pytest.mark.asyncio
async def test_ingest_experiment_event_filters_bots(client):
response = await client.post(
"/api/experiments/impression",
json={
"experiment_key": "homepage_hero_test",
"variant_key": "control",
"session_id": "session_abcd1234",
"path": "/",
"event_name": "impression",
"timestamp": datetime.now(timezone.utc).isoformat(),
},
headers={"user-agent": "Googlebot/2.1"},
)
assert response.status_code == 202, response.text
assert response.json() == {"ok": True, "accepted": False}
async with TestSessionLocal() as session:
result = await session.execute(select(ExperimentEvent))
assert result.scalars().all() == []
@pytest.mark.asyncio
async def test_experiment_results_aggregate_by_variant(client, admin_token):
now = datetime.now(timezone.utc)
async with TestSessionLocal() as session:
session.add_all(
[
ExperimentEvent(
experiment_key="pricing_cta_test",
variant_key="control",
session_id="session-1",
path="/our-pricing",
event_type="impression",
created_at=(now - timedelta(minutes=5)).replace(tzinfo=None),
),
ExperimentEvent(
experiment_key="pricing_cta_test",
variant_key="control",
session_id="session-1",
path="/our-pricing",
event_type="cta_click",
created_at=(now - timedelta(minutes=4)).replace(tzinfo=None),
),
ExperimentEvent(
experiment_key="pricing_cta_test",
variant_key="control",
session_id="session-1",
path="/our-pricing",
event_type="conversion",
conversion_value=Decimal("1.00"),
created_at=(now - timedelta(minutes=3)).replace(tzinfo=None),
),
ExperimentEvent(
experiment_key="pricing_cta_test",
variant_key="meet_greet_emphasis",
session_id="session-2",
path="/our-pricing",
event_type="impression",
created_at=(now - timedelta(minutes=2)).replace(tzinfo=None),
),
ExperimentEvent(
experiment_key="pricing_cta_test",
variant_key="meet_greet_emphasis",
session_id="session-3",
path="/our-pricing",
event_type="impression",
created_at=(now - timedelta(minutes=1)).replace(tzinfo=None),
),
]
)
await session.commit()
response = await client.get(
"/api/v1/experiments/results?experiment_key=pricing_cta_test",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200, response.text
body = response.json()
assert len(body) == 1
assert body[0]["experiment_key"] == "pricing_cta_test"
assert body[0]["variants"][0] == {
"variant_key": "control",
"impressions": 1,
"cta_clicks": 1,
"form_starts": 0,
"form_submits": 0,
"conversions": 1,
"unique_sessions": 1,
"conversion_rate": 1.0,
"conversion_value_total": 1.0,
}
@pytest.mark.asyncio
async def test_admin_can_update_backend_managed_experiment_definition(client, admin_token):
response = await client.put(
"/api/admin/experiments/homepage_hero_test",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"cookie_name": "exp_homepage_hero",
"name": "Homepage hero test",
"description": "Updated from admin",
"enabled": False,
"eligible_routes": ["/", "/contact"],
"variants": [
{
"variant_key": "control",
"label": "Original",
"allocation": 20,
"is_control": True,
},
{
"variant_key": "tiny_gang_social_proof",
"label": "Social proof",
"allocation": 80,
"is_control": False,
},
],
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["enabled"] is False
assert body["eligible_routes"] == ["/", "/contact"]
assert body["variants"][1]["allocation"] == 80
public_response = await client.get("/api/experiments")
assert public_response.status_code == 200, public_response.text
public_body = public_response.json()
updated = next(item for item in public_body if item["experiment_key"] == "homepage_hero_test")
assert updated["enabled"] is False
assert updated["description"] == "Updated from admin"
@pytest.mark.asyncio
async def test_public_experiments_return_empty_when_globally_disabled(client):
async with TestSessionLocal() as session:
session.add(SiteSettings(site_name="", experiments_enabled=False))
await session.commit()
response = await client.get("/api/experiments")
assert response.status_code == 200, response.text
assert response.json() == []
@pytest.mark.asyncio
async def test_experiment_ingest_is_ignored_when_globally_disabled(client):
async with TestSessionLocal() as session:
session.add(SiteSettings(site_name="", experiments_enabled=False))
await session.commit()
now = datetime.now(timezone.utc).isoformat()
response = await client.post(
"/api/experiments/event",
json={
"experiment_key": "homepage_hero_test",
"variant_key": "control",
"session_id": "session_abcd1234",
"path": "/",
"event_name": "cta_click",
"timestamp": now,
},
headers={"user-agent": "Mozilla/5.0"},
)
assert response.status_code == 202, response.text
assert response.json() == {"ok": True, "accepted": False}
async with TestSessionLocal() as session:
result = await session.execute(select(ExperimentEvent))
assert result.scalars().all() == []
@pytest.mark.asyncio
async def test_admin_experiments_returns_404_when_globally_disabled(client, admin_token):
async with TestSessionLocal() as session:
session.add(SiteSettings(site_name="", experiments_enabled=False))
await session.commit()
response = await client.get(
"/api/admin/experiments",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404, response.text
+34
View File
@@ -0,0 +1,34 @@
"""
Sync import-time tests — specifically the regression where missing email-validator
caused the backend to crash on startup.
These are plain (non-async) tests and deliberately live in a separate file so the
module-level `pytestmark = pytest.mark.asyncio` in test_members.py doesn't apply.
"""
def test_app_imports_without_error():
"""Regression: importing app.main must not raise (email-validator missing)."""
import importlib
import app.main # noqa: F401 — side-effect: registers all routers
importlib.import_module("app.schemas.member") # triggers Pydantic model construction
def test_member_schemas_construct():
"""All member Pydantic models can be instantiated — catches EmailStr import errors."""
from app.schemas.member import (
ClaimRequestSchema,
ClaimCompleteSchema,
MemberLoginSchema,
MemberLoginVerifySchema,
MemberProfileUpdate,
BookingCreate,
AdminCreateMember,
)
ClaimRequestSchema(email="a@b.com")
ClaimCompleteSchema(email="a@b.com", code="ABC123", password="secret99")
MemberLoginSchema(email="a@b.com", password="secret99")
MemberLoginVerifySchema(email="a@b.com", code="ABC123")
MemberProfileUpdate(first_name="Jane")
BookingCreate(service_type="pack_walk")
AdminCreateMember(email="a@b.com", first_name="Jane", last_name="Doe")
+271
View File
@@ -0,0 +1,271 @@
from datetime import datetime, timezone
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.section import ContentSection
from app.models.member import AdminMessage, Booking, Member
from app.models.settings import SiteSettings
from app.services.notifications import run_automatic_notifications
from app.auth.password import hash_password
pytestmark = pytest.mark.asyncio
async def _create_member(
db: AsyncSession,
email: str,
*,
claimed: bool = False,
member_status: str | None = None,
) -> Member:
member = Member(
email=email,
first_name="Jane",
last_name="Doe",
phone="021 000 0000",
is_claimed=claimed,
is_active=True,
notifications_enabled=True,
member_status=member_status or ("active" if claimed else "invited"),
hashed_password=hash_password("Password1!") if claimed else None,
onboarding_data={"dog_name": "Buddy", "breed": "Labrador"},
)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def test_activation_sends_member_message(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "notify-active@example.com", claimed=True, member_status="pending_review")
member.onboarding_completed_at = datetime.now(timezone.utc)
member.contract_signed_at = datetime.now(timezone.utc)
member.contract_signer_name = "Jane Doe"
await db_session.commit()
response = await client.post(
f"/api/v1/admin/members/{member.id}/activate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
messages = result.scalars().all()
assert len(messages) == 1
assert messages[0].subject == "Your Goodwalk members account is now active"
async def test_activation_respects_member_notification_toggle(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "notify-muted@example.com", claimed=True, member_status="pending_review")
member.onboarding_completed_at = datetime.now(timezone.utc)
member.contract_signed_at = datetime.now(timezone.utc)
member.contract_signer_name = "Jane Doe"
member.notifications_enabled = False
await db_session.commit()
response = await client.post(
f"/api/v1/admin/members/{member.id}/activate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
assert result.scalars().first() is None
async def test_global_notification_toggle_suppresses_activation_message(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "notify-global-off@example.com", claimed=True, member_status="pending_review")
member.onboarding_completed_at = datetime.now(timezone.utc)
member.contract_signed_at = datetime.now(timezone.utc)
member.contract_signer_name = "Jane Doe"
db_session.add(
SiteSettings(
site_name="Goodwalk",
automatic_member_notifications_enabled=False,
nz_public_holiday_notifications_enabled=True,
invoice_reminder_notifications_enabled=True,
invoice_day_of_week=1,
)
)
await db_session.commit()
response = await client.post(
f"/api/v1/admin/members/{member.id}/activate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
assert result.scalars().first() is None
async def test_booking_confirmation_sends_member_message(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "bookingnotify@example.com", claimed=True, member_status="active")
booking = Booking(
member_id=member.id,
service_type="pack_walk",
status="pending",
requested_date=datetime(2026, 4, 7, 9, 0, tzinfo=timezone.utc),
)
db_session.add(booking)
await db_session.commit()
await db_session.refresh(booking)
response = await client.put(
f"/api/v1/admin/bookings/{booking.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"status": "confirmed"},
)
assert response.status_code == 200
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
messages = result.scalars().all()
assert len(messages) == 1
assert "confirmed" in messages[0].subject.lower()
async def test_activation_uses_custom_template_content(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "custom-active@example.com", claimed=True, member_status="pending_review")
member.onboarding_completed_at = datetime.now(timezone.utc)
member.contract_signed_at = datetime.now(timezone.utc)
member.contract_signer_name = "Jane Doe"
db_session.add(
ContentSection(
key="notifications.automaticMessages",
data={
"templates": {
"member_activated": {
"subject": "Welcome live, {{member_first_name}}",
"body": "{{member_first_name}}, your members area is unlocked.",
}
}
},
)
)
await db_session.commit()
response = await client.post(
f"/api/v1/admin/members/{member.id}/activate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
message = result.scalars().one()
assert message.subject == "Welcome live, Jane"
assert message.body == "Jane, your members area is unlocked."
async def test_automatic_notifications_send_and_dedupe(db_session: AsyncSession):
enabled_member = await _create_member(db_session, "holidaymember@example.com", claimed=True, member_status="active")
muted_member = await _create_member(db_session, "holidaymuted@example.com", claimed=True, member_status="active")
muted_member.notifications_enabled = False
db_session.add(
SiteSettings(
site_name="Goodwalk",
automatic_member_notifications_enabled=True,
nz_public_holiday_notifications_enabled=True,
invoice_reminder_notifications_enabled=False,
invoice_day_of_week=1,
)
)
await db_session.commit()
summary = await run_automatic_notifications(db_session, now=datetime(2026, 7, 10, 0, 30, tzinfo=timezone.utc))
await db_session.commit()
assert summary.public_holiday_messages_sent == 1
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == enabled_member.id))
first_run_messages = result.scalars().all()
assert len(first_run_messages) == 1
assert "public holiday" in first_run_messages[0].subject.lower()
second_summary = await run_automatic_notifications(db_session, now=datetime(2026, 7, 10, 10, 0, tzinfo=timezone.utc))
await db_session.commit()
assert second_summary.public_holiday_messages_sent == 0
async def test_public_holiday_automation_uses_custom_template(db_session: AsyncSession):
member = await _create_member(db_session, "holiday-template@example.com", claimed=True, member_status="active")
db_session.add(
SiteSettings(
site_name="Goodwalk",
automatic_member_notifications_enabled=True,
nz_public_holiday_notifications_enabled=True,
invoice_reminder_notifications_enabled=False,
invoice_day_of_week=1,
)
)
db_session.add(
ContentSection(
key="notifications.publicHolidays",
data={
"subject": "Heads up for {{holiday_name}}",
"body": "{{member_first_name}}, service may change on {{holiday_name}}.",
},
)
)
await db_session.commit()
summary = await run_automatic_notifications(db_session, now=datetime(2026, 7, 10, 0, 30, tzinfo=timezone.utc))
await db_session.commit()
assert summary.public_holiday_messages_sent == 1
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
message = result.scalars().one()
assert message.subject == "Heads up for Matariki"
assert message.body == "Jane, service may change on Matariki."
async def test_invoice_automation_uses_custom_template(db_session: AsyncSession):
member = await _create_member(db_session, "invoice-template@example.com", claimed=True, member_status="active")
db_session.add(
SiteSettings(
site_name="Goodwalk",
automatic_member_notifications_enabled=True,
nz_public_holiday_notifications_enabled=False,
invoice_reminder_notifications_enabled=True,
invoice_day_of_week=1,
)
)
db_session.add(
ContentSection(
key="notifications.invoiceReminders",
data={
"subject": "Invoices go out on {{weekday_label}}",
"body": "Preview date: {{invoice_date_label}}.",
},
)
)
await db_session.commit()
summary = await run_automatic_notifications(db_session, now=datetime(2026, 4, 7, 1, 0, tzinfo=timezone.utc))
await db_session.commit()
assert summary.invoice_reminders_sent == 1
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
message = result.scalars().one()
assert message.subject == "Invoices go out on Tuesday"
assert message.body == "Preview date: Tuesday 7 April."
async def test_global_automatic_notification_toggle_suppresses_automation(db_session: AsyncSession):
await _create_member(db_session, "invoice@example.com", claimed=True, member_status="active")
db_session.add(
SiteSettings(
site_name="Goodwalk",
automatic_member_notifications_enabled=False,
nz_public_holiday_notifications_enabled=True,
invoice_reminder_notifications_enabled=True,
invoice_day_of_week=1,
)
)
await db_session.commit()
summary = await run_automatic_notifications(db_session, now=datetime(2026, 4, 7, 1, 0, tzinfo=timezone.utc))
await db_session.commit()
assert summary.automatic_member_notifications_enabled is False
assert summary.invoice_reminders_sent == 0
+740
View File
@@ -0,0 +1,740 @@
"""
Tests for the members area.
Covers:
- Startup regression: app imports without crashing (no email-validator missing)
- Claim flow: request code → complete with password
- Login flow: password check → 2FA → JWT tokens
- Token refresh
- Protected member endpoints: me, walks, bookings, contract, messages
- Admin endpoints: create member, list members, record walk, send message
"""
import pytest
from datetime import datetime, timezone
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
from app.models.member import Member, MemberVerificationCode, Walk, Booking, AdminMessage
from app.models.settings import SiteSettings
from app.auth.password import hash_password
pytestmark = [pytest.mark.asyncio, pytest.mark.members_admin]
# ── Startup regression ─────────────────────────────────────────────────────────
# ── Helpers ────────────────────────────────────────────────────────────────────
async def _create_member(
db: AsyncSession,
email: str = "member@example.com",
claimed: bool = False,
member_status: str | None = None,
) -> Member:
m = Member(
email=email,
first_name="Jane",
last_name="Doe",
phone="021 000 0000",
is_claimed=claimed,
is_active=True,
member_status=member_status or ("active" if claimed else "invited"),
hashed_password=hash_password("Password1!") if claimed else None,
onboarding_data={"dog_name": "Buddy", "breed": "Labrador"},
)
db.add(m)
await db.commit()
await db.refresh(m)
return m
async def _get_latest_code(db: AsyncSession, member_id, purpose: str) -> str:
result = await db.execute(
select(MemberVerificationCode)
.where(
MemberVerificationCode.member_id == member_id,
MemberVerificationCode.purpose == purpose,
MemberVerificationCode.used_at.is_(None),
)
.order_by(MemberVerificationCode.created_at.desc())
)
vc = result.scalars().first()
assert vc is not None, f"No unused {purpose} code found"
# Reverse-lookup the plaintext from the hash by brute-force on our fixture value.
# Since we control _generate_code in tests via known fixture, we read the hash
# and return it for use in the full flow — we need to inject a known code instead.
return vc # caller gets the row and patches it directly
# ── Admin: create member ───────────────────────────────────────────────────────
async def test_admin_create_member(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/admin/members",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "newmember@example.com",
"first_name": "John",
"last_name": "Smith",
"phone": "021 111 1111",
"onboarding_data": {"dog_name": "Rex"},
},
)
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "newmember@example.com"
assert data["is_claimed"] is False
assert data["member_status"] == "invited"
async def test_admin_create_member_with_special_rate_and_force_two_factor(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/admin/members",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "specialrate@example.com",
"first_name": "Casey",
"last_name": "Ngata",
"service_pricing_overrides": {"pack_walk": 49.5},
"force_two_factor": True,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["service_pricing_overrides"] == {"pack_walk": 49.5}
assert data["force_two_factor"] is True
async def test_admin_create_member_duplicate(client: AsyncClient, admin_token: str, db_session: AsyncSession):
await _create_member(db_session, "dup@example.com")
resp = await client.post(
"/api/v1/admin/members",
headers={"Authorization": f"Bearer {admin_token}"},
json={"email": "dup@example.com", "first_name": "X", "last_name": "Y"},
)
assert resp.status_code == 409
async def test_admin_list_members(client: AsyncClient, admin_token: str, db_session: AsyncSession):
await _create_member(db_session, "list1@example.com")
await _create_member(db_session, "list2@example.com")
resp = await client.get(
"/api/v1/admin/members",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert len(resp.json()) >= 2
async def test_admin_can_move_member_back_to_onboarding(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "statusreset@example.com", claimed=True, member_status="active")
member.onboarding_completed_at = datetime.now(timezone.utc)
member.contract_signed_at = datetime.now(timezone.utc)
member.contract_signer_name = "Jane Doe"
member.contract_version = "goodwalk-service-agreement-2026-03"
member.activated_at = datetime.now(timezone.utc)
await db_session.commit()
resp = await client.put(
f"/api/v1/admin/members/{member.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"member_status": "onboarding"},
)
assert resp.status_code == 200
data = resp.json()
assert data["member_status"] == "onboarding"
assert data["activated_at"] is None
assert data["onboarding_completed_at"] is None
assert data["contract_signed_at"] is None
async def test_admin_cannot_move_unclaimed_member_into_onboarding(client: AsyncClient, admin_token: str, db_session: AsyncSession):
member = await _create_member(db_session, "unclaimedstatus@example.com", claimed=False, member_status="invited")
resp = await client.put(
f"/api/v1/admin/members/{member.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"member_status": "onboarding"},
)
assert resp.status_code == 400
async def test_admin_requires_auth(client: AsyncClient):
resp = await client.get("/api/v1/admin/members")
assert resp.status_code in (401, 403) # HTTPBearer raises 403; get_current_user raises 401
# ── Claim flow ─────────────────────────────────────────────────────────────────
async def test_claim_request_unknown_email(client: AsyncClient):
"""Unknown email still returns 200 (no enumeration)."""
resp = await client.post(
"/api/v1/members/claim/request",
json={"email": "nobody@example.com"},
)
assert resp.status_code == 200
async def test_claim_request_already_claimed(client: AsyncClient, db_session: AsyncSession):
await _create_member(db_session, "claimed@example.com", claimed=True)
resp = await client.post(
"/api/v1/members/claim/request",
json={"email": "claimed@example.com"},
)
assert resp.status_code == 200 # still generic response
async def test_full_claim_flow(client: AsyncClient, db_session: AsyncSession):
"""Unclaimed member can claim their account and then log in."""
member = await _create_member(db_session, "claimme@example.com", claimed=False)
# Step 1 — request claim code
resp = await client.post(
"/api/v1/members/claim/request",
json={"email": "claimme@example.com"},
)
assert resp.status_code == 200
# Step 2 — retrieve the code row and inject a known plaintext
import hashlib, secrets
known_code = "AABBCC"
code_hash = hashlib.sha256(known_code.encode()).hexdigest()
await db_session.execute(
MemberVerificationCode.__table__.update()
.where(
MemberVerificationCode.member_id == member.id,
MemberVerificationCode.purpose == "claim",
)
.values(code_hash=code_hash)
)
await db_session.commit()
# Step 3 — complete claim
resp = await client.post(
"/api/v1/members/claim/complete",
json={"email": "claimme@example.com", "code": known_code, "password": "NewPass99!"},
)
assert resp.status_code == 200
# Verify member is now claimed
await db_session.refresh(member)
assert member.is_claimed is True
assert member.hashed_password is not None
assert member.member_status == "onboarding"
async def test_claim_wrong_code(client: AsyncClient, db_session: AsyncSession):
member = await _create_member(db_session, "wrongcode@example.com", claimed=False)
await client.post("/api/v1/members/claim/request", json={"email": "wrongcode@example.com"})
resp = await client.post(
"/api/v1/members/claim/complete",
json={"email": "wrongcode@example.com", "code": "ZZZZZZ", "password": "NewPass99!"},
)
assert resp.status_code == 400
async def test_claim_short_password(client: AsyncClient, db_session: AsyncSession):
await _create_member(db_session, "shortpw@example.com", claimed=False)
resp = await client.post(
"/api/v1/members/claim/complete",
json={"email": "shortpw@example.com", "code": "AABBCC", "password": "short"},
)
assert resp.status_code == 422
# ── Login / 2FA flow ───────────────────────────────────────────────────────────
async def _do_full_login(client: AsyncClient, db_session: AsyncSession, email: str = "login@example.com") -> dict:
"""Helper: create claimed member, go through full 2FA login, return tokens."""
import hashlib
member = await _create_member(db_session, email, claimed=True)
resp = await client.post(
"/api/v1/members/auth/login",
json={"email": email, "password": "Password1!"},
)
assert resp.status_code == 200
known_code = "112233"
code_hash = hashlib.sha256(known_code.encode()).hexdigest()
await db_session.execute(
MemberVerificationCode.__table__.update()
.where(
MemberVerificationCode.member_id == member.id,
MemberVerificationCode.purpose == "login_2fa",
)
.values(code_hash=code_hash)
)
await db_session.commit()
resp = await client.post(
"/api/v1/members/auth/login/verify",
json={"email": email, "code": known_code},
)
assert resp.status_code == 200
return resp.json()
async def test_login_invalid_password(client: AsyncClient, db_session: AsyncSession):
await _create_member(db_session, "badpw@example.com", claimed=True)
resp = await client.post(
"/api/v1/members/auth/login",
json={"email": "badpw@example.com", "password": "WrongPass!"},
)
assert resp.status_code == 401
async def test_login_unclaimed_member(client: AsyncClient, db_session: AsyncSession):
await _create_member(db_session, "unclaimed@example.com", claimed=False)
resp = await client.post(
"/api/v1/members/auth/login",
json={"email": "unclaimed@example.com", "password": "Password1!"},
)
assert resp.status_code == 401
async def test_full_login_flow(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "fulllogin@example.com")
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "bearer"
async def test_login_bypasses_two_factor_when_disabled(client: AsyncClient, db_session: AsyncSession):
await _create_member(db_session, "no2fa@example.com", claimed=True)
db_session.add(SiteSettings(site_name="", two_factor_enabled=False))
await db_session.commit()
resp = await client.post(
"/api/v1/members/auth/login",
json={"email": "no2fa@example.com", "password": "Password1!"},
)
assert resp.status_code == 200
body = resp.json()
assert body["two_factor_required"] is False
assert body["access_token"]
assert body["refresh_token"]
code_result = await db_session.execute(
select(MemberVerificationCode).where(MemberVerificationCode.purpose == "login_2fa")
)
assert code_result.scalars().all() == []
async def test_login_requires_two_factor_when_member_override_enabled(client: AsyncClient, db_session: AsyncSession):
member = await _create_member(db_session, "force2fa@example.com", claimed=True)
member.force_two_factor = True
db_session.add(SiteSettings(site_name="", two_factor_enabled=False))
await db_session.commit()
resp = await client.post(
"/api/v1/members/auth/login",
json={"email": "force2fa@example.com", "password": "Password1!"},
)
assert resp.status_code == 200
body = resp.json()
assert body["two_factor_required"] is True
import hashlib
known_code = "445566"
code_hash = hashlib.sha256(known_code.encode()).hexdigest()
await db_session.execute(
MemberVerificationCode.__table__.update()
.where(
MemberVerificationCode.member_id == member.id,
MemberVerificationCode.purpose == "login_2fa",
)
.values(code_hash=code_hash)
)
await db_session.commit()
verify_resp = await client.post(
"/api/v1/members/auth/login/verify",
json={"email": "force2fa@example.com", "code": known_code},
)
assert verify_resp.status_code == 200
assert "access_token" in verify_resp.json()
async def test_login_wrong_2fa_code(client: AsyncClient, db_session: AsyncSession):
await _create_member(db_session, "bad2fa@example.com", claimed=True)
await client.post(
"/api/v1/members/auth/login",
json={"email": "bad2fa@example.com", "password": "Password1!"},
)
resp = await client.post(
"/api/v1/members/auth/login/verify",
json={"email": "bad2fa@example.com", "code": "ZZZZZZ"},
)
assert resp.status_code == 401
async def test_member_token_refresh(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "refresh@example.com")
resp = await client.post(
"/api/v1/members/auth/refresh",
json={"refresh_token": tokens["refresh_token"]},
)
assert resp.status_code == 200
new_tokens = resp.json()
assert "access_token" in new_tokens
assert "refresh_token" in new_tokens
# New refresh token must differ from the old one (it's a random secret)
assert new_tokens["refresh_token"] != tokens["refresh_token"]
# Old refresh token is now revoked
resp2 = await client.post(
"/api/v1/members/auth/refresh",
json={"refresh_token": tokens["refresh_token"]},
)
assert resp2.status_code == 401
async def test_member_logout_revokes_refresh_token_and_logs_audit(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "logout@example.com")
logout_resp = await client.post(
"/api/v1/members/auth/logout",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"refresh_token": tokens["refresh_token"]},
)
assert logout_resp.status_code == 204
refresh_resp = await client.post(
"/api/v1/members/auth/refresh",
json={"refresh_token": tokens["refresh_token"]},
)
assert refresh_resp.status_code == 401
audit_result = await db_session.execute(
select(AuditLog)
.where(AuditLog.member_email == "logout@example.com", AuditLog.action_type == "logout")
.order_by(AuditLog.timestamp.desc())
)
audit_entry = audit_result.scalars().first()
assert audit_entry is not None
assert audit_entry.area == "members/logout"
async def test_member_logout_does_not_log_audit_when_audit_history_disabled(
client: AsyncClient,
db_session: AsyncSession,
):
db_session.add(SiteSettings(site_name="", audit_history_enabled=False))
await db_session.commit()
tokens = await _do_full_login(client, db_session, "noauditlogout@example.com")
logout_resp = await client.post(
"/api/v1/members/auth/logout",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"refresh_token": tokens["refresh_token"]},
)
assert logout_resp.status_code == 204
audit_result = await db_session.execute(
select(AuditLog)
.where(AuditLog.member_email == "noauditlogout@example.com", AuditLog.action_type == "logout")
.order_by(AuditLog.timestamp.desc())
)
assert audit_result.scalars().first() is None
# ── Member-authenticated endpoints ─────────────────────────────────────────────
async def test_get_profile(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "profile@example.com")
resp = await client.get(
"/api/v1/members/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["email"] == "profile@example.com"
assert data["first_name"] == "Jane"
assert data["member_status"] == "active"
async def test_update_profile(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "updateme@example.com")
resp = await client.put(
"/api/v1/members/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"first_name": "Updated", "phone": "021 999 9999"},
)
assert resp.status_code == 200
assert resp.json()["first_name"] == "Updated"
assert resp.json()["phone"] == "021 999 9999"
async def test_get_walks_empty(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "nowalks@example.com")
resp = await client.get(
"/api/v1/members/walks",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert resp.status_code == 200
assert resp.json() == []
async def test_get_contract(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "contract@example.com")
resp = await client.get(
"/api/v1/members/contract",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["email"] == "contract@example.com"
assert data["onboarding_data"]["dog_name"] == "Buddy"
async def test_create_and_get_booking(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "booking@example.com")
auth = {"Authorization": f"Bearer {tokens['access_token']}"}
resp = await client.post(
"/api/v1/members/bookings",
headers=auth,
json={"service_type": "pack_walk", "notes": "Morning preferred"},
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
resp2 = await client.get("/api/v1/members/bookings", headers=auth)
assert resp2.status_code == 200
assert len(resp2.json()) == 1
async def test_bookings_feature_can_be_disabled(client: AsyncClient, db_session: AsyncSession):
tokens = await _do_full_login(client, db_session, "bookingflag@example.com")
auth = {"Authorization": f"Bearer {tokens['access_token']}"}
db_session.add(SiteSettings(site_name="", bookings_enabled=False))
await db_session.commit()
get_resp = await client.get("/api/v1/members/bookings", headers=auth)
assert get_resp.status_code == 404
post_resp = await client.post(
"/api/v1/members/bookings",
headers=auth,
json={"service_type": "pack_walk"},
)
assert post_resp.status_code == 404
async def test_messages_and_mark_read(client: AsyncClient, db_session: AsyncSession, admin_token: str):
tokens = await _do_full_login(client, db_session, "messages@example.com")
auth = {"Authorization": f"Bearer {tokens['access_token']}"}
admin_auth = {"Authorization": f"Bearer {admin_token}"}
# Find member id
members_resp = await client.get("/api/v1/admin/members", headers=admin_auth)
member_id = next(m["id"] for m in members_resp.json() if m["email"] == "messages@example.com")
# Admin sends a message
send_resp = await client.post(
"/api/v1/admin/messages",
headers=admin_auth,
json={"member_id": member_id, "subject": "Hello!", "body": "Welcome to Goodwalk."},
)
assert send_resp.status_code == 201
# Member reads messages
msgs_resp = await client.get("/api/v1/members/messages", headers=auth)
assert msgs_resp.status_code == 200
messages = msgs_resp.json()
assert len(messages) == 1
assert messages[0]["read_at"] is None
# Mark as read
msg_id = messages[0]["id"]
read_resp = await client.put(f"/api/v1/members/messages/{msg_id}/read", headers=auth)
assert read_resp.status_code == 200
# Confirm read_at is now set
msgs_resp2 = await client.get("/api/v1/members/messages", headers=auth)
assert msgs_resp2.json()[0]["read_at"] is not None
async def test_messages_feature_can_be_disabled(client: AsyncClient, db_session: AsyncSession, admin_token: str):
tokens = await _do_full_login(client, db_session, "messageflag@example.com")
auth = {"Authorization": f"Bearer {tokens['access_token']}"}
admin_auth = {"Authorization": f"Bearer {admin_token}"}
members_resp = await client.get("/api/v1/admin/members", headers=admin_auth)
member_id = next(m["id"] for m in members_resp.json() if m["email"] == "messageflag@example.com")
db_session.add(SiteSettings(site_name="", messages_enabled=False))
await db_session.commit()
admin_send_resp = await client.post(
"/api/v1/admin/messages",
headers=admin_auth,
json={"member_id": member_id, "subject": "Hello!", "body": "Welcome to Goodwalk."},
)
assert admin_send_resp.status_code == 404
member_messages_resp = await client.get("/api/v1/members/messages", headers=auth)
assert member_messages_resp.status_code == 404
async def test_admin_record_walk(client: AsyncClient, db_session: AsyncSession, admin_token: str):
tokens = await _do_full_login(client, db_session, "walktest@example.com")
admin_auth = {"Authorization": f"Bearer {admin_token}"}
members_resp = await client.get("/api/v1/admin/members", headers=admin_auth)
member_id = next(m["id"] for m in members_resp.json() if m["email"] == "walktest@example.com")
walk_resp = await client.post(
"/api/v1/admin/walks",
headers=admin_auth,
json={
"member_id": member_id,
"walked_at": "2026-03-31T09:00:00+00:00",
"service_type": "pack_walk",
"duration_minutes": 60,
"notes": "Great session",
},
)
assert walk_resp.status_code == 201
# Member can see their walk
member_auth = {"Authorization": f"Bearer {tokens['access_token']}"}
walks_resp = await client.get("/api/v1/members/walks", headers=member_auth)
assert walks_resp.status_code == 200
assert len(walks_resp.json()) == 1
assert walks_resp.json()[0]["notes"] == "Great session"
async def test_walks_feature_can_be_disabled(client: AsyncClient, db_session: AsyncSession, admin_token: str):
tokens = await _do_full_login(client, db_session, "walkflag@example.com")
admin_auth = {"Authorization": f"Bearer {admin_token}"}
member_auth = {"Authorization": f"Bearer {tokens['access_token']}"}
members_resp = await client.get("/api/v1/admin/members", headers=admin_auth)
member_id = next(m["id"] for m in members_resp.json() if m["email"] == "walkflag@example.com")
db_session.add(SiteSettings(site_name="", walks_enabled=False))
await db_session.commit()
member_walks_resp = await client.get("/api/v1/members/walks", headers=member_auth)
assert member_walks_resp.status_code == 404
admin_walk_resp = await client.post(
"/api/v1/admin/walks",
headers=admin_auth,
json={
"member_id": member_id,
"walked_at": "2026-03-31T09:00:00+00:00",
"service_type": "pack_walk",
"duration_minutes": 60,
},
)
assert admin_walk_resp.status_code == 404
async def test_member_token_rejected_on_admin_endpoint(client: AsyncClient, db_session: AsyncSession):
"""Member JWT must not grant access to admin-only endpoints."""
tokens = await _do_full_login(client, db_session, "notadmin@example.com")
resp = await client.get(
"/api/v1/admin/members",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert resp.status_code == 401
async def test_protected_endpoint_requires_auth(client: AsyncClient):
resp = await client.get("/api/v1/members/me")
assert resp.status_code in (401, 403) # HTTPBearer raises 403; guard raises 401
async def test_onboarding_flow_requires_activation(client: AsyncClient, db_session: AsyncSession, admin_token: str):
import hashlib
member = await _create_member(db_session, "onboarding@example.com", claimed=False)
await client.post("/api/v1/members/claim/request", json={"email": "onboarding@example.com"})
known_claim_code = "AABBCC"
await db_session.execute(
MemberVerificationCode.__table__.update()
.where(
MemberVerificationCode.member_id == member.id,
MemberVerificationCode.purpose == "claim",
)
.values(code_hash=hashlib.sha256(known_claim_code.encode()).hexdigest())
)
await db_session.commit()
claim_resp = await client.post(
"/api/v1/members/claim/complete",
json={"email": "onboarding@example.com", "code": known_claim_code, "password": "NewPass99!"},
)
assert claim_resp.status_code == 200
login_resp = await client.post(
"/api/v1/members/auth/login",
json={"email": "onboarding@example.com", "password": "NewPass99!"},
)
assert login_resp.status_code == 200
known_login_code = "112233"
await db_session.execute(
MemberVerificationCode.__table__.update()
.where(
MemberVerificationCode.member_id == member.id,
MemberVerificationCode.purpose == "login_2fa",
)
.values(code_hash=hashlib.sha256(known_login_code.encode()).hexdigest())
)
await db_session.commit()
verify_resp = await client.post(
"/api/v1/members/auth/login/verify",
json={"email": "onboarding@example.com", "code": known_login_code},
)
assert verify_resp.status_code == 200
auth = {"Authorization": f"Bearer {verify_resp.json()['access_token']}"}
onboarding_resp = await client.get("/api/v1/members/onboarding", headers=auth)
assert onboarding_resp.status_code == 200
assert onboarding_resp.json()["member_status"] == "onboarding"
update_resp = await client.put(
"/api/v1/members/onboarding",
headers=auth,
json={
"phone": "021 111 1111",
"onboarding_data": {"dog_name": "Buddy", "dog_breed": "Shih Tzu"},
"complete_onboarding": True,
},
)
assert update_resp.status_code == 200
assert update_resp.json()["member_status"] == "pending_contract"
protected_resp = await client.get("/api/v1/members/me", headers=auth)
assert protected_resp.status_code == 403
sign_resp = await client.post(
"/api/v1/members/onboarding/contract",
headers=auth,
json={"signer_name": "Jane Doe", "agreed": True},
)
assert sign_resp.status_code == 200
assert sign_resp.json()["member_status"] == "pending_review"
activate_resp = await client.post(
f"/api/v1/admin/members/{member.id}/activate",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert activate_resp.status_code == 200
assert activate_resp.json()["member_status"] == "active"
active_profile_resp = await client.get("/api/v1/members/me", headers=auth)
assert active_profile_resp.status_code == 200
+129
View File
@@ -0,0 +1,129 @@
"""
Tests for the /api/v1/pages/* endpoints.
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_list_pages_empty(client: AsyncClient):
"""GET /pages returns an empty list when no pages exist."""
response = await client.get("/api/v1/pages")
assert response.status_code == 200
assert response.json() == []
async def test_create_page_without_auth(client: AsyncClient):
"""POST /pages without an auth token returns 401."""
response = await client.post(
"/api/v1/pages",
json={"title": "Test", "slug": "test", "body": "<p>Hello</p>", "published": True},
)
assert response.status_code == 401
async def test_create_page_with_auth(client: AsyncClient, admin_token: str):
"""POST /pages with a valid token creates the page and returns 201."""
response = await client.post(
"/api/v1/pages",
json={
"title": "About Us",
"slug": "about",
"body": "<p>We walk dogs.</p>",
"published": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 201
data = response.json()
assert data["slug"] == "about"
assert data["title"] == "About Us"
assert data["published"] is True
assert "id" in data
assert "created_at" in data
async def test_get_page_by_slug(client: AsyncClient, admin_token: str):
"""GET /pages/{slug} returns the created page."""
# Create the page first
await client.post(
"/api/v1/pages",
json={
"title": "Contact",
"slug": "contact",
"body": "<p>Contact us here.</p>",
"published": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
response = await client.get("/api/v1/pages/contact")
assert response.status_code == 200
data = response.json()
assert data["slug"] == "contact"
assert data["title"] == "Contact"
async def test_get_page_not_found(client: AsyncClient):
"""GET /pages/{slug} for a non-existent slug returns 404."""
response = await client.get("/api/v1/pages/does-not-exist")
assert response.status_code == 404
async def test_unpublished_page_not_visible_publicly(client: AsyncClient, admin_token: str):
"""Unpublished pages are not returned by the public GET endpoints."""
await client.post(
"/api/v1/pages",
json={
"title": "Draft",
"slug": "draft",
"body": "<p>Work in progress.</p>",
"published": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
# Should not appear in list
list_resp = await client.get("/api/v1/pages")
slugs = [p["slug"] for p in list_resp.json()]
assert "draft" not in slugs
# Should not be accessible by slug
get_resp = await client.get("/api/v1/pages/draft")
assert get_resp.status_code == 404
async def test_update_page(client: AsyncClient, admin_token: str):
"""PUT /pages/{slug} updates the page."""
await client.post(
"/api/v1/pages",
json={"title": "Old Title", "slug": "updatable", "body": "<p>Old</p>", "published": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
response = await client.put(
"/api/v1/pages/updatable",
json={"title": "New Title"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert response.json()["title"] == "New Title"
async def test_delete_page(client: AsyncClient, admin_token: str):
"""DELETE /pages/{slug} removes the page."""
await client.post(
"/api/v1/pages",
json={"title": "Bye", "slug": "bye", "body": "<p>bye</p>", "published": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
del_resp = await client.delete(
"/api/v1/pages/bye",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert del_resp.status_code == 204
get_resp = await client.get("/api/v1/pages/bye")
assert get_resp.status_code == 404