sub2api/backend/ent/schema/auth_identity_schema_test.go
DaydreamCoding b19da9c7fe feat(dingtalk): 钉钉 OAuth 登录接入与 internal_only 用户属性同步
⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。

钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
  GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
  加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
  等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值

internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
  SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
  dingtalk_department);非 internal_only policy 在写入层与加载层均
  coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
  之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
  (钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
  新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
  createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
  AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义

附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
  覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
  渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
  (Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
  退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
  用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults

前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
  视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
  注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
  (拉取 user attribute definitions),展示 fieldEmail /
  qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:27:47 +08:00

169 lines
4.4 KiB
Go

package schema
import (
"testing"
"entgo.io/ent"
"entgo.io/ent/entc/load"
"entgo.io/ent/schema/field"
"github.com/stretchr/testify/require"
)
func TestAuthIdentityFoundationSchemas(t *testing.T) {
spec, err := (&load.Config{Path: "."}).Load()
require.NoError(t, err)
schemas := map[string]*load.Schema{}
for _, schema := range spec.Schemas {
schemas[schema.Name] = schema
}
authIdentity := requireSchema(t, schemas, "AuthIdentity")
requireSchemaFields(t, authIdentity,
"user_id",
"provider_type",
"provider_key",
"provider_subject",
"verified_at",
"issuer",
"metadata",
)
requireHasUniqueIndex(t, authIdentity, "provider_type", "provider_key", "provider_subject")
authIdentityChannel := requireSchema(t, schemas, "AuthIdentityChannel")
requireSchemaFields(t, authIdentityChannel,
"identity_id",
"provider_type",
"provider_key",
"channel",
"channel_app_id",
"channel_subject",
"metadata",
)
requireHasUniqueIndex(t, authIdentityChannel, "provider_type", "provider_key", "channel", "channel_app_id", "channel_subject")
pendingAuthSession := requireSchema(t, schemas, "PendingAuthSession")
requireSchemaFields(t, pendingAuthSession,
"intent",
"provider_type",
"provider_key",
"provider_subject",
"target_user_id",
"redirect_to",
"resolved_email",
"registration_password_hash",
"upstream_identity_claims",
"local_flow_state",
"browser_session_key",
"completion_code_hash",
"completion_code_expires_at",
"email_verified_at",
"password_verified_at",
"totp_verified_at",
"expires_at",
"consumed_at",
)
adoptionDecision := requireSchema(t, schemas, "IdentityAdoptionDecision")
requireSchemaFields(t, adoptionDecision,
"pending_auth_session_id",
"identity_id",
"adopt_display_name",
"adopt_avatar",
"decided_at",
)
requireHasUniqueIndex(t, adoptionDecision, "pending_auth_session_id")
userSchema := requireSchema(t, schemas, "User")
requireSchemaFields(t, userSchema, "signup_source", "last_login_at", "last_active_at")
signupSource := requireSchemaField(t, userSchema, "signup_source")
require.Equal(t, field.TypeString, signupSource.Info.Type)
require.True(t, signupSource.Default)
require.Equal(t, "email", signupSource.DefaultValue)
require.Equal(t, 1, signupSource.Validators)
validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source")
for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk"} {
require.NoError(t, validator(value))
}
require.Error(t, validator("unknown"))
}
func requireSchema(t *testing.T, schemas map[string]*load.Schema, name string) *load.Schema {
t.Helper()
schema, ok := schemas[name]
require.True(t, ok, "schema %s should exist", name)
return schema
}
func requireSchemaFields(t *testing.T, schema *load.Schema, names ...string) {
t.Helper()
fields := map[string]struct{}{}
for _, field := range schema.Fields {
fields[field.Name] = struct{}{}
}
for _, name := range names {
_, ok := fields[name]
require.True(t, ok, "schema %s should include field %s", schema.Name, name)
}
}
func requireSchemaField(t *testing.T, schema *load.Schema, name string) *load.Field {
t.Helper()
for _, schemaField := range schema.Fields {
if schemaField.Name == name {
return schemaField
}
}
require.Failf(t, "missing schema field", "schema %s should include field %s", schema.Name, name)
return nil
}
func requireStringFieldValidator(t *testing.T, fields []ent.Field, name string) func(string) error {
t.Helper()
for _, entField := range fields {
descriptor := entField.Descriptor()
if descriptor.Name != name {
continue
}
require.NotEmpty(t, descriptor.Validators, "field %s should include a validator", name)
validator, ok := descriptor.Validators[0].(func(string) error)
require.True(t, ok, "field %s validator should be func(string) error", name)
return validator
}
require.Failf(t, "missing field validator", "schema should include field %s", name)
return nil
}
func requireHasUniqueIndex(t *testing.T, schema *load.Schema, fields ...string) {
t.Helper()
for _, index := range schema.Indexes {
if !index.Unique {
continue
}
if len(index.Fields) != len(fields) {
continue
}
match := true
for i := range fields {
if index.Fields[i] != fields[i] {
match = false
break
}
}
if match {
return
}
}
require.Failf(t, "missing unique index", "schema %s should include unique index on %v", schema.Name, fields)
}