"""Tests for the public GET /accounts/invites/{code}/lookup endpoint (consumed by the /accept-invite page on the frontend).""" import uuid from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import select from app.models.account_invite import AccountInvite @pytest.mark.asyncio async def test_invite_lookup_returns_account_info_for_valid_code( client, test_db, test_user, auth_headers ): """A freshly-created, unused, unexpired invite resolves to the inviter's account name + the inviter's display name + the invited email + role.""" with patch( "app.core.email.EmailService.send_account_invite_email", new_callable=AsyncMock, return_value=True, ): create_resp = await client.post( "/api/v1/accounts/me/invites", json={"email": "lookup@example.com", "role": "engineer"}, headers=auth_headers, ) assert create_resp.status_code == 201, create_resp.json() code = create_resp.json()["code"] response = await client.get(f"/api/v1/accounts/invites/{code}/lookup") assert response.status_code == 200, response.json() body = response.json() assert body["invited_email"] == "lookup@example.com" assert body["role"] == "engineer" assert body["inviter_name"] == test_user["user_data"]["name"] # account_name is whatever the test_user fixture seeded for the account. assert isinstance(body["account_name"], str) and body["account_name"] @pytest.mark.asyncio async def test_invite_lookup_returns_404_for_invalid_or_expired_code( client, test_db, test_user ): """Three failure modes (unknown code, expired, revoked, used) all collapse to the same 404 + invite_invalid_or_expired_or_revoked error code.""" invited_by_id = uuid.UUID(test_user["user_data"]["id"]) account_id = uuid.UUID(test_user["user_data"]["account_id"]) # 1) Unknown code unknown = await client.get("/api/v1/accounts/invites/DOESNOTEXIST/lookup") assert unknown.status_code == 404 assert unknown.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked" # 2) Expired expired_invite = AccountInvite( account_id=account_id, invited_by_id=invited_by_id, email="expired@example.com", code="EXPIREDLOOKUP01", role="engineer", expires_at=datetime.now(timezone.utc) - timedelta(days=1), ) test_db.add(expired_invite) await test_db.commit() expired = await client.get("/api/v1/accounts/invites/EXPIREDLOOKUP01/lookup") assert expired.status_code == 404 assert expired.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked" # 3) Revoked revoked_invite = AccountInvite( account_id=account_id, invited_by_id=invited_by_id, email="revoked@example.com", code="REVOKEDLOOKUP01", role="engineer", expires_at=datetime.now(timezone.utc) + timedelta(days=7), revoked_at=datetime.now(timezone.utc), ) test_db.add(revoked_invite) await test_db.commit() revoked = await client.get("/api/v1/accounts/invites/REVOKEDLOOKUP01/lookup") assert revoked.status_code == 404 assert revoked.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked" # 4) Already used used_invite = AccountInvite( account_id=account_id, invited_by_id=invited_by_id, email="used@example.com", code="USEDLOOKUP01", role="engineer", expires_at=datetime.now(timezone.utc) + timedelta(days=7), accepted_by_id=invited_by_id, used_at=datetime.now(timezone.utc), ) test_db.add(used_invite) await test_db.commit() used = await client.get("/api/v1/accounts/invites/USEDLOOKUP01/lookup") assert used.status_code == 404 assert used.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked" # Sanity: rows survived (no destructive side effects). persisted = ( await test_db.execute( select(AccountInvite).where( AccountInvite.code.in_( ["EXPIREDLOOKUP01", "REVOKEDLOOKUP01", "USEDLOOKUP01"] ) ) ) ).scalars().all() assert len(persisted) == 3 @pytest.mark.asyncio async def test_oauth_callback_links_invite_when_account_invite_code_supplied( client, test_db, test_user, auth_headers, monkeypatch ): """Brand-new OAuth user with account_invite_code joins the invited account instead of getting a personal one. Invite is marked used.""" from app.core.config import settings from app.models.user import User from app.services.oauth_providers import OAuthProfile monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy") monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy") with patch( "app.core.email.EmailService.send_account_invite_email", new_callable=AsyncMock, return_value=True, ): create_resp = await client.post( "/api/v1/accounts/me/invites", json={"email": "oauth-invite@example.com", "role": "engineer"}, headers=auth_headers, ) code = create_resp.json()["code"] inviter_account_id = uuid.UUID(test_user["user_data"]["account_id"]) profile = OAuthProfile( provider_subject="google_invite_subject_1", email="oauth-invite@example.com", name="OAuth Invitee", ) with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): response = await client.post( "/api/v1/auth/google/callback", json={ "code": "auth_code_xyz", "account_invite_code": code, "invited_email": "oauth-invite@example.com", }, ) assert response.status_code == 200, response.json() assert response.json()["is_new_user"] is True user = ( await test_db.execute( select(User).where(User.email == "oauth-invite@example.com") ) ).scalar_one() assert user.account_id == inviter_account_id assert user.account_role == "engineer" invite = ( await test_db.execute( select(AccountInvite).where(AccountInvite.code == code) ) ).scalar_one() assert invite.used_at is not None assert invite.accepted_by_id == user.id @pytest.mark.asyncio async def test_oauth_callback_existing_email_with_invite_returns_400( client, test_db, test_user, auth_headers, monkeypatch ): """If a user already exists with the invited email (e.g., previously registered via password), arriving via /accept-invite OAuth must NOT silently link the OAuth identity to their existing account and skip the invite. Surface email_already_registered_use_login so the user signs in and accepts the invite from the dashboard instead.""" from app.core.config import settings from app.services.oauth_providers import OAuthProfile monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy") monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy") # 1) Pre-existing user with a password (separate from the inviter). existing_email = "already-here@example.com" register_resp = await client.post( "/api/v1/auth/register", json={ "email": existing_email, "password": "PreviousPassword123!", "name": "Already Here", }, ) assert register_resp.status_code in (200, 201), register_resp.json() # 2) Inviter creates an invite for that exact email. with patch( "app.core.email.EmailService.send_account_invite_email", new_callable=AsyncMock, return_value=True, ): create_resp = await client.post( "/api/v1/accounts/me/invites", json={"email": existing_email, "role": "engineer"}, headers=auth_headers, ) assert create_resp.status_code == 201, create_resp.json() code = create_resp.json()["code"] # 3) The existing user does Google OAuth and the callback receives the # invite code. Backend must reject — not link silently. profile = OAuthProfile( provider_subject="google_existing_subject_1", email=existing_email, name="Already Here", ) with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): response = await client.post( "/api/v1/auth/google/callback", json={ "code": "auth_code_xyz", "account_invite_code": code, "invited_email": existing_email, }, ) assert response.status_code == 400, response.json() assert ( response.json()["detail"]["error"] == "email_already_registered_use_login" ) # 4) Sanity: the invite was NOT consumed. invite = ( await test_db.execute( select(AccountInvite).where(AccountInvite.code == code) ) ).scalar_one() assert invite.used_at is None assert invite.accepted_by_id is None @pytest.mark.asyncio async def test_oauth_callback_invite_email_mismatch_returns_400( client, test_db, test_user, auth_headers, monkeypatch ): """If the OAuth profile's email differs from the invite's email, the backend rejects the link with invite_email_mismatch (mirrors register).""" from app.core.config import settings from app.services.oauth_providers import OAuthProfile monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy") monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy") with patch( "app.core.email.EmailService.send_account_invite_email", new_callable=AsyncMock, return_value=True, ): create_resp = await client.post( "/api/v1/accounts/me/invites", json={"email": "expected@example.com", "role": "engineer"}, headers=auth_headers, ) code = create_resp.json()["code"] profile = OAuthProfile( provider_subject="google_invite_subject_2", email="different@example.com", name="Wrong Email", ) with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): response = await client.post( "/api/v1/auth/google/callback", json={ "code": "auth_code_xyz", "account_invite_code": code, "invited_email": "expected@example.com", }, ) assert response.status_code == 400, response.json() assert response.json()["detail"]["error"] == "invite_email_mismatch"