Google 계정 데이터 별칭 복구를 추가

This commit is contained in:
Yun Chan 2026-08-29 15:34:50 +09:00
parent 15f609368c
commit dba9b75a38
9 changed files with 642 additions and 30 deletions

View file

@ -177,6 +177,7 @@ class InactiveUserError(Exception):
_sessions: dict[str, SessionUser] = {}
_users: dict[str, ManagedUser] = {}
_email_index: dict[str, str] = {}
_auth_identity_alias_index: dict[str, str] = {}
_inactive_emails: set[str] = set()
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
@ -373,6 +374,19 @@ async def _runtime_tables_ready(conn) -> bool:
) AS has_persona_triggers,
to_regclass('app.persona_voice_map') IS NOT NULL AS has_persona_voice_map,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.auth_identity_alias') IS NOT NULL AS has_auth_identity_alias,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'auth_session'
AND column_name = 'login_email'
) AS has_auth_session_login_email,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'auth_identity_alias'
AND policyname = 'p_auth_identity_alias_select'
) AS has_auth_identity_alias_select_policy,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
EXISTS (
SELECT 1 FROM information_schema.columns
@ -536,6 +550,9 @@ async def _runtime_tables_ready(conn) -> bool:
and row["has_persona_triggers"]
and row["has_persona_voice_map"]
and row["has_auth_session"]
and row["has_auth_identity_alias"]
and row["has_auth_session_login_email"]
and row["has_auth_identity_alias_select_policy"]
and row["has_preferences"]
and row["has_engine_config"]
and row["has_session_columns"]
@ -669,6 +686,36 @@ async def ensure_runtime_tables() -> None:
)
"""
)
await conn.execute(
"""
ALTER TABLE app.auth_session
ADD COLUMN IF NOT EXISTS login_email TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS app.auth_identity_alias (
external_id TEXT PRIMARY KEY
CHECK (external_id ~ '^[a-z0-9_-]+:.+$'),
user_id UUID NOT NULL
REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
source_user_id UUID
REFERENCES app.app_user(user_id) ON DELETE SET NULL,
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
linked_by TEXT NOT NULL,
reason TEXT NOT NULL,
CHECK (source_user_id IS NULL OR source_user_id <> user_id)
);
CREATE INDEX IF NOT EXISTS idx_auth_identity_alias_user
ON app.auth_identity_alias(user_id, linked_at DESC);
ALTER TABLE app.auth_identity_alias ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_auth_identity_alias_select
ON app.auth_identity_alias;
CREATE POLICY p_auth_identity_alias_select
ON app.auth_identity_alias
FOR SELECT
USING (true);
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
@ -1619,6 +1666,78 @@ async def get_managed_user_by_email(email: str) -> ManagedUser | None:
return _users.get(uid or "")
async def get_managed_user_by_auth_alias(external_id: str) -> ManagedUser | None:
"""Resolve an explicitly approved provider identity to its canonical user.
Email equality never creates an alias. A configured alias whose canonical
target is inactive or suspended fails closed instead of creating a new user.
"""
normalized_external_id = (external_id or "").strip().lower()
if not normalized_external_id:
return None
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
u.user_id,
u.email,
u.display_name,
u.role,
u.admin_access,
u.learner_feedback_enabled,
u.account_status,
u.cohort,
u.affiliation,
u.legal_name,
u.department,
u.grade_level,
u.phone,
u.contact_address,
u.nickname,
u.self_introduction,
u.avatar_url,
u.consent_at,
u.profile_completed_at,
u.terms_agreed_at,
u.privacy_agreed_at,
u.terms_version,
u.privacy_version,
u.created_at,
u.last_seen_at,
u.is_active
FROM app.auth_identity_alias AS alias
JOIN app.app_user AS u ON u.user_id = alias.user_id
WHERE alias.external_id = $1
""",
normalized_external_id,
)
if row is None:
return None
if not bool(row["is_active"]) or _account_status(row["account_status"]) == "suspended":
raise InactiveUserError("aliased user is inactive")
user = _managed_user_from_row(row)
_memory_upsert_managed_user(
ManagedUserMemoryInput.from_user(user, reactivate=True)
)
_auth_identity_alias_index[normalized_external_id] = user.user_id
return user
except InactiveUserError:
raise
except Exception:
require_runtime_fallback_allowed("auth identity alias lookup")
uid = _auth_identity_alias_index.get(normalized_external_id)
user = _users.get(uid or "")
if user is None:
return None
if user.account_status == "suspended" or user.email in _inactive_emails:
raise InactiveUserError("aliased user is inactive")
return user
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
try:
pool = get_pool()
@ -2018,21 +2137,24 @@ async def create_session(
user_id: str | None = None,
external_id: str | None = None,
account_status: AccountStatus | None = None,
managed_user: ManagedUser | None = None,
) -> tuple[str, SessionUser]:
raw_sid = secrets.token_urlsafe(32)
normalized_email = _normalize_email(email)
managed = await upsert_managed_user(
ManagedUserUpsertInput(
email=normalized_email,
display_name=display_name,
role=role,
cohort_ids=cohort_ids,
user_id=user_id,
external_id=external_id,
account_status=account_status,
reactivate=False,
managed = managed_user
if managed is None:
managed = await upsert_managed_user(
ManagedUserUpsertInput(
email=normalized_email,
display_name=display_name,
role=role,
cohort_ids=cohort_ids,
user_id=user_id,
external_id=external_id,
account_status=account_status,
reactivate=False,
)
)
)
if managed.account_status == "pending":
try:
await notifications.enqueue_account_pending_approval(
@ -2067,14 +2189,16 @@ async def create_session(
await conn.execute(
"""
INSERT INTO app.auth_session (
sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at
sid_hash, user_id, role, display_name, cohort_ids,
login_email, expires_at, last_seen_at
)
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, now())
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, $7, now())
ON CONFLICT (sid_hash) DO UPDATE SET
user_id = EXCLUDED.user_id,
role = EXCLUDED.role,
display_name = EXCLUDED.display_name,
cohort_ids = EXCLUDED.cohort_ids,
login_email = EXCLUDED.login_email,
expires_at = EXCLUDED.expires_at,
revoked_at = NULL,
last_seen_at = now()
@ -2084,6 +2208,7 @@ async def create_session(
managed.role,
managed.display_name,
list(managed.cohort_ids),
normalized_email,
datetime.fromtimestamp(expires_at, tz=timezone.utc),
)
except Exception:
@ -2104,7 +2229,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
SELECT
s.expires_at,
u.user_id,
u.email,
COALESCE(NULLIF(s.login_email, ''), u.email) AS login_email,
u.email AS canonical_email,
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role,
u.admin_access,
@ -2128,7 +2254,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
_row_value(row, "admin_access", False)
)
effective_admin_access = has_admin_access(
row["email"],
row["canonical_email"],
app_role,
stored_admin_access,
)
@ -2155,11 +2281,11 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
)
return SessionUser(
user_id=str(row["user_id"]),
email=row["email"],
email=row["login_email"],
display_name=row["display_name"],
role=app_role,
admin_access=effective_admin_access,
super_admin=is_super_admin_email(row["email"]),
super_admin=is_super_admin_email(row["canonical_email"]),
account_status=_account_status(row["account_status"]),
cohort_ids=_cohort_ids(row["cohort"]),
consent_at=_optional_ts(row["consent_at"]),

View file

@ -147,6 +147,13 @@ async def healthcheck() -> bool:
SELECT
to_regclass('app.app_user') IS NOT NULL AS has_user,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.auth_identity_alias') IS NOT NULL AS has_auth_identity_alias,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'auth_session'
AND column_name = 'login_email'
) AS has_auth_session_login_email,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
EXISTS (
SELECT 1 FROM information_schema.columns
@ -210,6 +217,8 @@ async def healthcheck() -> bool:
row
and row["has_user"]
and row["has_auth_session"]
and row["has_auth_identity_alias"]
and row["has_auth_session_login_email"]
and row["has_preferences"]
and row["has_engine_config"]
and row["has_admin_health_event"]

View file

@ -29,6 +29,7 @@ from ..auth_types import AccountStatus, RoleName
from ..auth_sessions import (
ManagedUser,
get_managed_user,
get_managed_user_by_auth_alias,
get_managed_user_by_email,
has_admin_access,
InactiveUserError,
@ -705,7 +706,10 @@ async def _me_response(user: SessionUser | Principal) -> MeResponse:
)
email = getattr(user, "email", "")
role = user.role.value if isinstance(user.role, Role) else user.role
stored_admin_access = managed.admin_access if managed else getattr(user, "admin_access", False)
stored_admin_access = (
managed.admin_access if managed else getattr(user, "admin_access", False)
)
entitlement_email = managed.email if managed else email
return MeResponse(
user_id=user.user_id,
email=email,
@ -715,8 +719,8 @@ async def _me_response(user: SessionUser | Principal) -> MeResponse:
or email
),
role=role,
admin_access=has_admin_access(email, role, stored_admin_access),
super_admin=is_super_admin_email(email),
admin_access=has_admin_access(entitlement_email, role, stored_admin_access),
super_admin=is_super_admin_email(entitlement_email),
account_status=account_status,
approval_required=account_status != "approved",
cohort_ids=user.cohort_ids,
@ -970,7 +974,17 @@ async def callback(
email_domain=_email_domain(str(claims.get("email") or "")),
)
return _oauth_callback_error("id_token_invalid", request)
managed_user = await get_managed_user_by_email(email)
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
try:
aliased_user = await get_managed_user_by_auth_alias(external_id)
except InactiveUserError:
_log_oauth_callback_failure(
request,
"inactive_user",
email_domain=_email_domain(email),
)
return _oauth_callback_error("inactive_user", request)
managed_user = aliased_user or await get_managed_user_by_email(email)
role = _role_for_managed_user(managed_user, _role_for_email(email))
display_name = str(claims.get("name") or email)
cohort_ids = _cohort_ids_for_managed_user(
@ -980,16 +994,26 @@ async def callback(
hosted_domain=str(claims.get("hd") or ""),
),
)
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
try:
sid, _ = await create_session(
email=email,
display_name=display_name,
role=role.value,
cohort_ids=cohort_ids,
external_id=external_id,
account_status="approved",
)
if aliased_user is not None:
sid, _ = await create_session(
email=email,
display_name=display_name,
role=role.value,
cohort_ids=cohort_ids,
external_id=external_id,
account_status="approved",
managed_user=aliased_user,
)
else:
sid, _ = await create_session(
email=email,
display_name=display_name,
role=role.value,
cohort_ids=cohort_ids,
external_id=external_id,
account_status="approved",
)
except InactiveUserError:
_log_oauth_callback_failure(
request,

View file

@ -0,0 +1,387 @@
from __future__ import annotations
import time
import unittest
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
from starlette.requests import Request
from . import auth_sessions
from .config import settings
from .routes import auth as auth_routes
@contextmanager
def patched_settings(**values: Any):
previous = {key: getattr(settings, key) for key in values}
for key, value in values.items():
setattr(settings, key, value)
try:
yield
finally:
for key, value in previous.items():
setattr(settings, key, value)
def managed_user(
*,
user_id: str = "f646404b-a9e5-4422-a97d-548c074334d5",
email: str = "yunchan@twentyoz.kr",
account_status: str = "approved",
) -> auth_sessions.ManagedUser:
return auth_sessions.ManagedUser(
user_id=user_id,
email=email,
display_name="Yun Chan",
role="admin",
admin_access=True,
account_status=account_status,
cohort_ids=["owner"],
affiliation="TwentyOZ",
legal_name="Yun Chan",
department="",
grade_level="",
phone="",
contact_address="",
nickname="윤찬",
self_introduction="관리자",
avatar_url="",
consent_at=1.0,
profile_completed_at=1.0,
terms_agreed_at=1.0,
privacy_agreed_at=1.0,
terms_version="v1",
privacy_version="v1",
created_at=1.0,
last_seen_at=1.0,
)
def alias_row(*, active: bool = True, status: str = "approved") -> dict[str, Any]:
user = managed_user(account_status=status)
return {
"user_id": user.user_id,
"email": user.email,
"display_name": user.display_name,
"role": "admin",
"admin_access": user.admin_access,
"learner_feedback_enabled": True,
"account_status": status,
"cohort": "owner",
"affiliation": user.affiliation,
"legal_name": user.legal_name,
"department": user.department,
"grade_level": user.grade_level,
"phone": user.phone,
"contact_address": user.contact_address,
"nickname": user.nickname,
"self_introduction": user.self_introduction,
"avatar_url": user.avatar_url,
"consent_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"profile_completed_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"terms_agreed_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"privacy_agreed_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"terms_version": user.terms_version,
"privacy_version": user.privacy_version,
"created_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"last_seen_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"is_active": active,
}
class FakeAcquire:
def __init__(self, conn: Any) -> None:
self.conn = conn
async def __aenter__(self) -> Any:
return self.conn
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
return None
class FakePool:
def __init__(self, conn: Any) -> None:
self.conn = conn
def acquire(self) -> FakeAcquire:
return FakeAcquire(self.conn)
class AuthIdentityAliasTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
auth_routes._oauth_states.clear()
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._auth_identity_alias_index.clear()
auth_sessions._inactive_emails.clear()
async def asyncTearDown(self) -> None:
auth_routes._oauth_states.clear()
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._auth_identity_alias_index.clear()
auth_sessions._inactive_emails.clear()
async def test_explicit_alias_resolves_canonical_user_without_email_merge(self) -> None:
class Conn:
query = ""
args: tuple[Any, ...] = ()
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
self.query = query
self.args = args
return alias_row()
conn = Conn()
with patch.object(auth_sessions, "get_pool", return_value=FakePool(conn)):
user = await auth_sessions.get_managed_user_by_auth_alias(
"google:gmail-subject"
)
self.assertIsNotNone(user)
self.assertEqual(user.user_id, managed_user().user_id)
self.assertEqual(user.email, "yunchan@twentyoz.kr")
self.assertIn("app.auth_identity_alias", conn.query)
self.assertEqual(conn.args, ("google:gmail-subject",))
async def test_alias_to_inactive_or_suspended_user_fails_closed(self) -> None:
class Conn:
def __init__(self, row: dict[str, Any]) -> None:
self.row = row
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
return self.row
for row in (alias_row(active=False), alias_row(status="suspended")):
with (
self.subTest(row=row),
patch.object(auth_sessions, "get_pool", return_value=FakePool(Conn(row))),
):
with self.assertRaises(auth_sessions.InactiveUserError):
await auth_sessions.get_managed_user_by_auth_alias(
"google:gmail-subject"
)
async def test_alias_session_keeps_login_email_and_canonical_entitlements(self) -> None:
class Conn:
query = ""
args: tuple[Any, ...] = ()
async def execute(self, query: str, *args: Any) -> str:
self.query = query
self.args = args
return "INSERT 0 1"
conn = Conn()
canonical = managed_user()
with (
patched_settings(
environment="prod",
auth_super_admin_emails=[canonical.email],
),
patch.object(auth_sessions, "get_pool", return_value=FakePool(conn)),
):
_, session_user = await auth_sessions.create_session(
email="yunchan8804@gmail.com",
display_name="Yun Chan",
role="admin",
external_id="google:gmail-subject",
account_status="approved",
managed_user=canonical,
)
self.assertEqual(session_user.user_id, canonical.user_id)
self.assertEqual(session_user.email, "yunchan8804@gmail.com")
self.assertTrue(session_user.super_admin)
self.assertTrue(session_user.admin_access)
self.assertIn("login_email", conn.query)
self.assertEqual(conn.args[1], canonical.user_id)
self.assertEqual(conn.args[5], "yunchan8804@gmail.com")
async def test_restored_session_uses_login_email_but_canonical_super_admin(self) -> None:
canonical = managed_user()
class Conn:
query = ""
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
self.query = query
return {
"expires_at": datetime.fromtimestamp(
time.time() + 3600, tz=timezone.utc
),
"user_id": canonical.user_id,
"login_email": "yunchan8804@gmail.com",
"canonical_email": canonical.email,
"display_name": canonical.display_name,
"role": "admin",
"admin_access": True,
"learner_feedback_enabled": True,
"account_status": "approved",
"cohort": "owner",
"consent_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
"profile_completed_at": datetime.fromtimestamp(
1.0, tz=timezone.utc
),
}
async def execute(self, query: str, *args: Any) -> str:
return "UPDATE 1"
conn = Conn()
with (
patched_settings(auth_super_admin_emails=[canonical.email]),
patch.object(auth_sessions, "get_pool", return_value=FakePool(conn)),
):
restored = await auth_sessions.get_session("opaque-session")
self.assertIsNotNone(restored)
self.assertEqual(restored.email, "yunchan8804@gmail.com")
self.assertTrue(restored.super_admin)
self.assertIn("s.login_email", conn.query)
self.assertIn("canonical_email", conn.query)
async def test_me_response_uses_canonical_entitlement_not_login_email(self) -> None:
canonical = managed_user()
session_user = auth_sessions.SessionUser(
user_id=canonical.user_id,
email="yunchan8804@gmail.com",
display_name=canonical.display_name,
role="admin",
admin_access=True,
super_admin=True,
account_status="approved",
cohort_ids=canonical.cohort_ids,
consent_at=canonical.consent_at,
profile_completed_at=canonical.profile_completed_at,
expires_at=time.time() + 3600,
)
with (
patched_settings(auth_super_admin_emails=[canonical.email]),
patch.object(
auth_routes,
"get_managed_user",
AsyncMock(return_value=canonical),
),
):
response = await auth_routes._me_response(session_user)
self.assertEqual(response.email, "yunchan8804@gmail.com")
self.assertTrue(response.admin_access)
self.assertTrue(response.super_admin)
self.assertIsNotNone(response.onboarding_completed_at)
async def test_google_callback_resolves_alias_before_email_and_redirects_admin(
self,
) -> None:
state = "gmail-alias-state"
auth_routes._oauth_states[state] = auth_routes.OAuthState(
code_verifier="verifier",
next_path="/learn",
created_at=time.time(),
)
canonical = managed_user()
class FakeResponse:
status_code = 200
def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
def json(self) -> dict[str, Any]:
return self.payload
class FakeAsyncClient:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
async def __aenter__(self) -> "FakeAsyncClient":
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
return None
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
return FakeResponse({"id_token": "id-token"})
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
return FakeResponse(
{
"aud": "google-client",
"iss": "https://accounts.google.com",
"email": "yunchan8804@gmail.com",
"email_verified": True,
"name": "Yun Chan",
"sub": "gmail-subject",
}
)
request = Request(
{
"type": "http",
"method": "GET",
"path": "/auth/callback",
"headers": [(b"host", b"api-vignette.test")],
}
)
create_session = AsyncMock(return_value=("opaque-session", object()))
email_lookup = AsyncMock(side_effect=AssertionError("email lookup must not run"))
with (
patched_settings(
environment="prod",
oauth_google_client_id="google-client",
oauth_google_client_secret="google-secret",
frontend_base_url="https://vignette.test",
auth_super_admin_emails=[canonical.email],
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(
auth_routes,
"get_managed_user_by_auth_alias",
AsyncMock(return_value=canonical),
),
patch.object(auth_routes, "get_managed_user_by_email", email_lookup),
patch.object(auth_routes, "create_session", create_session),
):
response = await auth_routes.callback(
request,
code="auth-code",
state=state,
oauth_state_cookie=state,
)
self.assertEqual(response.headers["location"], "https://vignette.test/admin")
email_lookup.assert_not_awaited()
create_session.assert_awaited_once_with(
email="yunchan8804@gmail.com",
display_name="Yun Chan",
role="admin",
cohort_ids=["owner"],
external_id="google:gmail-subject",
account_status="approved",
managed_user=canonical,
)
def test_migration_makes_alias_runtime_read_only(self) -> None:
migration = (
Path(__file__).resolve().parents[3]
/ "infra"
/ "db"
/ "init"
/ "19_auth_identity_alias.sql"
).read_text(encoding="utf-8")
self.assertIn("CREATE TABLE IF NOT EXISTS app.auth_identity_alias", migration)
self.assertIn("ENABLE ROW LEVEL SECURITY", migration)
self.assertIn("FOR SELECT", migration)
self.assertNotIn("FOR INSERT", migration)
self.assertNotIn("FOR UPDATE", migration)
self.assertNotIn("FOR DELETE", migration)
self.assertIn("ADD COLUMN IF NOT EXISTS login_email", migration)

View file

@ -139,6 +139,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._auth_identity_alias_index.clear()
auth_sessions._inactive_emails.clear()
async def asyncTearDown(self) -> None:
@ -147,6 +148,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._auth_identity_alias_index.clear()
auth_sessions._inactive_emails.clear()
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
@ -1188,6 +1190,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
auth_allowed_email_domains=["hs.ac.kr"],
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(
auth_routes,
"get_managed_user_by_auth_alias",
AsyncMock(return_value=None),
),
patch.object(
auth_routes,
"get_managed_user_by_email",
@ -1272,6 +1279,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"},
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(
auth_routes,
"get_managed_user_by_auth_alias",
AsyncMock(return_value=None),
),
patch.object(
auth_routes,
"get_managed_user_by_email",
@ -1363,6 +1375,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
auth_super_admin_emails=[],
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(
auth_routes,
"get_managed_user_by_auth_alias",
AsyncMock(return_value=None),
),
patch.object(auth_routes, "get_managed_user_by_email", AsyncMock(return_value=managed)),
patch.object(auth_routes, "create_session", create_session_mock),
):
@ -1456,6 +1473,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
session_secret="signed-oauth-state-secret",
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(
auth_routes,
"get_managed_user_by_auth_alias",
AsyncMock(return_value=None),
),
patch.object(
auth_routes,
"get_managed_user_by_email",

View file

@ -305,6 +305,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
"has_persona_triggers": True,
"has_persona_voice_map": True,
"has_auth_session": True,
"has_auth_identity_alias": True,
"has_auth_session_login_email": True,
"has_auth_identity_alias_select_policy": True,
"has_preferences": True,
"has_engine_config": True,
"has_session_columns": True,