Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
291 lines
10 KiB
Python
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"
|