v1
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user