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>
This commit is contained in:
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Public endpoint for resolving an account invite code into display info.
|
||||
|
||||
Mounted as a public route (no tenant context, no auth) — used by the
|
||||
/accept-invite page on the frontend so an invitee can see what account they
|
||||
are about to join before they sign up. Uses the BYPASSRLS admin session
|
||||
factory because account_invites is account-scoped under Phase 4 RLS but the
|
||||
caller has no tenant identity yet.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.schemas.oauth import InviteLookupResponse
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["account-invite-lookup"])
|
||||
|
||||
|
||||
@router.get("/invites/{code}/lookup", response_model=InviteLookupResponse)
|
||||
async def lookup_invite(
|
||||
code: str,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> InviteLookupResponse:
|
||||
"""Return minimal display data for a valid (unused, unexpired, not revoked)
|
||||
invite. Returns 404 with `invite_invalid_or_expired_or_revoked` for any
|
||||
invalid state — the AcceptInvitePage shows a single "ask the inviter to
|
||||
resend" message regardless of which condition failed (anti-enumeration)."""
|
||||
result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == code)
|
||||
.options(
|
||||
joinedload(AccountInvite.account),
|
||||
joinedload(AccountInvite.invited_by),
|
||||
)
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
|
||||
if invite is None or not invite.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
|
||||
return InviteLookupResponse(
|
||||
account_name=invite.account.name,
|
||||
inviter_name=invite.invited_by.name,
|
||||
invited_email=invite.email,
|
||||
role=invite.role,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
|
||||
@@ -31,9 +32,21 @@ def _generate_display_code(length: int = 8) -> str:
|
||||
|
||||
|
||||
async def _sign_in_or_register(
|
||||
db: AsyncSession, provider: str, profile: OAuthProfile
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
profile: OAuthProfile,
|
||||
*,
|
||||
account_invite_code: str | None = None,
|
||||
invited_email: str | None = None,
|
||||
) -> tuple[User, bool]:
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject)."""
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
|
||||
|
||||
When ``account_invite_code`` is supplied (from the /accept-invite flow),
|
||||
a brand-new user is created inside the invited account instead of getting
|
||||
a personal account + Pro trial. Mismatch between the OAuth profile email
|
||||
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
|
||||
contract that mirrors the email+password register path.
|
||||
"""
|
||||
identity = (
|
||||
await db.execute(
|
||||
select(OAuthIdentity).where(
|
||||
@@ -53,28 +66,96 @@ async def _sign_in_or_register(
|
||||
await db.execute(select(User).where(User.email == profile.email))
|
||||
).scalar_one_or_none()
|
||||
is_new_user = user is None
|
||||
|
||||
# If the user arrived via an invite link but already has a ResolutionFlow
|
||||
# account (e.g., previously signed up with email+password), silently
|
||||
# linking the OAuth identity to that existing account would bypass the
|
||||
# invite — they'd stay in their personal account and the invite would
|
||||
# never be consumed. Fail loud instead so they can sign in and accept the
|
||||
# invite from the dashboard. The "invited user wants to transfer accounts"
|
||||
# case is a v2 concern.
|
||||
if account_invite_code and not is_new_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "email_already_registered_use_login",
|
||||
"message": (
|
||||
"An account already exists for this email. Please sign in "
|
||||
"instead, then accept the invite from your dashboard."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
invite_record: AccountInvite | None = None
|
||||
if is_new_user and account_invite_code:
|
||||
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
|
||||
# consume the same invite code.
|
||||
invite_record = (
|
||||
await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == account_invite_code)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if invite_record is None or not invite_record.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
# Verify the OAuth profile email matches what was invited. We compare
|
||||
# against the invite row directly (source of truth), but also accept
|
||||
# the client-supplied invited_email as a defensive equality check.
|
||||
if invite_record.email.lower() != profile.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
if invited_email and invited_email.lower() != invite_record.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
if is_new_user:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
if invite_record is not None:
|
||||
# Join the invited account directly — no personal account, no
|
||||
# trial creation.
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=invite_record.account_id,
|
||||
account_role=invite_record.role,
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
invite_record.accepted_by_id = user.id
|
||||
invite_record.used_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
else:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
|
||||
db.add(
|
||||
OAuthIdentity(
|
||||
@@ -98,7 +179,13 @@ async def google_callback(
|
||||
raise HTTPException(status_code=503, detail="Google sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
|
||||
profile = await google_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(db, "google", profile)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"google",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
@@ -115,7 +202,13 @@ async def microsoft_callback(
|
||||
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
|
||||
profile = await microsoft_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(db, "microsoft", profile)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"microsoft",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
|
||||
@@ -70,6 +70,7 @@ from app.api.endpoints import (
|
||||
uploads,
|
||||
webhooks,
|
||||
accounts,
|
||||
account_invite_lookup,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -95,6 +96,7 @@ api_router.include_router(webhooks.router) # Stripe webhook receiver
|
||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
||||
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
|
||||
api_router.include_router(config_endpoints.router) # Public runtime feature flags
|
||||
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoints — super_admin only
|
||||
|
||||
@@ -4,6 +4,11 @@ from pydantic import BaseModel
|
||||
class OAuthCallbackPayload(BaseModel):
|
||||
code: str
|
||||
state: str | None = None
|
||||
# When the OAuth flow originated from /accept-invite, the frontend round-trips
|
||||
# the invite code + invited email so the backend can link the new user to the
|
||||
# invited account instead of creating a personal one.
|
||||
account_invite_code: str | None = None
|
||||
invited_email: str | None = None
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
@@ -11,3 +16,17 @@ class OAuthCallbackResponse(BaseModel):
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
is_new_user: bool
|
||||
|
||||
|
||||
class InviteLookupResponse(BaseModel):
|
||||
"""Public response surface for GET /accounts/invites/{code}/lookup.
|
||||
|
||||
Returns the minimum context needed for the AcceptInvitePage:
|
||||
account name (so we can title the card), inviter name (for the resend
|
||||
fallback message), invited email (locked into the form), and role.
|
||||
"""
|
||||
|
||||
account_name: str
|
||||
inviter_name: str
|
||||
invited_email: str
|
||||
role: str
|
||||
|
||||
290
backend/tests/test_account_invite_lookup.py
Normal file
290
backend/tests/test_account_invite_lookup.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user