v1
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 0–6; 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"] == []
|
||||
@@ -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"])
|
||||
@@ -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_
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user