개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -183,6 +183,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
config = await auth_routes.auth_config(request)
self.assertTrue(config.dev_login_enabled)
self.assertEqual(config.allowed_email_domains, [])
async def test_learner_can_accept_and_withdraw_practice_consent(self) -> None:
with patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")):
@ -328,7 +329,9 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(me.cohort_ids, ["e2e-hanshin"])
async def test_admin_created_external_domain_user_can_login(self) -> None:
async def test_google_identity_accepts_any_verified_domain_and_auto_approves(
self,
) -> None:
with (
patched_settings(
environment="dev",
@ -354,29 +357,57 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
admin_routes.AdminUserCreate(
email="forced.gmail@gmail.com",
display_name="Forced Gmail",
role="teacher",
account_status="approved",
role="learner",
cohort_ids=["manual-cohort"],
),
super_admin,
)
me = await auth_routes.dev_login(
_request(path="/auth/dev-login"),
auth_routes.DevLoginRequest(
email="forced.gmail@gmail.com",
role="learner",
display_name="Provider Name",
self.assertEqual(created.account_status, "pending")
self.assertEqual(
auth_routes.validate_google_identity(
email="unmanaged@gmail.com",
email_verified=True,
),
Response(),
"unmanaged@gmail.com",
)
google_email = auth_routes.validate_google_identity(
email="forced.gmail@gmail.com",
email_verified=True,
)
google_managed = await auth_sessions.get_managed_user_by_email(google_email)
self.assertEqual(google_email, created.email)
self.assertIsNotNone(google_managed)
self.assertEqual(google_managed.user_id, created.user_id)
self.assertEqual(me.user_id, created.user_id)
self.assertEqual(me.email, "forced.gmail@gmail.com")
self.assertEqual(me.role, "teacher")
self.assertEqual(me.account_status, "approved")
self.assertEqual(me.cohort_ids, ["manual-cohort"])
_, google_user = await auth_sessions.create_session(
email="forced.gmail@gmail.com",
display_name="Provider Name",
role="learner",
external_id="google:external-participant",
account_status="approved",
)
self.assertEqual(google_user.user_id, created.user_id)
self.assertEqual(google_user.account_status, "approved")
self.assertEqual(google_user.cohort_ids, ["manual-cohort"])
suspended = await admin_routes.patch_user(
created.user_id,
admin_routes.AdminUserPatch(account_status="suspended"),
super_admin,
)
self.assertEqual(suspended.account_status, "suspended")
_, suspended_login = await auth_sessions.create_session(
email="forced.gmail@gmail.com",
display_name="Provider Name",
role="learner",
external_id="google:external-participant",
account_status="approved",
)
self.assertEqual(suspended_login.account_status, "suspended")
# Google is open to every verified account, while the local dev-login
# fixture remains constrained to its explicit test domains.
with self.assertRaises(auth_routes.HTTPException) as denied:
await auth_routes.dev_login(
_request(path="/auth/dev-login"),
@ -388,6 +419,14 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
Response(),
)
self.assertEqual(denied.exception.status_code, 403)
self.assertEqual(denied.exception.detail, "email domain is not allowed")
with self.assertRaises(auth_routes.HTTPException) as unverified:
auth_routes.validate_google_identity(
email="unmanaged@gmail.com",
email_verified=False,
)
self.assertEqual(unverified.exception.status_code, 403)
async def test_super_admin_email_is_admin_and_auto_approved(self) -> None:
with (
@ -664,6 +703,62 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("$7::boolean", conn.queries[0])
self.assertIn("$9::boolean", conn.queries[1])
async def test_google_oauth_promotes_preregistered_pending_user_in_db(self) -> None:
class RecordingConn:
def __init__(self) -> None:
self.query = ""
self.args: tuple[Any, ...] = ()
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
self.query = query
self.args = args
now = datetime.now(timezone.utc)
return {
"user_id": "00000000-0000-0000-0000-000000000606",
"email": "preregistered@gmail.com",
"display_name": "Preregistered",
"role": "learner",
"admin_access": False,
"account_status": "approved",
"cohort": "pilot",
"affiliation": "",
"created_at": now,
"last_seen_at": now,
}
class RecordingAcquire:
def __init__(self, conn: RecordingConn) -> None:
self.conn = conn
async def __aenter__(self) -> RecordingConn:
return self.conn
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class RecordingPool:
def __init__(self, conn: RecordingConn) -> None:
self.conn = conn
def acquire(self) -> RecordingAcquire:
return RecordingAcquire(self.conn)
conn = RecordingConn()
with patch.object(auth_sessions, "get_pool", return_value=RecordingPool(conn)):
user = await auth_sessions.upsert_managed_user(
auth_sessions.ManagedUserUpsertInput(
email="preregistered@gmail.com",
display_name="Preregistered",
role="learner",
external_id="google:preregistered-sub",
account_status="approved",
)
)
self.assertEqual(user.account_status, "approved")
self.assertIn("WHEN $8::text = 'approved' THEN 'approved'", conn.query)
self.assertEqual(conn.args[7], "approved")
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
request = _request(
[
@ -1040,6 +1135,82 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("oauth=local_oauth_unavailable", location)
self.assertFalse(auth_routes._oauth_states)
async def test_google_callback_accepts_unregistered_external_email(self) -> None:
state = "external-email-state"
auth_routes._oauth_states[state] = auth_routes.OAuthState(
code_verifier="verifier",
next_path="/learn",
created_at=1_800_000_000.0,
)
class FakeResponse:
def __init__(self, payload: dict[str, Any]) -> None:
self.status_code = 200
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: object, exc: object, tb: object) -> 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": "unregistered@gmail.com",
"email_verified": "true",
"name": "Unregistered External",
"sub": "google-external-sub",
}
)
create_session_mock = AsyncMock(return_value=("opaque-session", object()))
with (
patched_settings(
oauth_google_client_id="google-client",
oauth_google_client_secret="google-secret",
frontend_base_url="https://vignette.test",
environment="prod",
auth_allowed_email_domains=["hs.ac.kr"],
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(
auth_routes,
"get_managed_user_by_email",
AsyncMock(return_value=None),
),
patch.object(auth_routes, "create_session", create_session_mock),
):
response = await auth_routes.callback(
_request(),
code="auth-code",
state=state,
oauth_state_cookie=state,
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["location"], "https://vignette.test/learn")
create_session_mock.assert_awaited_once_with(
email="unregistered@gmail.com",
display_name="Unregistered External",
role="learner",
cohort_ids=[],
external_id="google:google-external-sub",
account_status="approved",
)
async def test_google_callback_sets_opaque_cookie_without_browser_tokens(self) -> None:
state = "state-token"
auth_routes._oauth_states[state] = auth_routes.OAuthState(
@ -1099,6 +1270,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_email",
AsyncMock(return_value=None),
),
patch.object(auth_routes, "create_session", create_session_mock),
):
response = await auth_routes.callback(
@ -1116,6 +1292,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
role="learner",
cohort_ids=["pilot-a", "hanshin-2026"],
external_id="google:learner@hs.ac.kr",
account_status="approved",
)
cookie_blob = "\n".join(
value.decode("latin1")
@ -1202,6 +1379,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
role="learner",
cohort_ids=[],
external_id="google:operator@hs.ac.kr",
account_status="approved",
)
self.assertEqual(
auth_routes._post_login_next_path(
@ -1276,6 +1454,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_email",
AsyncMock(return_value=None),
),
patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))),
):
response = await auth_routes.callback(