package identity import ( "context" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "aigateway.local/core/internal/platform/cryptox" ) // hostRouter 把固定平台域名路由到本地的 httptest 服务,验证三个平台的 // code 换取身份协议(端点、请求体、响应字段)。 type hostRouter struct { targets map[string]string inner *http.Transport } func (r hostRouter) RoundTrip(request *http.Request) (*http.Response, error) { base, ok := r.targets[request.URL.Host] if !ok { return nil, errors.New("unexpected host " + request.URL.Host) } target, err := url.Parse(base) if err != nil { return nil, err } clone := request.Clone(request.Context()) clone.URL.Scheme = target.Scheme clone.URL.Host = target.Host return r.inner.RoundTrip(clone) } func testSocialService(t *testing.T, targets map[string]string) *Service { t.Helper() key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) cipher, err := cryptox.NewAESGCM(key, 1, "test") if err != nil { t.Fatal(err) } return &Service{ idpCipher: cipher, oidcClient: &http.Client{Transport: hostRouter{targets: targets, inner: http.DefaultTransport.(*http.Transport).Clone()}}, } } func socialProviderWithSecret(t *testing.T, service *Service, kind, clientID string, secret string) SocialProvider { t.Helper() raw, _ := json.Marshal(socialCredentials{Secret: secret}) encrypted, version, err := service.idpCipher.Encrypt(raw) if err != nil { t.Fatal(err) } return SocialProvider{Kind: kind, ClientID: clientID, EncryptedCredentials: encrypted, CredentialKEKVersion: version} } func TestExchangeWeCom(t *testing.T) { tokenCalls := 0 userCalls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/cgi-bin/gettoken"): tokenCalls++ if r.URL.Query().Get("corpid") != "corp-1" || r.URL.Query().Get("corpsecret") != "s3cret" { t.Errorf("gettoken query = %v", r.URL.RawQuery) } _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "access_token": "token-1"}) case strings.HasPrefix(r.URL.Path, "/cgi-bin/auth/getuserinfo"): userCalls++ if r.URL.Query().Get("code") != "code-x" { t.Errorf("getuserinfo code = %q", r.URL.Query().Get("code")) } _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "userid": "zhangsan", "openid": "open-1"}) default: t.Errorf("unexpected wecom path %s", r.URL.Path) } })) defer server.Close() service := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server.URL}) identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "wecom", "corp-1", "s3cret"), "code-x") if err != nil { t.Fatalf("wecom exchange failed: %v", err) } if identity.UID != "zhangsan" { t.Errorf("uid = %q, want zhangsan", identity.UID) } if tokenCalls != 1 || userCalls != 1 { t.Errorf("calls token=%d user=%d, want 1/1", tokenCalls, userCalls) } // 企业外成员只有 openid 时回退 openid。 server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/cgi-bin/gettoken") { _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "access_token": "token-2"}) return } _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "userid": "", "openid": "open-2"}) })) defer server2.Close() service2 := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server2.URL}) identity2, err := service2.exchangeSocial(context.Background(), socialProviderWithSecret(t, service2, "wecom", "corp-1", "s3cret"), "code-x") if err != nil { t.Fatalf("wecom openid fallback failed: %v", err) } if identity2.UID != "open-2" { t.Errorf("uid = %q, want open-2", identity2.UID) } } func TestExchangeDingTalk(t *testing.T) { var tokenBody map[string]string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/v1.0/oauth2/userAccessToken"): if err := json.NewDecoder(r.Body).Decode(&tokenBody); err != nil { t.Errorf("decode token body: %v", err) } _ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "dt-token"}) case strings.HasPrefix(r.URL.Path, "/v1.0/contact/users/me"): if r.Header.Get("x-acs-dingtalk-access-token") != "dt-token" { t.Errorf("missing x-acs-dingtalk-access-token header") } _ = json.NewEncoder(w).Encode(map[string]any{"unionId": "union-9", "openId": "open-9", "nick": "张三"}) default: t.Errorf("unexpected dingtalk path %s", r.URL.Path) } })) defer server.Close() service := testSocialService(t, map[string]string{"api.dingtalk.com": server.URL}) identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "dingtalk", "app-key", "app-secret"), "code-d") if err != nil { t.Fatalf("dingtalk exchange failed: %v", err) } if identity.UID != "union-9" || identity.Name != "张三" { t.Errorf("uid=%q name=%q", identity.UID, identity.Name) } if tokenBody["grantType"] != "authorization_code" || tokenBody["clientId"] != "app-key" { t.Errorf("token body = %v", tokenBody) } } func TestExchangeFeishu(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/open-apis/authen/v1/oidc/access_token"): var body map[string]string if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Errorf("decode body: %v", err) } if body["grant_type"] != "authorization_code" || body["app_id"] != "app-1" { t.Errorf("token body = %v", body) } _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"access_token": "fs-token"}}) case strings.HasPrefix(r.URL.Path, "/open-apis/authen/v1/user_info"): if r.Header.Get("Authorization") != "Bearer fs-token" { t.Errorf("missing bearer token") } _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"name": "李四", "open_id": "ou_1", "union_id": "on_1"}}) default: t.Errorf("unexpected feishu path %s", r.URL.Path) } })) defer server.Close() service := testSocialService(t, map[string]string{"open.feishu.cn": server.URL}) identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "feishu", "app-1", "app-secret"), "code-f") if err != nil { t.Fatalf("feishu exchange failed: %v", err) } if identity.UID != "on_1" || identity.Name != "李四" { t.Errorf("uid=%q name=%q", identity.UID, identity.Name) } } func TestExchangeSocialRejectsPlatformErrors(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40013, "errmsg": "invalid corpsecret"}) })) defer server.Close() service := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server.URL}) if _, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "wecom", "corp-1", "bad"), "code-x"); err == nil { t.Fatal("expected error for platform errcode != 0") } } func TestSocialKindSupported(t *testing.T) { for _, kind := range []string{"wecom", "dingtalk", "feishu", "WECOM", " DingTalk "} { if !socialKindSupported(kind) { t.Errorf("kind %q should be supported (case/space normalized)", kind) } } for _, kind := range []string{"oidc", "saml", "", "weixin"} { if socialKindSupported(kind) { t.Errorf("kind %q should not be supported", kind) } } }