Files
ai-ticket-platform/backend/apps/accounts/tests/test_enterprise_identity.py

239 lines
7.7 KiB
Python

from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.http import HttpResponseRedirect
from apps.accounts.models import (
EnterpriseGroupMapping,
EnterpriseIdentityProvider,
ExternalIdentity,
UserGroup,
)
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import encrypt_secret
pytestmark = pytest.mark.django_db
def login_admin(client):
call_command("bootstrap_governance")
admin = get_user_model().objects.get(username="admin")
client.force_login(admin)
return admin
def oidc_provider(**overrides):
values = {
"slug": "corporate",
"name": "企业统一身份",
"provider_type": "oidc",
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "ticket-platform",
"credential_encrypted": encrypt_secret("client-secret"),
"scopes": ["openid", "profile", "email"],
"claim_mappings": {},
"configuration": {},
}
values.update(overrides)
return EnterpriseIdentityProvider.objects.create(**values)
def test_provider_management_encrypts_credentials_and_exposes_safe_public_list(client):
login_admin(client)
response = client.post(
"/api/identity/providers",
data={
"slug": "corp-oidc",
"name": "公司 SSO",
"provider_type": "oidc",
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "ticket-platform",
"credential": "top-secret-value",
"scopes": ["openid", "profile", "email"],
},
content_type="application/json",
)
assert response.status_code == 200
payload = response.json()
assert payload["credential_set"] is True
assert "top-secret-value" not in str(payload)
provider = EnterpriseIdentityProvider.objects.get(slug="corp-oidc")
assert provider.credential_encrypted != "top-secret-value"
client.logout()
public_response = client.get("/api/identity/auth/providers")
assert public_response.status_code == 200
assert public_response.json() == [
{
"slug": "corp-oidc",
"name": "公司 SSO",
"button_label": "使用 公司 SSO 登录",
"provider_type": "oidc",
}
]
def test_provider_rejects_plaintext_secrets_inside_configuration(client):
login_admin(client)
response = client.post(
"/api/identity/providers",
data={
"slug": "unsafe",
"name": "不安全配置",
"provider_type": "ldap",
"configuration": {
"server_uri": "ldaps://directory.example.com",
"bind_password": "must-not-be-plain",
},
},
content_type="application/json",
)
assert response.status_code == 400
assert not EnterpriseIdentityProvider.objects.filter(slug="unsafe").exists()
def test_directory_sync_is_idempotent_and_maps_enterprise_groups(client):
login_admin(client)
provider = oidc_provider(
enabled=False,
claim_mappings={
"subject": "employee.id",
"username": "employee.account",
"email": "employee.mail",
"display_name": "employee.name",
"groups": "organization.memberships",
},
)
support = UserGroup.objects.create(name="一线支持", code="support_l1")
EnterpriseGroupMapping.objects.create(
provider=provider,
external_group="IT-Support",
user_group=support,
)
url = f"/api/identity/providers/{provider.id}/directory-sync"
record = {
"subject": "employee-1001",
"username": "zhangsan",
"email": "zhangsan@example.com",
"display_name": "张三",
"groups": ["IT-Support"],
}
first = client.post(
url,
data={"records": [record]},
content_type="application/json",
)
second = client.post(
url,
data={"records": [{**record, "display_name": "张三(支持)"}]},
content_type="application/json",
)
assert first.status_code == 200
assert first.json()["created_count"] == 1
assert second.status_code == 200
assert second.json()["updated_count"] == 1
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
assert identity.user.first_name == "张三(支持)"
assert identity.user.business_groups.filter(id=support.id).exists()
assert not identity.user.has_usable_password()
def test_oidc_callback_logs_in_user_and_reuses_external_identity(client):
provider = oidc_provider()
support = UserGroup.objects.create(name="企业支持组", code="enterprise_support")
EnterpriseGroupMapping.objects.create(
provider=provider,
external_group="support",
user_group=support,
)
class FakeOIDCClient:
def authorize_access_token(self, request):
return {
"userinfo": {
"sub": "stable-subject",
"preferred_username": "corp-user",
"email": "corp-user@example.com",
"name": "企业用户",
"groups": ["support"],
}
}
session = client.session
session[f"identity_return_to:{provider.slug}"] = "/tickets"
session.save()
callback = f"/api/identity/auth/oidc/{provider.slug}/callback"
with patch(
"apps.accounts.identity_api.build_oidc_client",
return_value=FakeOIDCClient(),
):
first = client.get(callback)
client.logout()
second = client.get(callback)
assert first.status_code == 302
assert first.url.endswith("/#/tickets")
assert second.status_code == 302
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
assert identity.user.business_groups.filter(id=support.id).exists()
assert OperationAuditLog.objects.filter(
action="oidc_login",
target_id=str(identity.id),
).count() == 2
def test_oidc_start_uses_server_side_client_and_callback_failure_is_safe(client):
provider = oidc_provider()
class FakeOIDCClient:
def authorize_redirect(self, request, redirect_uri):
assert redirect_uri.endswith(f"/api/identity/auth/oidc/{provider.slug}/callback")
return HttpResponseRedirect("https://idp.example.com/authorize?state=managed")
def authorize_access_token(self, request):
return {"access_token": "must-never-be-audited"}
with patch(
"apps.accounts.identity_api.build_oidc_client",
return_value=FakeOIDCClient(),
):
start = client.get(
f"/api/identity/auth/oidc/{provider.slug}/start",
{"return_to": "//evil.example.com"},
)
callback = client.get(f"/api/identity/auth/oidc/{provider.slug}/callback")
assert start.status_code == 302
assert start.url.startswith("https://idp.example.com/authorize")
assert callback.status_code == 302
assert "auth_error=" in callback.url
audit = OperationAuditLog.objects.get(action="oidc_callback_failed")
assert "must-never-be-audited" not in str(audit.request_snapshot)
def test_identity_governance_requires_permission(client):
provider = oidc_provider(enabled=False)
assert client.get("/api/identity/providers").status_code == 401
assert (
client.post(
f"/api/identity/providers/{provider.id}/directory-sync",
data={"records": []},
content_type="application/json",
).status_code
== 401
)