""" 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