741 lines
28 KiB
Python
741 lines
28 KiB
Python
"""
|
|
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
|