Files
resolutionflow/backend/tests/test_account_invite_lookup.py
Michael Chihlas 39e85c9770 feat(auth): add /accept-invite page + lookup endpoint
Adds the invitee-side flow for self-serve signup Phase 2 (Task 36):

Backend
- Public GET /accounts/invites/{code}/lookup returns
  {account_name, inviter_name, invited_email, role} for a valid invite,
  404 invite_invalid_or_expired_or_revoked otherwise (collapses unknown /
  expired / revoked / used into one anti-enumeration response). Mounted
  in a new account_invite_lookup endpoints module on the public route
  list, uses get_admin_db (BYPASSRLS) since the caller has no tenant.
- OAuthCallbackPayload gains optional account_invite_code + invited_email.
  _sign_in_or_register honors them: a new OAuth user with a valid invite
  joins the invited account (no personal account, no Pro trial), the
  invite is marked used, and OAuth-profile-email vs invite-email mismatch
  raises invite_email_mismatch (matching the email+password register
  contract).

Frontend
- New public route /accept-invite -> AcceptInvitePage. Reads ?code=,
  calls inviteApi.lookupAccountInvite, renders "Join {account} on
  ResolutionFlow" with the invited email locked (rendered as a div, not
  an input), three sign-in options (set password, Google, Microsoft),
  and a clear "ask {inviter} to resend" + mailto: fallback for invalid
  codes.
- OAuth state for invitees is base64url(JSON({csrf, accountInviteCode,
  invitedEmail})). OAuthCallbackPage decodes both shapes, forwards the
  invite fields to the backend, and surfaces invite_email_mismatch /
  invite_invalid_or_expired_or_revoked errors with friendly text.
  Successful invite-OAuth lands on /?welcome=teammate (suppresses the
  welcome wizard for invitees per spec).
- UserCreate type + invite/auth API clients extended for the new fields.

Tests
- Backend: invite lookup happy path + four invalid-state collapse, OAuth
  callback links invite when supplied + rejects on email mismatch.
- Frontend Vitest: AcceptInvitePage renders account name + locked email
  + accept buttons; resend message + mailto on invalid code.

All 43 backend auth/account/invite/email-verification tests green;
frontend Vitest 120/120 green; tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:34:22 -04:00

291 lines
10 KiB
Python

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