feat: add enterprise identity and channel integrations
This commit is contained in:
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
@@ -0,0 +1,238 @@
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user