Files
sub2api/backend/internal/handler/auth_dingtalk_oauth_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

392 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDingTalkOAuthStart_Disabled は sentinel テスト。
// TODO(task-1.10): newTestAuthHandlerWithDingTalk helper が追加されたら t.Skip を外す。
func TestDingTalkOAuthStart_Disabled(t *testing.T) {
t.Skip("helper newTestAuthHandlerWithDingTalk added in Task 1.10; sentinel only")
}
// TestBuildDingTalkSyntheticEmail_UsesUnionID 验证合成邮箱种子使用 unionID。
func TestBuildDingTalkSyntheticEmail_UsesUnionID(t *testing.T) {
unionID := "union_AbCdEf123"
email := buildDingTalkSyntheticEmail(unionID)
want := "dingtalk-union_abcdef123@dingtalk-connect.invalid"
require.Equal(t, want, email)
// 确保结果都是小写(邮箱大小写不敏感,统一小写)
require.True(t, strings.ToLower(email) == email, "synthetic email should be all lowercase")
// 确保前缀正确
require.True(t, strings.HasPrefix(email, "dingtalk-"), "should have dingtalk- prefix")
// 确保后缀是合成邮箱域名
require.True(t, strings.HasSuffix(email, "@dingtalk-connect.invalid"), "should have reserved domain suffix")
}
// TestBuildDingTalkSyntheticEmail_TrimsSpace 验证 unionID 空白被修剪。
func TestBuildDingTalkSyntheticEmail_TrimsSpace(t *testing.T) {
email := buildDingTalkSyntheticEmail(" UID_XYZ ")
require.Equal(t, "dingtalk-uid_xyz@dingtalk-connect.invalid", email)
}
// TestBuildDingTalkUpstreamClaims_EmptyStaff 验证 staff 为空 struct(跨组织降级路径)时:
// - subject 等于 unionID(与 identityKey.ProviderSubject 一致)
// - corp_user_id 为空字符串(跨组织时拿不到企业 userid)
// - email/username 为空字符串
// B/C: Step 3/4 失败降级时 staff = &DingTalkStaffInfo{}claims 不应有 nil。
func TestBuildDingTalkUpstreamClaims_EmptyStaff(t *testing.T) {
staff := &DingTalkStaffInfo{}
claims := buildDingTalkUpstreamClaims(staff, "UNION_AAA", "CORP_X")
require.Equal(t, "", claims["email"])
require.Equal(t, "", claims["username"])
// 重构后 subject = unionID(与 identityKey.ProviderSubject 保持一致)
require.Equal(t, "UNION_AAA", claims["subject"])
require.Equal(t, "", claims["corp_user_id"]) // 企业 userid 跨组织时为空
require.Equal(t, "UNION_AAA", claims["union_id"])
require.Equal(t, "CORP_X", claims["corp_id"])
}
// TestCheckDingTalkCorpAllowed_CrossOrgPolicy 验证 policy=none 时允许任意 corp。
// D: corp 校验提前后逻辑不变。
func TestCheckDingTalkCorpAllowed_CrossOrgPolicy(t *testing.T) {
cfg := config.DingTalkConnectConfig{CorpRestrictionPolicy: "none"}
assert.True(t, checkDingTalkCorpAllowed(cfg, "dingABC"), "policy=none should allow any corp")
assert.True(t, checkDingTalkCorpAllowed(cfg, ""), "policy=none should allow empty corp")
assert.True(t, checkDingTalkCorpAllowed(cfg, "foreign_corp"), "policy=none should allow foreign corp")
}
// TestCheckDingTalkCorpAllowed_InternalOnly 验证 policy=internal_only 时的 corp 校验语义(方案 A 修订)。
// 钉钉 userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)不返回 corpId 字段,
// 因此 checkDingTalkCorpAllowed 完全不校验 corpID,由 step 3 GetUserIdByUnionId 做真实判定
// (跨企业用户会被钉钉错误码 60011/60121 拒绝,mapDingTalkErrorCode 映射回 corp_rejected)。
func TestCheckDingTalkCorpAllowed_InternalOnly(t *testing.T) {
cfgWithCorpID := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
InternalCorpID: "dingInternal",
}
assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "dingInternal"), "internal_only: matching corpID allowed")
assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "foreign_corp"), "internal_only: corpID 字段不再用于决策,step 3 兜底")
assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, ""), "internal_only: 空 corpID 也通过(钉钉部分授权场景不返回 corpId)")
cfgNoCorpID := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
InternalCorpID: "",
}
assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, "dingAnyNonEmpty"), "internal_only + no InternalCorpID: 非空 corpID 通过")
assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, ""), "internal_only + no InternalCorpID: 空 corpID 也通过")
}
// TestDecideDingTalkStep34Strategy_PolicyNone 验证 policy=none 时
// Step 3/4 失败应降级(shouldFallback=true, isFatal=false)。
func TestDecideDingTalkStep34Strategy_PolicyNone(t *testing.T) {
step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
shouldFallback, isFatal := decideDingTalkStep34Strategy("none", step3Err)
require.True(t, shouldFallback, "policy=none: step3 failure should trigger fallback")
require.False(t, isFatal, "policy=none: step3 failure should NOT be fatal")
}
// TestDecideDingTalkStep34Strategy_PolicyNoneEmpty 验证 policy="" 时行为与 "none" 相同。
func TestDecideDingTalkStep34Strategy_PolicyNoneEmpty(t *testing.T) {
stepErr := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
shouldFallback, isFatal := decideDingTalkStep34Strategy("", stepErr)
require.True(t, shouldFallback, "policy='': step failure should trigger fallback")
require.False(t, isFatal, "policy='': step failure should NOT be fatal")
}
// TestDecideDingTalkStep34Strategy_PolicyInternalOnly 验证 policy=internal_only 时
// Step 3/4 失败应 hard failisFatal=true)。
func TestDecideDingTalkStep34Strategy_PolicyInternalOnly(t *testing.T) {
step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403}
shouldFallback, isFatal := decideDingTalkStep34Strategy("internal_only", step3Err)
require.False(t, shouldFallback, "policy=internal_only: should NOT fallback on step3 error")
require.True(t, isFatal, "policy=internal_only: step3 failure should be fatal")
}
// TestDecideDingTalkStep34Strategy_NoError 验证 stepErr=nil 时两个返回值均为 false。
func TestDecideDingTalkStep34Strategy_NoError(t *testing.T) {
for _, policy := range []string{"none", "internal_only", ""} {
shouldFallback, isFatal := decideDingTalkStep34Strategy(policy, nil)
require.False(t, shouldFallback, "no error should not trigger fallback (policy=%q)", policy)
require.False(t, isFatal, "no error should not be fatal (policy=%q)", policy)
}
}
// TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart 验证 username 为空时
// 退到 email local part@ 之前的部分)。
// E: CompleteDingTalkOAuthRegistration username fallback。
func TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart(t *testing.T) {
tests := []struct {
name string
email string
username string
wantUser string
wantValid bool
}{
{
name: "username empty, normal email → local part",
email: "dingtalk-uid123@dingtalk-connect.invalid",
username: "",
wantUser: "dingtalk-uid123",
wantValid: true,
},
{
name: "username already set → keep original",
email: "user@example.com",
username: "张三",
wantUser: "张三",
wantValid: true,
},
{
name: "username empty, no @ in email → use whole email",
email: "noemail",
username: "",
wantUser: "noemail",
wantValid: true,
},
{
name: "both empty → invalid",
email: "",
username: "",
wantUser: "",
wantValid: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
username := tc.username
email := tc.email
// 模拟 CompleteDingTalkOAuthRegistration 中的 fallback 逻辑
if username == "" {
if at := strings.Index(email, "@"); at > 0 {
username = email[:at]
} else {
username = email
}
}
isValid := email != "" && username != ""
require.Equal(t, tc.wantUser, username, fmt.Sprintf("username for email=%q", tc.email))
require.Equal(t, tc.wantValid, isValid, "validity check")
})
}
}
// TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID 验证重构后 subject = unionID
// 而非 staff.UserID,与 identityKey.ProviderSubject 保持一致。
// §4.2: buildDingTalkUpstreamClaims subject 字段修正。
func TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "user123", Name: "张三", Email: "zhangsan@corp.com"}
claims := buildDingTalkUpstreamClaims(staff, "union456", "dingcorp789")
// 重构后 subject = unionID(全局唯一,与 identityKey.ProviderSubject 一致)
require.Equal(t, "union456", claims["subject"], "subject should equal unionID after refactor")
// 企业 userid 保留为独立字段,供 audit/debug 使用
require.Equal(t, "user123", claims["corp_user_id"], "corp_user_id should be staff.UserID")
// union_id 字段与 subject 相同(冗余保留,便于读取)
require.Equal(t, "union456", claims["union_id"])
require.Equal(t, "dingcorp789", claims["corp_id"])
require.Equal(t, "张三", claims["username"])
require.Equal(t, "zhangsan@corp.com", claims["email"])
}
// TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID 验证跨组织降级时
// corp_user_id 为空字符串(跨组织拿不到企业 userid),subject 仍为 unionID。
func TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID(t *testing.T) {
// 跨组织降级路径:staff = &DingTalkStaffInfo{}(所有字段为零值)
staff := &DingTalkStaffInfo{}
claims := buildDingTalkUpstreamClaims(staff, "union_cross_org", "foreign_corp")
require.Equal(t, "union_cross_org", claims["subject"], "subject should still be unionID for cross-org users")
require.Equal(t, "", claims["corp_user_id"], "corp_user_id should be empty for cross-org fallback")
require.Equal(t, "", claims["email"])
require.Equal(t, "", claims["username"])
}
// TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims 验证首个 dept_id 被存入 claims。
func TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "u1", Name: "张三", Email: "a@b.com", DeptIDs: []int64{42, 99}}
claims := buildDingTalkUpstreamClaims(staff, "uid1", "corpX")
// 只取首个 dept_id
require.Equal(t, int64(42), claims["primary_dept_id"], "primary_dept_id should be the first dept_id")
}
// TestBuildDingTalkUpstreamClaims_NoDeptIDs 验证无部门时 primary_dept_id=0。
func TestBuildDingTalkUpstreamClaims_NoDeptIDs(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "u2", Name: "李四"}
claims := buildDingTalkUpstreamClaims(staff, "uid2", "corpY")
require.Equal(t, int64(0), claims["primary_dept_id"], "primary_dept_id should be 0 when no depts")
}
// TestDingTalkStaffFromClaims_RoundTrip 验证 dingTalkStaffFromClaims 能从 claims 恢复 staff 信息。
func TestDingTalkStaffFromClaims_RoundTrip(t *testing.T) {
staff := &DingTalkStaffInfo{UserID: "u3", Name: "王五", Email: "ww@corp.com", DeptIDs: []int64{55}}
claims := buildDingTalkUpstreamClaims(staff, "uid3", "corpZ")
recovered := dingTalkStaffFromClaims(claims)
require.Equal(t, "王五", recovered.Name)
require.Equal(t, "ww@corp.com", recovered.Email)
require.Equal(t, "u3", recovered.UserID)
require.Equal(t, []int64{55}, recovered.DeptIDs)
}
// TestResolveDingTalkDeptPath_SingleLevel 验证单层部门(parent_id=1)返回部门名。
func TestResolveDingTalkDeptPath_SingleLevel(t *testing.T) {
handler := &AuthHandler{}
callCount := 0
responses := map[string]string{
"42": `{"errcode":0,"result":{"dept_id":42,"name":"研发部","parent_id":1}}`,
"1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
var req struct {
DeptID int64 `json:"dept_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
if resp, ok := responses[fmt.Sprintf("%d", req.DeptID)]; ok {
_, _ = w.Write([]byte(resp))
} else {
_, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`))
}
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
httpClient: server.Client(),
}
cli.appToken = "tok"
cli.appTokenExp = time.Now().Add(time.Hour)
path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42)
require.NoError(t, err)
require.Equal(t, "研发部", path)
require.Equal(t, 2, callCount)
}
// TestSyncDingTalkIdentity_UsesCfgAttrKeys 验证 syncDingTalkIdentity 使用 cfg 中配置的 attr key
// 而不是硬编码值。通过 userAttributeService=nil 使同步路径走 warn 跳过,但在此之前先验证
// syncField 构建逻辑(即 attr key 从 cfg 读取)。
// 间接验证:通过构造定制 cfg,确认不同 attr key 可以正确传入(编译时保证类型正确,运行时不 panic)。
func TestSyncDingTalkIdentity_UsesCfgAttrKeys_NoopWithNilService(t *testing.T) {
handler := &AuthHandler{
userAttributeService: nil, // nil → 触发 warn 跳过,但不 panic
}
cfg := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
SyncCorpEmail: true,
SyncDisplayName: true,
SyncDept: true,
// 自定义 attr key(非默认值)
SyncCorpEmailAttrKey: "custom_email_key",
SyncDisplayNameAttrKey: "custom_name_key",
SyncDeptAttrKey: "custom_dept_key",
}
staff := &DingTalkStaffInfo{
Name: "张三",
Email: "zhangsan@example.com",
}
// 调用不应 panicuserAttributeService 为 nil 时走 warn 跳过路径)
require.NotPanics(t, func() {
handler.syncDingTalkIdentity(context.Background(), cfg, nil, 42, staff, false)
})
}
// TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService 验证 cfg 默认 attr key 为空时
// 使用 fallback 默认值(dingtalk_email / dingtalk_name / dingtalk_department)。
// 此测试主要验证调用路径不 panic;实际 key 赋值默认值的逻辑在 GetDingTalkConnectOAuthConfig 层。
func TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService(t *testing.T) {
handler := &AuthHandler{
userAttributeService: nil,
}
cfg := config.DingTalkConnectConfig{
CorpRestrictionPolicy: "internal_only",
SyncCorpEmail: true,
SyncDisplayName: true,
SyncDept: false,
// 不设置 attr key(等同于 GetDingTalkConnectOAuthConfig 未设置时 fallback 后的默认值已在调用前填充)
SyncCorpEmailAttrKey: "dingtalk_email",
SyncDisplayNameAttrKey: "dingtalk_name",
SyncDeptAttrKey: "dingtalk_department",
}
staff := &DingTalkStaffInfo{
Name: "李四",
Email: "lisi@corp.com",
}
require.NotPanics(t, func() {
handler.syncDingTalkIdentity(context.Background(), cfg, nil, 99, staff, false)
})
}
// TestResolveDingTalkDeptPath_MultiLevel 验证多层部门路径拼接。
func TestResolveDingTalkDeptPath_MultiLevel(t *testing.T) {
handler := &AuthHandler{}
// 模拟:42(AI研发) → parent=10(研发部) → parent=1(根)
responses := map[string]string{
"42": `{"errcode":0,"result":{"dept_id":42,"name":"AI研发","parent_id":10}}`,
"10": `{"errcode":0,"result":{"dept_id":10,"name":"研发部","parent_id":1}}`,
"1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 解析请求 body 拿到 dept_id
var req struct {
DeptID int64 `json:"dept_id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key := fmt.Sprintf("%d", req.DeptID)
w.Header().Set("Content-Type", "application/json")
if resp, ok := responses[key]; ok {
_, _ = w.Write([]byte(resp))
} else {
_, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`))
}
}))
defer server.Close()
cli := &DingTalkClient{
cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"},
httpClient: server.Client(),
}
cli.appToken = "tok"
cli.appTokenExp = time.Now().Add(time.Hour)
path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42)
require.NoError(t, err)
require.Equal(t, "研发部/AI研发", path)
}