mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
763 lines
28 KiB
Python
763 lines
28 KiB
Python
from _thread import LockType
|
|
from collections.abc import Callable, Generator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from threading import Barrier, Lock
|
|
from typing import NoReturn
|
|
|
|
import pytest
|
|
|
|
from services.account_errors import (
|
|
AccountEmailDomainSuspendedError,
|
|
AccountEmailFrozenError,
|
|
InvalidOAuthInvitationError,
|
|
InvalidOAuthProviderError,
|
|
OAuthAccountBannedError,
|
|
OAuthIdentityLockUnavailableError,
|
|
OAuthInvitationAccountMismatchError,
|
|
OAuthRegistrationError,
|
|
OAuthWorkspaceCreationNotAllowedError,
|
|
)
|
|
from services.account_oauth_service import AccountOAuthService
|
|
from services.entities.account_entities import AccountSessionTokens, AccountSnapshot
|
|
from services.entities.account_oauth_entities import (
|
|
OAuthAccountRegistration,
|
|
OAuthAuthorizationRequest,
|
|
OAuthCallbackCommand,
|
|
OAuthIdentity,
|
|
OAuthInvitation,
|
|
OAuthInvitationResult,
|
|
OAuthSignInResult,
|
|
)
|
|
|
|
NOW = datetime(2026, 8, 24, 12, 0)
|
|
|
|
|
|
def _account(
|
|
*,
|
|
account_id: str = "account-1",
|
|
email: str = "user@example.com",
|
|
status: str = "active",
|
|
) -> AccountSnapshot:
|
|
return AccountSnapshot(
|
|
id=account_id,
|
|
name="User",
|
|
email=email,
|
|
avatar=None,
|
|
is_password_set=False,
|
|
interface_language="en-US",
|
|
interface_theme="light",
|
|
timezone="UTC",
|
|
last_login_at=None,
|
|
last_login_ip=None,
|
|
status=status,
|
|
initialized_at=None,
|
|
created_at=NOW,
|
|
)
|
|
|
|
|
|
class FakeProvider:
|
|
def __init__(self, identity: OAuthIdentity | None = None) -> None:
|
|
self.identity = identity or OAuthIdentity(id="provider-user", name="User", email="user@example.com")
|
|
self.authorization_requests: list[OAuthAuthorizationRequest] = []
|
|
self.codes: list[str] = []
|
|
self.identity_hook: Callable[[], None] | None = None
|
|
|
|
def get_authorization_url(self, request: OAuthAuthorizationRequest) -> str:
|
|
self.authorization_requests.append(request)
|
|
return "https://provider.example/authorize"
|
|
|
|
def get_identity(self, code: str) -> OAuthIdentity:
|
|
self.codes.append(code)
|
|
if self.identity_hook is not None:
|
|
self.identity_hook()
|
|
return self.identity
|
|
|
|
|
|
@dataclass
|
|
class FakeAccounts:
|
|
email_account: AccountSnapshot | None = None
|
|
stored: dict[str, AccountSnapshot] = field(default_factory=dict)
|
|
get_calls: list[str] = field(default_factory=list)
|
|
email_lookups: list[str] = field(default_factory=list)
|
|
activations: list[tuple[str, datetime]] = field(default_factory=list)
|
|
|
|
def get(self, account_id: str) -> AccountSnapshot | None:
|
|
self.get_calls.append(account_id)
|
|
return self.stored.get(account_id)
|
|
|
|
def find_by_email(self, email: str) -> AccountSnapshot | None:
|
|
self.email_lookups.append(email)
|
|
return self.email_account
|
|
|
|
def activate_pending(self, account_id: str, *, initialized_at: datetime) -> None:
|
|
self.activations.append((account_id, initialized_at))
|
|
|
|
def get_credentials(self, account_id: str) -> NoReturn:
|
|
raise AssertionError(account_id)
|
|
|
|
def update_profile(self, account_id: str, changes: object) -> NoReturn:
|
|
raise AssertionError((account_id, changes))
|
|
|
|
def update_password(self, account_id: str, password: object) -> NoReturn:
|
|
raise AssertionError((account_id, password))
|
|
|
|
def initialize(
|
|
self,
|
|
account_id: str,
|
|
initialization: object,
|
|
*,
|
|
invitation_code: str | None,
|
|
workspace_id: str | None,
|
|
) -> NoReturn:
|
|
raise AssertionError((account_id, initialization, invitation_code, workspace_id))
|
|
|
|
def email_exists(self, email: str) -> bool:
|
|
raise AssertionError(email)
|
|
|
|
def reset_email(self, account_id: str, *, expected_old_email: str, new_email: str) -> NoReturn:
|
|
raise AssertionError((account_id, expected_old_email, new_email))
|
|
|
|
|
|
@dataclass
|
|
class FakeIntegrations:
|
|
accounts: FakeAccounts
|
|
account_ids_by_identity: dict[tuple[str, str], str] = field(default_factory=dict)
|
|
identity_lookups: list[tuple[str, str]] = field(default_factory=list)
|
|
links: list[tuple[str, str, str]] = field(default_factory=list)
|
|
|
|
def find_account_id(self, *, provider: str, open_id: str) -> str | None:
|
|
self.identity_lookups.append((provider, open_id))
|
|
return self.account_ids_by_identity.get((provider, open_id))
|
|
|
|
def list_for_account(self, account_id: str) -> NoReturn:
|
|
raise AssertionError(account_id)
|
|
|
|
def link(self, account_id: str, *, provider: str, open_id: str) -> None:
|
|
self.links.append((account_id, provider, open_id))
|
|
self.account_ids_by_identity[(provider, open_id)] = account_id
|
|
account = self.accounts.get(account_id) or self.accounts.email_account
|
|
if account is not None:
|
|
self.accounts.email_account = account
|
|
|
|
|
|
@dataclass
|
|
class FakeAccountClaimLease:
|
|
lost: bool = False
|
|
checks: int = 0
|
|
|
|
def ensure_owned(self) -> None:
|
|
self.checks += 1
|
|
if self.lost:
|
|
raise OAuthIdentityLockUnavailableError
|
|
|
|
|
|
@dataclass
|
|
class FakeAccountClaims:
|
|
claims: list[tuple[str, str, str]] = field(default_factory=list)
|
|
account_ids: list[str] = field(default_factory=list)
|
|
identity_leases: list[FakeAccountClaimLease] = field(default_factory=list)
|
|
account_leases: list[FakeAccountClaimLease] = field(default_factory=list)
|
|
lose_identity_on_acquire: bool = False
|
|
_locks: dict[str, LockType] = field(default_factory=dict, repr=False)
|
|
_registry_lock: LockType = field(default_factory=Lock, repr=False)
|
|
|
|
@contextmanager
|
|
def acquire(self, *, provider: str, open_id: str, email: str) -> Generator[FakeAccountClaimLease, None, None]:
|
|
self.claims.append((provider, open_id, email))
|
|
lease = FakeAccountClaimLease(lost=self.lose_identity_on_acquire)
|
|
self.identity_leases.append(lease)
|
|
with self._acquire_keys((f"email:{email}", f"identity:{provider}:{open_id}")):
|
|
yield lease
|
|
lease.ensure_owned()
|
|
|
|
@contextmanager
|
|
def acquire_account(self, account_id: str) -> Generator[FakeAccountClaimLease, None, None]:
|
|
self.account_ids.append(account_id)
|
|
lease = FakeAccountClaimLease()
|
|
self.account_leases.append(lease)
|
|
with self._acquire_keys((f"account:{account_id}",)):
|
|
yield lease
|
|
lease.ensure_owned()
|
|
|
|
@contextmanager
|
|
def _acquire_keys(self, keys: tuple[str, ...]) -> Generator[None, None, None]:
|
|
with self._registry_lock:
|
|
locks = [self._locks.setdefault(key, Lock()) for key in sorted(keys)]
|
|
for lock in locks:
|
|
lock.acquire()
|
|
try:
|
|
yield
|
|
finally:
|
|
for lock in reversed(locks):
|
|
lock.release()
|
|
|
|
|
|
@dataclass
|
|
class FakeMemberships:
|
|
workspace_ids: tuple[str, ...] = ("workspace-1",)
|
|
account_ids: list[str] = field(default_factory=list)
|
|
check_hook: Callable[[], None] | None = None
|
|
|
|
def list_ids_for_account(self, account_id: str) -> tuple[str, ...]:
|
|
self.account_ids.append(account_id)
|
|
return self.workspace_ids
|
|
|
|
def has_active_membership(self, account_id: str) -> bool:
|
|
self.account_ids.append(account_id)
|
|
if self.check_hook is not None:
|
|
self.check_hook()
|
|
return bool(self.workspace_ids)
|
|
|
|
|
|
@dataclass
|
|
class FakeInvitations:
|
|
invitation: OAuthInvitation | None = None
|
|
resolutions: list[str] = field(default_factory=list)
|
|
|
|
def resolve(self, invite_token: str) -> OAuthInvitation | None:
|
|
self.resolutions.append(invite_token)
|
|
return self.invitation
|
|
|
|
|
|
@dataclass
|
|
class FakeRegistration:
|
|
account_id: str = "new-account"
|
|
registrations: list[OAuthAccountRegistration] = field(default_factory=list)
|
|
registration_hook: Callable[[], None] | None = None
|
|
|
|
def register(self, registration: OAuthAccountRegistration) -> str:
|
|
self.registrations.append(registration)
|
|
if self.registration_hook is not None:
|
|
self.registration_hook()
|
|
return self.account_id
|
|
|
|
|
|
@dataclass
|
|
class FakeRuntime:
|
|
memberships: FakeMemberships
|
|
integrations: FakeIntegrations
|
|
created_accounts: list[str] = field(default_factory=list)
|
|
default_workspace_accounts: list[str] = field(default_factory=list)
|
|
workspace_operations: list[tuple[str, str]] = field(default_factory=list)
|
|
logins: list[tuple[str, str]] = field(default_factory=list)
|
|
default_workspace_id: str | None = None
|
|
workspace_creation_error: Exception | None = None
|
|
|
|
def create_owner_workspace(self, account_id: str) -> None:
|
|
self._assert_identity_linked(account_id)
|
|
if self.workspace_creation_error is not None:
|
|
raise self.workspace_creation_error
|
|
self.created_accounts.append(account_id)
|
|
self.workspace_operations.append(("owner", account_id))
|
|
self.memberships.workspace_ids = (*self.memberships.workspace_ids, f"owner-{account_id}")
|
|
|
|
def try_join_default_workspace(self, account_id: str) -> None:
|
|
self._assert_identity_linked(account_id)
|
|
self.default_workspace_accounts.append(account_id)
|
|
self.workspace_operations.append(("default", account_id))
|
|
if self.default_workspace_id is not None:
|
|
self.memberships.workspace_ids = (*self.memberships.workspace_ids, self.default_workspace_id)
|
|
|
|
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
|
|
self.logins.append((account_id, ip_address))
|
|
return AccountSessionTokens("access", "refresh", "csrf")
|
|
|
|
def _assert_identity_linked(self, account_id: str) -> None:
|
|
if not any(linked_account_id == account_id for linked_account_id, _, _ in self.integrations.links):
|
|
raise AssertionError(f"workspace provisioning preceded identity link for {account_id}")
|
|
|
|
|
|
@dataclass
|
|
class FakePolicy:
|
|
registration_allowed: bool = True
|
|
creation_allowed: bool = True
|
|
freeze_type: str | None = None
|
|
freeze_lookups: list[str] = field(default_factory=list)
|
|
|
|
def is_registration_allowed(self) -> bool:
|
|
return self.registration_allowed
|
|
|
|
def get_freeze_type(self, email: str) -> str | None:
|
|
self.freeze_lookups.append(email)
|
|
return self.freeze_type
|
|
|
|
def is_creation_allowed(self) -> bool:
|
|
return self.creation_allowed
|
|
|
|
|
|
@dataclass
|
|
class Harness:
|
|
service: AccountOAuthService
|
|
provider: FakeProvider
|
|
providers: dict[str, FakeProvider]
|
|
accounts: FakeAccounts
|
|
integrations: FakeIntegrations
|
|
account_claims: FakeAccountClaims
|
|
memberships: FakeMemberships
|
|
invitations: FakeInvitations
|
|
registration: FakeRegistration
|
|
runtime: FakeRuntime
|
|
policy: FakePolicy
|
|
|
|
|
|
def _harness(
|
|
*,
|
|
identity: OAuthIdentity | None = None,
|
|
additional_identities: dict[str, OAuthIdentity] | None = None,
|
|
) -> Harness:
|
|
provider = FakeProvider(identity)
|
|
providers = {"github": provider}
|
|
providers.update(
|
|
{name: FakeProvider(additional_identity) for name, additional_identity in (additional_identities or {}).items()}
|
|
)
|
|
accounts = FakeAccounts()
|
|
integrations = FakeIntegrations(accounts=accounts)
|
|
account_claims = FakeAccountClaims()
|
|
memberships = FakeMemberships()
|
|
invitations = FakeInvitations()
|
|
registration = FakeRegistration()
|
|
runtime = FakeRuntime(memberships=memberships, integrations=integrations)
|
|
policy = FakePolicy()
|
|
service = AccountOAuthService(
|
|
providers=providers,
|
|
accounts=accounts,
|
|
integrations=integrations,
|
|
memberships=memberships,
|
|
invitations=invitations,
|
|
account_claims=account_claims,
|
|
registration=registration,
|
|
workspaces=runtime,
|
|
sessions=runtime,
|
|
registration_policy=policy,
|
|
workspace_policy=policy,
|
|
supported_languages=("en-US", "zh-Hans"),
|
|
now=lambda: NOW,
|
|
)
|
|
return Harness(
|
|
service=service,
|
|
provider=provider,
|
|
providers=providers,
|
|
accounts=accounts,
|
|
integrations=integrations,
|
|
account_claims=account_claims,
|
|
memberships=memberships,
|
|
invitations=invitations,
|
|
registration=registration,
|
|
runtime=runtime,
|
|
policy=policy,
|
|
)
|
|
|
|
|
|
def _bind_identity(
|
|
harness: Harness,
|
|
account: AccountSnapshot,
|
|
*,
|
|
provider: str = "github",
|
|
open_id: str = "provider-user",
|
|
) -> None:
|
|
harness.accounts.stored[account.id] = account
|
|
harness.integrations.account_ids_by_identity[(provider, open_id)] = account.id
|
|
|
|
|
|
def _command(**overrides: object) -> OAuthCallbackCommand:
|
|
values: dict[str, object] = {
|
|
"provider": "github",
|
|
"code": "code-1",
|
|
"invite_token": None,
|
|
"timezone": None,
|
|
"language": None,
|
|
"browser_language": "en-US",
|
|
"ip_address": "203.0.113.10",
|
|
}
|
|
values.update(overrides)
|
|
return OAuthCallbackCommand(**values) # type: ignore[arg-type]
|
|
|
|
|
|
def test_start_authorization_delegates_to_configured_provider() -> None:
|
|
harness = _harness()
|
|
request = OAuthAuthorizationRequest(invite_token="invite", timezone="Asia/Shanghai")
|
|
|
|
result = harness.service.start_authorization("github", request)
|
|
|
|
assert result == "https://provider.example/authorize"
|
|
assert harness.provider.authorization_requests == [request]
|
|
|
|
|
|
def test_unknown_provider_is_rejected_before_any_account_work() -> None:
|
|
harness = _harness()
|
|
|
|
with pytest.raises(InvalidOAuthProviderError):
|
|
harness.service.complete_authorization(_command(provider="unknown"))
|
|
|
|
assert harness.integrations.identity_lookups == []
|
|
|
|
|
|
def test_existing_account_login_uses_repositories_and_runtime_gateways() -> None:
|
|
harness = _harness()
|
|
_bind_identity(harness, _account())
|
|
|
|
result = harness.service.complete_authorization(_command())
|
|
|
|
assert isinstance(result, OAuthSignInResult)
|
|
assert result.oauth_new_user is False
|
|
assert harness.integrations.identity_lookups == [("github", "provider-user")]
|
|
assert harness.accounts.get_calls[0] == "account-1"
|
|
assert harness.accounts.email_lookups == []
|
|
assert harness.integrations.links == [("account-1", "github", "provider-user")]
|
|
assert harness.runtime.created_accounts == []
|
|
assert harness.runtime.logins == [("account-1", "203.0.113.10")]
|
|
assert harness.registration.registrations == []
|
|
|
|
|
|
def test_existing_account_without_workspace_obeys_creation_policy() -> None:
|
|
harness = _harness()
|
|
harness.accounts.email_account = _account()
|
|
harness.memberships.workspace_ids = ()
|
|
harness.policy.creation_allowed = False
|
|
|
|
with pytest.raises(OAuthWorkspaceCreationNotAllowedError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.integrations.links == [("account-1", "github", "provider-user")]
|
|
assert harness.runtime.created_accounts == []
|
|
|
|
|
|
def test_existing_account_without_active_workspace_creates_owner_workspace() -> None:
|
|
harness = _harness()
|
|
harness.accounts.email_account = _account()
|
|
harness.memberships.workspace_ids = ()
|
|
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.runtime.created_accounts == ["account-1"]
|
|
|
|
|
|
def test_new_account_registration_normalizes_email_and_prefers_state_language() -> None:
|
|
identity = OAuthIdentity(id="provider-user", name="", email="User@Example.com")
|
|
harness = _harness(identity=identity)
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account", email="user@example.com")
|
|
harness.memberships.workspace_ids = ()
|
|
harness.runtime.default_workspace_id = "enterprise-default"
|
|
|
|
result = harness.service.complete_authorization(
|
|
_command(language="zh-Hans", browser_language="en-US", timezone="Asia/Shanghai")
|
|
)
|
|
|
|
assert isinstance(result, OAuthSignInResult)
|
|
assert result.oauth_new_user is True
|
|
assert harness.registration.registrations == [
|
|
OAuthAccountRegistration(
|
|
email="user@example.com",
|
|
name="Dify",
|
|
language="zh-Hans",
|
|
timezone="Asia/Shanghai",
|
|
ip_address="203.0.113.10",
|
|
)
|
|
]
|
|
assert harness.memberships.account_ids == ["new-account"]
|
|
assert harness.runtime.created_accounts == ["new-account"]
|
|
assert harness.runtime.default_workspace_accounts == ["new-account"]
|
|
assert harness.runtime.workspace_operations == [("owner", "new-account"), ("default", "new-account")]
|
|
assert harness.memberships.workspace_ids == ("owner-new-account", "enterprise-default")
|
|
assert harness.integrations.links == [("new-account", "github", "provider-user")]
|
|
|
|
|
|
def test_new_account_workspace_provisioning_obeys_the_same_policy_as_existing_accounts() -> None:
|
|
harness = _harness()
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account")
|
|
harness.memberships.workspace_ids = ()
|
|
harness.policy.creation_allowed = False
|
|
|
|
with pytest.raises(OAuthWorkspaceCreationNotAllowedError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert len(harness.registration.registrations) == 1
|
|
assert harness.runtime.created_accounts == []
|
|
assert harness.runtime.default_workspace_accounts == ["new-account"]
|
|
assert harness.integrations.links == [("new-account", "github", "provider-user")]
|
|
|
|
|
|
def test_new_account_uses_default_workspace_fallback_when_creation_is_disallowed() -> None:
|
|
harness = _harness()
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account")
|
|
harness.memberships.workspace_ids = ()
|
|
harness.runtime.default_workspace_id = "enterprise-default"
|
|
harness.policy.creation_allowed = False
|
|
|
|
result = harness.service.complete_authorization(_command())
|
|
|
|
assert isinstance(result, OAuthSignInResult)
|
|
assert result.oauth_new_user is True
|
|
assert harness.runtime.default_workspace_accounts == ["new-account"]
|
|
assert harness.memberships.account_ids == ["new-account", "new-account"]
|
|
assert harness.memberships.workspace_ids == ("enterprise-default",)
|
|
assert harness.runtime.created_accounts == []
|
|
assert harness.runtime.workspace_operations == [("default", "new-account")]
|
|
|
|
|
|
def test_new_account_default_workspace_membership_bypasses_personal_workspace_quota() -> None:
|
|
harness = _harness()
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account")
|
|
harness.memberships.workspace_ids = ()
|
|
harness.runtime.default_workspace_id = "enterprise-default"
|
|
harness.runtime.workspace_creation_error = OAuthWorkspaceCreationNotAllowedError()
|
|
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.runtime.default_workspace_accounts == ["new-account"]
|
|
assert harness.memberships.workspace_ids == ("enterprise-default",)
|
|
assert harness.runtime.created_accounts == []
|
|
assert harness.runtime.workspace_operations == [("default", "new-account")]
|
|
|
|
|
|
def test_concurrent_callbacks_claim_identity_before_creating_account_or_workspace() -> None:
|
|
harness = _harness()
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account")
|
|
harness.memberships.workspace_ids = ()
|
|
identity_resolved = Barrier(2)
|
|
|
|
def synchronize_callbacks() -> None:
|
|
identity_resolved.wait(timeout=5)
|
|
|
|
harness.provider.identity_hook = synchronize_callbacks
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
futures = [executor.submit(harness.service.complete_authorization, _command()) for _ in range(2)]
|
|
results = [future.result(timeout=5) for future in futures]
|
|
|
|
assert len(harness.registration.registrations) == 1
|
|
assert harness.account_claims.claims == [
|
|
("github", "provider-user", "user@example.com"),
|
|
("github", "provider-user", "user@example.com"),
|
|
]
|
|
assert harness.integrations.links == [
|
|
("new-account", "github", "provider-user"),
|
|
("new-account", "github", "provider-user"),
|
|
]
|
|
assert harness.runtime.default_workspace_accounts == ["new-account"]
|
|
assert harness.runtime.created_accounts == ["new-account"]
|
|
assert sorted(result.oauth_new_user for result in results if isinstance(result, OAuthSignInResult)) == [False, True]
|
|
|
|
|
|
def test_concurrent_provider_callbacks_claim_normalized_email_before_registration() -> None:
|
|
harness = _harness(
|
|
identity=OAuthIdentity("github-user", "User", "Shared.User+github@GoogleMail.com"),
|
|
additional_identities={"google": OAuthIdentity("google-user", "User", "shareduser@gmail.COM")},
|
|
)
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account", email="shared@example.com")
|
|
harness.memberships.workspace_ids = ()
|
|
identity_resolved = Barrier(2)
|
|
|
|
def synchronize_callbacks() -> None:
|
|
identity_resolved.wait(timeout=5)
|
|
|
|
harness.providers["github"].identity_hook = synchronize_callbacks
|
|
harness.providers["google"].identity_hook = synchronize_callbacks
|
|
commands = [
|
|
_command(provider="github"),
|
|
_command(provider="google"),
|
|
]
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
futures = [executor.submit(harness.service.complete_authorization, command) for command in commands]
|
|
results = [future.result(timeout=5) for future in futures]
|
|
|
|
assert len(harness.registration.registrations) == 1
|
|
assert sorted(harness.account_claims.claims) == [
|
|
("github", "github-user", "shareduser@gmail.com"),
|
|
("google", "google-user", "shareduser@gmail.com"),
|
|
]
|
|
assert sorted(harness.integrations.links) == [
|
|
("new-account", "github", "github-user"),
|
|
("new-account", "google", "google-user"),
|
|
]
|
|
assert harness.runtime.default_workspace_accounts == ["new-account"]
|
|
assert harness.runtime.created_accounts == ["new-account"]
|
|
assert sorted(result.oauth_new_user for result in results if isinstance(result, OAuthSignInResult)) == [False, True]
|
|
|
|
|
|
def test_concurrent_provider_callbacks_for_one_account_serialize_workspace_provisioning() -> None:
|
|
harness = _harness(
|
|
identity=OAuthIdentity("github-user", "User", "github@example.com"),
|
|
additional_identities={"google": OAuthIdentity("google-user", "User", "google@example.com")},
|
|
)
|
|
account = _account(email="primary@example.com")
|
|
_bind_identity(harness, account, provider="github", open_id="github-user")
|
|
_bind_identity(harness, account, provider="google", open_id="google-user")
|
|
harness.memberships.workspace_ids = ()
|
|
identity_resolved = Barrier(2)
|
|
|
|
def synchronize_callbacks() -> None:
|
|
identity_resolved.wait(timeout=5)
|
|
|
|
harness.providers["github"].identity_hook = synchronize_callbacks
|
|
harness.providers["google"].identity_hook = synchronize_callbacks
|
|
commands = [
|
|
_command(provider="github"),
|
|
_command(provider="google"),
|
|
]
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
futures = [executor.submit(harness.service.complete_authorization, command) for command in commands]
|
|
results = [future.result(timeout=5) for future in futures]
|
|
|
|
assert harness.registration.registrations == []
|
|
assert sorted(harness.account_claims.claims) == [
|
|
("github", "github-user", "github@example.com"),
|
|
("google", "google-user", "google@example.com"),
|
|
]
|
|
assert harness.account_claims.account_ids == ["account-1", "account-1"]
|
|
assert harness.runtime.created_accounts == ["account-1"]
|
|
assert all(isinstance(result, OAuthSignInResult) and not result.oauth_new_user for result in results)
|
|
|
|
|
|
def test_lost_identity_claim_stops_before_registration() -> None:
|
|
harness = _harness()
|
|
harness.account_claims.lose_identity_on_acquire = True
|
|
|
|
with pytest.raises(OAuthIdentityLockUnavailableError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.registration.registrations == []
|
|
assert harness.integrations.links == []
|
|
assert harness.runtime.created_accounts == []
|
|
|
|
|
|
def test_identity_claim_lost_during_registration_stops_follow_up_writes() -> None:
|
|
harness = _harness()
|
|
harness.accounts.stored["new-account"] = _account(account_id="new-account")
|
|
|
|
def lose_identity_claim() -> None:
|
|
harness.account_claims.identity_leases[0].lost = True
|
|
|
|
harness.registration.registration_hook = lose_identity_claim
|
|
|
|
with pytest.raises(OAuthIdentityLockUnavailableError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert len(harness.registration.registrations) == 1
|
|
assert harness.integrations.links == []
|
|
assert harness.runtime.default_workspace_accounts == []
|
|
assert harness.runtime.created_accounts == []
|
|
|
|
|
|
def test_lost_account_claim_stops_before_workspace_creation() -> None:
|
|
harness = _harness()
|
|
_bind_identity(harness, _account())
|
|
harness.memberships.workspace_ids = ()
|
|
|
|
def lose_account_claim() -> None:
|
|
harness.account_claims.account_leases[0].lost = True
|
|
|
|
harness.memberships.check_hook = lose_account_claim
|
|
|
|
with pytest.raises(OAuthIdentityLockUnavailableError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.runtime.created_accounts == []
|
|
assert harness.runtime.logins == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("freeze_type", "expected_error"),
|
|
[
|
|
("email_domain_suspended", AccountEmailDomainSuspendedError),
|
|
("freeze", AccountEmailFrozenError),
|
|
(None, OAuthRegistrationError),
|
|
],
|
|
)
|
|
def test_disabled_registration_applies_account_policy(
|
|
freeze_type: str | None,
|
|
expected_error: type[Exception],
|
|
) -> None:
|
|
harness = _harness()
|
|
harness.policy.registration_allowed = False
|
|
harness.policy.freeze_type = freeze_type
|
|
|
|
with pytest.raises(expected_error):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.policy.freeze_lookups == ["user@example.com"]
|
|
assert harness.registration.registrations == []
|
|
|
|
|
|
def test_pending_account_is_activated_through_repository() -> None:
|
|
harness = _harness()
|
|
_bind_identity(harness, _account(status="pending"))
|
|
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.accounts.activations == [("account-1", NOW)]
|
|
|
|
|
|
def test_pending_account_is_not_activated_when_workspace_creation_is_disallowed() -> None:
|
|
harness = _harness()
|
|
_bind_identity(harness, _account(status="pending"))
|
|
harness.memberships.workspace_ids = ()
|
|
harness.policy.creation_allowed = False
|
|
|
|
with pytest.raises(OAuthWorkspaceCreationNotAllowedError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.accounts.activations == []
|
|
assert harness.runtime.logins == []
|
|
|
|
|
|
def test_pending_account_is_not_activated_when_workspace_creation_fails() -> None:
|
|
harness = _harness()
|
|
_bind_identity(harness, _account(status="pending"))
|
|
harness.memberships.workspace_ids = ()
|
|
harness.runtime.workspace_creation_error = RuntimeError("workspace quota exceeded")
|
|
|
|
with pytest.raises(RuntimeError, match="workspace quota exceeded"):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.accounts.activations == []
|
|
assert harness.runtime.logins == []
|
|
|
|
|
|
def test_valid_invitation_links_and_logs_in_invited_account() -> None:
|
|
harness = _harness(identity=OAuthIdentity("provider-user", "User", "Invitee@Example.com"))
|
|
harness.invitations.invitation = OAuthInvitation("invited-account", "invitee@example.com", "active")
|
|
|
|
result = harness.service.complete_authorization(_command(invite_token="invite-token"))
|
|
|
|
assert isinstance(result, OAuthInvitationResult)
|
|
assert result.invite_token == "invite-token"
|
|
assert harness.integrations.links == [("invited-account", "github", "provider-user")]
|
|
assert harness.runtime.logins == [("invited-account", "203.0.113.10")]
|
|
assert harness.integrations.identity_lookups == []
|
|
assert harness.invitations.resolutions == ["invite-token"]
|
|
|
|
|
|
def test_resolvable_invitation_requires_matching_email() -> None:
|
|
harness = _harness()
|
|
harness.invitations.invitation = OAuthInvitation("invited-account", "other@example.com", "active")
|
|
|
|
with pytest.raises(OAuthInvitationAccountMismatchError) as raised:
|
|
harness.service.complete_authorization(_command(invite_token="invite-token"))
|
|
|
|
assert raised.value.invite_token == "invite-token"
|
|
assert harness.integrations.links == []
|
|
|
|
|
|
def test_stale_invitation_is_rejected() -> None:
|
|
harness = _harness()
|
|
|
|
with pytest.raises(InvalidOAuthInvitationError):
|
|
harness.service.complete_authorization(_command(invite_token="invite-token"))
|
|
|
|
assert harness.invitations.resolutions == ["invite-token"]
|
|
assert harness.integrations.identity_lookups == []
|
|
assert harness.registration.registrations == []
|
|
|
|
|
|
def test_banned_account_is_rejected_before_writes() -> None:
|
|
harness = _harness()
|
|
_bind_identity(harness, _account(status="banned"))
|
|
|
|
with pytest.raises(OAuthAccountBannedError):
|
|
harness.service.complete_authorization(_command())
|
|
|
|
assert harness.integrations.links == []
|