197 lines
6.5 KiB
Python
197 lines
6.5 KiB
Python
"""SAML 2.0 provider 单元测试 (U4 SSO 集成)。
|
||
|
||
验证:
|
||
- ACS NameID 提取 + JIT 用户创建
|
||
- 按 NameID 关联(R1a: 禁止邮箱合并)
|
||
- 相同 NameID 复用现有用户
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import aiosqlite
|
||
import pytest
|
||
|
||
from agentkit.server.auth.idp_registry import (
|
||
IDPConfig,
|
||
IDPRegistry,
|
||
SAMLConfig,
|
||
)
|
||
from agentkit.server.auth.models import init_auth_db
|
||
from agentkit.server.auth.providers.saml import SAMLProvider
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def saml_idp(monkeypatch: pytest.MonkeyPatch) -> IDPRegistry:
|
||
"""注册一个测试 SAML IDP 并替换全局 registry。"""
|
||
registry = IDPRegistry()
|
||
registry.register(
|
||
IDPConfig(
|
||
name="test_saml",
|
||
type="saml",
|
||
display_name="Test SAML",
|
||
saml=SAMLConfig(
|
||
entity_id="https://idp.test/entity",
|
||
sso_url="https://idp.test/sso",
|
||
idp_cert="-----BEGIN CERTIFICATE-----\nfakecert\n-----END CERTIFICATE-----",
|
||
sp_entity_id="https://app.test/sp",
|
||
acs_url="https://app.test/api/v1/auth/saml/test_saml/acs",
|
||
),
|
||
)
|
||
)
|
||
monkeypatch.setattr("agentkit.server.auth.providers.saml.get_idp_registry", lambda: registry)
|
||
return registry
|
||
|
||
|
||
@pytest.fixture
|
||
async def auth_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||
db_path = tmp_path / "auth.db"
|
||
await init_auth_db(db_path)
|
||
monkeypatch.setenv("AGENTKIT_AUTH_DB", str(db_path))
|
||
return db_path
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_saml_parse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""替换 _process_saml 为假实现 — 返回固定 NameID + attributes。"""
|
||
|
||
def _fake_process(
|
||
self: SAMLProvider, req: dict, cfg: Any, provider_name: str
|
||
) -> tuple[str, dict[str, Any]]:
|
||
return "saml-nameid-456", {"email": "saml_user@example.com", "name": "SAML User"}
|
||
|
||
monkeypatch.setattr(SAMLProvider, "_process_saml", _fake_process)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestHandleAcs:
|
||
async def test_creates_user_by_nameid(
|
||
self,
|
||
saml_idp: IDPRegistry,
|
||
auth_db: Path,
|
||
mock_saml_parse: None,
|
||
) -> None:
|
||
"""ACS 消费 SAMLResponse + NameID 提取 + JIT 用户创建。"""
|
||
user = await SAMLProvider().handle_acs("test_saml", "fake-saml-response")
|
||
assert user["username"] == "SAML User"
|
||
assert user["email"] == "saml_user@example.com"
|
||
|
||
# 验证 user_idp_links 按 NameID 关联
|
||
async with aiosqlite.connect(str(auth_db)) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute(
|
||
"SELECT * FROM user_idp_links WHERE idp_name = ? AND idp_subject = ?",
|
||
("test_saml", "saml-nameid-456"),
|
||
)
|
||
link = await cursor.fetchone()
|
||
assert link is not None
|
||
assert link["idp_type"] == "saml"
|
||
assert link["user_id"] == user["id"]
|
||
|
||
async def test_same_nameid_reuses_user(
|
||
self,
|
||
saml_idp: IDPRegistry,
|
||
auth_db: Path,
|
||
mock_saml_parse: None,
|
||
) -> None:
|
||
"""相同 NameID 第二次登录复用现有用户。"""
|
||
provider = SAMLProvider()
|
||
user1 = await provider.handle_acs("test_saml", "resp1")
|
||
user2 = await provider.handle_acs("test_saml", "resp2")
|
||
assert user1["id"] == user2["id"]
|
||
|
||
async def test_no_email_merge(
|
||
self,
|
||
saml_idp: IDPRegistry,
|
||
auth_db: Path,
|
||
mock_saml_parse: None,
|
||
) -> None:
|
||
"""R1a: 不同 NameID 相同 email — 禁止合并。"""
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
local_id = str(uuid.uuid4())
|
||
async with aiosqlite.connect(str(auth_db)) as db:
|
||
await db.execute(
|
||
"INSERT INTO users (id, username, email, password_hash, role, is_active, "
|
||
"is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(
|
||
local_id,
|
||
"local_saml_user",
|
||
"saml_user@example.com",
|
||
"$2b$12$dummyhash",
|
||
"member",
|
||
1,
|
||
0,
|
||
0,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
await db.commit()
|
||
|
||
# SAML NameID=saml-nameid-456(不同于本地用户)
|
||
saml_user = await SAMLProvider().handle_acs("test_saml", "resp")
|
||
assert saml_user["id"] != local_id
|
||
|
||
async def test_unknown_provider_raises(
|
||
self,
|
||
saml_idp: IDPRegistry,
|
||
auth_db: Path,
|
||
mock_saml_parse: None,
|
||
) -> None:
|
||
with pytest.raises(ValueError, match="未配置"):
|
||
await SAMLProvider().handle_acs("nonexistent", "resp")
|
||
|
||
async def test_missing_nameid_raises(
|
||
self,
|
||
saml_idp: IDPRegistry,
|
||
auth_db: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""SAML assertion 缺少 NameID 应拒绝。"""
|
||
monkeypatch.setattr(
|
||
SAMLProvider,
|
||
"_process_saml",
|
||
lambda self, req, cfg, name: ("", {}),
|
||
)
|
||
with pytest.raises(ValueError, match="NameID"):
|
||
await SAMLProvider().handle_acs("test_saml", "resp")
|
||
|
||
|
||
class TestAuthenticateNotImplemented:
|
||
async def test_raises(self) -> None:
|
||
from agentkit.server.auth.providers.exceptions import ProviderNotImplemented
|
||
|
||
with pytest.raises(ProviderNotImplemented):
|
||
await SAMLProvider().authenticate(username="x", password="y")
|
||
|
||
|
||
class TestRevokeUser:
|
||
async def test_disables_local_user(
|
||
self,
|
||
saml_idp: IDPRegistry,
|
||
auth_db: Path,
|
||
mock_saml_parse: None,
|
||
) -> None:
|
||
provider = SAMLProvider()
|
||
user = await provider.handle_acs("test_saml", "resp")
|
||
await provider.revoke_user(user["id"])
|
||
async with aiosqlite.connect(str(auth_db)) as db:
|
||
db.row_factory = aiosqlite.Row
|
||
cursor = await db.execute("SELECT is_active FROM users WHERE id = ?", (user["id"],))
|
||
row = await cursor.fetchone()
|
||
assert bool(row["is_active"]) is False
|