diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go new file mode 100644 index 00000000..ca076908 --- /dev/null +++ b/internal/auth/cli_auth_check_test.go @@ -0,0 +1,840 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +// setupMCPConfigDir creates a temp config dir with mcp_url pointing to the +// given test server and sets DWS_CONFIG_DIR via t.Setenv. +// NOTE: tests calling this must NOT use t.Parallel(). +func setupMCPConfigDir(t *testing.T, srvURL string) string { + t.Helper() + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "mcp_url"), []byte(srvURL), 0o600) + t.Setenv("DWS_CONFIG_DIR", dir) + return dir +} + +// resetClientIDFromMCP clears the MCP-sourced flag (test helper). +func resetClientIDFromMCP() { + clientMu.Lock() + defer clientMu.Unlock() + clientIDFromMCP = false +} + +// --------------------------------------------------------------------------- +// 1. CheckCLIAuthEnabled: interface error → fail-closed with retry +// --------------------------------------------------------------------------- + +func TestCheckCLIAuthEnabled_ServerError_FailClosed(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + _, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token") + + if err == nil { + t.Fatal("expected error from CheckCLIAuthEnabled when server returns 500, got nil") + } + if !strings.Contains(err.Error(), "failed after 3 attempts") { + t.Fatalf("error should mention retry exhaustion, got: %s", err) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 retry attempts, got %d", c) + } + t.Logf("✅ Server 500 → fail-closed: error=%q, attempts=%d", err, calls.Load()) +} + +func TestCheckCLIAuthEnabled_ConnectionRefused_FailClosed(t *testing.T) { + configDir := setupMCPConfigDir(t, "http://127.0.0.1:1") + p := &OAuthProvider{ + configDir: configDir, + httpClient: &http.Client{Timeout: 2 * time.Second}, + } + + _, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token") + + if err == nil { + t.Fatal("expected error when connection is refused, got nil") + } + if !strings.Contains(err.Error(), "failed after 3 attempts") { + t.Fatalf("error should mention retry exhaustion, got: %s", err) + } + t.Logf("✅ Connection refused → fail-closed: error=%q", err) +} + +func TestCheckCLIAuthEnabled_MalformedJSON_FailClosed(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{this is not valid json}`) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + _, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token") + + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 retry attempts, got %d", c) + } + t.Logf("✅ Malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load()) +} + +func TestCheckCLIAuthEnabled_Timeout_FailClosed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{ + configDir: configDir, + httpClient: &http.Client{Timeout: 200 * time.Millisecond}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := p.CheckCLIAuthEnabled(ctx, "fake-token") + + if err == nil { + t.Fatal("expected error on request timeout, got nil") + } + t.Logf("✅ Timeout → fail-closed: error=%q", err) +} + +// --------------------------------------------------------------------------- +// 2. CheckCLIAuthEnabled: transient error then recovery → succeeds +// --------------------------------------------------------------------------- + +func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n <= 2 { + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: true}, + }) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token") + + if err != nil { + t.Fatalf("expected success after transient failures, got error: %v", err) + } + if !status.Success || !status.Result.CLIAuthEnabled { + t.Fatalf("expected CLIAuthEnabled=true, got %+v", status) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c) + } + t.Logf("✅ Transient error then success: attempts=%d, enabled=%v", calls.Load(), status.Result.CLIAuthEnabled) +} + +// --------------------------------------------------------------------------- +// 3. CheckCLIAuthEnabled: normal responses (pass-through) +// --------------------------------------------------------------------------- + +func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-user-access-token") != "good-token" { + t.Errorf("missing access token header") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: true}, + }) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + status, err := p.CheckCLIAuthEnabled(context.Background(), "good-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !status.Result.CLIAuthEnabled { + t.Fatal("expected CLIAuthEnabled=true") + } + t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) +} + +func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: false}, + }) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.Result.CLIAuthEnabled { + t.Fatal("expected CLIAuthEnabled=false") + } + t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) +} + +// --------------------------------------------------------------------------- +// 4. OAuth callback: simulates the fail-closed logic at the /callback level +// --------------------------------------------------------------------------- + +func TestOAuthCallback_CLIAuthError_ShowsNotEnabledPage(t *testing.T) { + t.Parallel() + + var statusErr error = fmt.Errorf("simulated network error") + var authStatus *CLIAuthStatus + _ = authStatus + + // This is the exact expression used in oauth_provider.go callback: + // cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + cliAuthEnabled := statusErr == nil // false + + if cliAuthEnabled { + t.Fatal("cliAuthEnabled should be false when statusErr != nil") + } + t.Logf("✅ OAuth callback: statusErr=%v → cliAuthEnabled=%v → shows notEnabledHTML (fail-closed)", statusErr, cliAuthEnabled) +} + +func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) { + t.Parallel() + + var statusErr error + authStatus := &CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: true}, + } + + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + if !cliAuthEnabled { + t.Fatal("cliAuthEnabled should be true when API returns enabled") + } + t.Logf("✅ OAuth callback: statusErr=nil, enabled=true → cliAuthEnabled=%v → shows successHTML", cliAuthEnabled) +} + +func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T) { + t.Parallel() + + var statusErr error + authStatus := &CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: false}, + } + + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + if cliAuthEnabled { + t.Fatal("cliAuthEnabled should be false when server says disabled") + } + t.Logf("✅ OAuth callback: statusErr=nil, enabled=false → cliAuthEnabled=%v → shows notEnabledHTML", cliAuthEnabled) +} + +// --------------------------------------------------------------------------- +// 5. Device Flow: loginOnce with broken cliAuthEnabled endpoint +// --------------------------------------------------------------------------- + +func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) { + SetClientIDFromMCP("test-client-id") + t.Cleanup(func() { + SetClientID("") + resetClientIDFromMCP() + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, DeviceCodePath): + writeServiceResult(w, true, DeviceAuthResponse{ + DeviceCode: "dc-test", + UserCode: "TEST-CODE", + VerificationURI: "https://example.com/verify", + ExpiresIn: 60, + Interval: 1, + }, "", "") + + case strings.HasSuffix(r.URL.Path, DeviceTokenPath): + writeServiceResult(w, true, DeviceTokenResponse{ + AuthCode: "test-auth-code", + }, "", "") + + case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath): + json.NewEncoder(w).Encode(map[string]any{ + "accessToken": "test-access-token", + "refreshToken": "test-refresh-token", + "expiresIn": 7200, + "corpId": "corp123", + }) + + case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + + default: + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + } + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + provider := &DeviceFlowProvider{ + configDir: configDir, + clientID: "test-client-id", + scope: DefaultScopes, + baseURL: srv.URL, + logger: newDeviceFlowTestLogger(), + Output: io.Discard, + httpClient: srv.Client(), + } + + _, err := provider.loginOnce(context.Background(), 1) + + if err == nil { + t.Fatal("expected loginOnce to fail when CLI auth check fails, got nil") + } + if !strings.Contains(err.Error(), "检查 CLI 授权状态失败") && !strings.Contains(err.Error(), "Failed to check CLI auth status") { + t.Fatalf("unexpected error message: %s", err) + } + t.Logf("✅ Device Flow: CLI auth check error → login blocked: %s", err) +} + +func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) { + SetClientIDFromMCP("test-client-id") + t.Cleanup(func() { + SetClientID("") + resetClientIDFromMCP() + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, DeviceCodePath): + writeServiceResult(w, true, DeviceAuthResponse{ + DeviceCode: "dc-test", + UserCode: "TEST-CODE", + VerificationURI: "https://example.com/verify", + ExpiresIn: 60, + Interval: 1, + }, "", "") + + case strings.HasSuffix(r.URL.Path, DeviceTokenPath): + writeServiceResult(w, true, DeviceTokenResponse{ + AuthCode: "test-auth-code", + }, "", "") + + case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath): + json.NewEncoder(w).Encode(map[string]any{ + "accessToken": "test-access-token", + "refreshToken": "test-refresh-token", + "expiresIn": 7200, + "corpId": "corp123", + }) + + case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: false}, + }) + + case strings.HasSuffix(r.URL.Path, SuperAdminPath): + json.NewEncoder(w).Encode(SuperAdminResponse{ + Success: true, + Result: []SuperAdmin{{StaffID: "admin1", Name: "张三"}}, + }) + + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + provider := &DeviceFlowProvider{ + configDir: configDir, + clientID: "test-client-id", + scope: DefaultScopes, + baseURL: srv.URL, + logger: newDeviceFlowTestLogger(), + Output: io.Discard, + httpClient: srv.Client(), + } + + _, err := provider.loginOnce(context.Background(), 1) + + if err == nil { + t.Fatal("expected loginOnce to fail when CLI auth is disabled, got nil") + } + t.Logf("✅ Device Flow: CLI auth disabled by server → login blocked: %s", err) +} + +func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) { + SetClientIDFromMCP("test-client-id") + t.Cleanup(func() { + SetClientID("") + resetClientIDFromMCP() + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, DeviceCodePath): + writeServiceResult(w, true, DeviceAuthResponse{ + DeviceCode: "dc-test", + UserCode: "TEST-CODE", + VerificationURI: "https://example.com/verify", + ExpiresIn: 60, + Interval: 1, + }, "", "") + + case strings.HasSuffix(r.URL.Path, DeviceTokenPath): + writeServiceResult(w, true, DeviceTokenResponse{ + AuthCode: "test-auth-code", + }, "", "") + + case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath): + json.NewEncoder(w).Encode(map[string]any{ + "accessToken": "test-access-token", + "refreshToken": "test-refresh-token", + "expiresIn": 7200, + "corpId": "corp123", + }) + + case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + }{CLIAuthEnabled: true}, + }) + + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + provider := &DeviceFlowProvider{ + configDir: configDir, + clientID: "test-client-id", + scope: DefaultScopes, + baseURL: srv.URL, + logger: newDeviceFlowTestLogger(), + Output: io.Discard, + httpClient: srv.Client(), + } + + token, err := provider.loginOnce(context.Background(), 1) + + if err != nil { + t.Fatalf("expected loginOnce to succeed, got error: %v", err) + } + if token.AccessToken != "test-access-token" { + t.Fatalf("unexpected token: %s", token.AccessToken) + } + t.Logf("✅ Device Flow: CLI auth enabled → login succeeded, token=%s", token.AccessToken) +} + +// --------------------------------------------------------------------------- +// 6. FetchClientIDFromMCP: /cli/clientId error handling +// --------------------------------------------------------------------------- + +func TestFetchClientIDFromMCP_ServerError_FailClosed(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + _, err := FetchClientIDFromMCP(context.Background()) + + if err == nil { + t.Fatal("expected error when /cli/clientId returns 500, got nil") + } + if !strings.Contains(err.Error(), "failed after 3 attempts") { + t.Fatalf("error should mention retry exhaustion, got: %s", err) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 retry attempts, got %d", c) + } + t.Logf("✅ /cli/clientId 500 → fail-closed with retry: error=%q, attempts=%d", err, calls.Load()) +} + +func TestFetchClientIDFromMCP_ConnectionRefused_FailClosed(t *testing.T) { + setupMCPConfigDir(t, "http://127.0.0.1:1") + + _, err := FetchClientIDFromMCP(context.Background()) + + if err == nil { + t.Fatal("expected error when connection is refused, got nil") + } + if !strings.Contains(err.Error(), "failed after 3 attempts") { + t.Fatalf("error should mention retry exhaustion, got: %s", err) + } + t.Logf("✅ /cli/clientId connection refused → fail-closed: error=%q", err) +} + +func TestFetchClientIDFromMCP_MalformedJSON_FailClosed(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `not json at all`) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + _, err := FetchClientIDFromMCP(context.Background()) + + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 retry attempts, got %d", c) + } + t.Logf("✅ /cli/clientId malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load()) +} + +func TestFetchClientIDFromMCP_BusinessError_FailClosed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ClientIDResponse{ + Success: false, + ErrorCode: "forbidden", + ErrorMsg: "access denied", + }) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + _, err := FetchClientIDFromMCP(context.Background()) + + if err == nil { + t.Fatal("expected error when server returns success=false, got nil") + } + if !strings.Contains(err.Error(), "access denied") { + t.Fatalf("error should contain server error message, got: %s", err) + } + t.Logf("✅ /cli/clientId business error → fail-closed: error=%q", err) +} + +func TestFetchClientIDFromMCP_TransientThenSuccess(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n <= 2 { + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ClientIDResponse{ + Success: true, + Result: "recovered-client-id", + }) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + id, err := FetchClientIDFromMCP(context.Background()) + + if err != nil { + t.Fatalf("expected success after transient failures, got error: %v", err) + } + if id != "recovered-client-id" { + t.Fatalf("expected client ID 'recovered-client-id', got %q", id) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c) + } + t.Logf("✅ /cli/clientId transient then success: attempts=%d, id=%s", calls.Load(), id) +} + +func TestFetchClientIDFromMCP_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != ClientIDPath { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ClientIDResponse{ + Success: true, + Result: "my-client-id-123", + }) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + id, err := FetchClientIDFromMCP(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != "my-client-id-123" { + t.Fatalf("expected 'my-client-id-123', got %q", id) + } + t.Logf("✅ /cli/clientId normal success: id=%s", id) +} + +// --------------------------------------------------------------------------- +// 7. GetSuperAdmins: /cli/superAdmin error handling +// --------------------------------------------------------------------------- + +func TestGetSuperAdmins_ServerError_RetriesAndFails(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + _, err := GetSuperAdmins(context.Background(), "fake-token") + + if err == nil { + t.Fatal("expected error when /cli/superAdmin returns 500, got nil") + } + if !strings.Contains(err.Error(), "failed after 3 attempts") { + t.Fatalf("error should mention retry exhaustion, got: %s", err) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 retry attempts, got %d", c) + } + t.Logf("✅ /cli/superAdmin 500 → retried 3 times: error=%q", err) +} + +func TestGetSuperAdmins_TransientThenSuccess(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n <= 2 { + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SuperAdminResponse{ + Success: true, + Result: []SuperAdmin{{StaffID: "a1", Name: "张三"}, {StaffID: "a2", Name: "李四"}}, + }) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + result, err := GetSuperAdmins(context.Background(), "fake-token") + + if err != nil { + t.Fatalf("expected success after transient failures, got error: %v", err) + } + if !result.Success || len(result.Result) != 2 { + t.Fatalf("unexpected result: %+v", result) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 attempts, got %d", c) + } + t.Logf("✅ /cli/superAdmin transient then success: attempts=%d, admins=%v", calls.Load(), result.Result) +} + +func TestGetSuperAdmins_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-user-access-token") != "good-token" { + t.Errorf("missing access token header") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SuperAdminResponse{ + Success: true, + Result: []SuperAdmin{{StaffID: "admin1", Name: "王五"}}, + }) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + result, err := GetSuperAdmins(context.Background(), "good-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Result) != 1 || result.Result[0].Name != "王五" { + t.Fatalf("unexpected result: %+v", result) + } + t.Logf("✅ /cli/superAdmin normal success: admins=%v", result.Result) +} + +// --------------------------------------------------------------------------- +// 8. SendCliAuthApply: /cli/sendCliAuthApply error handling +// --------------------------------------------------------------------------- + +func TestSendCliAuthApply_ServerError_RetriesAndFails(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + _, err := SendCliAuthApply(context.Background(), "fake-token", "admin1") + + if err == nil { + t.Fatal("expected error when /cli/sendCliAuthApply returns 500, got nil") + } + if !strings.Contains(err.Error(), "failed after 3 attempts") { + t.Fatalf("error should mention retry exhaustion, got: %s", err) + } + if c := calls.Load(); c != 3 { + t.Fatalf("expected 3 retry attempts, got %d", c) + } + t.Logf("✅ /cli/sendCliAuthApply 500 → retried 3 times: error=%q", err) +} + +func TestSendCliAuthApply_TransientThenSuccess(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n <= 1 { + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true}) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + result, err := SendCliAuthApply(context.Background(), "fake-token", "admin1") + + if err != nil { + t.Fatalf("expected success after transient failure, got error: %v", err) + } + if !result.Success || !result.Result { + t.Fatalf("unexpected result: %+v", result) + } + if c := calls.Load(); c != 2 { + t.Fatalf("expected 2 attempts, got %d", c) + } + t.Logf("✅ /cli/sendCliAuthApply transient then success: attempts=%d", calls.Load()) +} + +func TestSendCliAuthApply_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-user-access-token") != "good-token" { + t.Errorf("missing access token header") + } + if !strings.Contains(r.URL.RawQuery, "adminStaffId=admin123") { + t.Errorf("missing or wrong adminStaffId param: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true}) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + result, err := SendCliAuthApply(context.Background(), "good-token", "admin123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success || !result.Result { + t.Fatalf("unexpected result: %+v", result) + } + t.Logf("✅ /cli/sendCliAuthApply normal success: result=%+v", result) +} + +func TestSendCliAuthApply_BusinessError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SendApplyResponse{ + Success: false, + ErrorCode: "invalid_admin", + ErrorMsg: "admin not found", + Result: false, + }) + })) + defer srv.Close() + + setupMCPConfigDir(t, srv.URL) + + result, err := SendCliAuthApply(context.Background(), "fake-token", "nonexistent") + if err != nil { + t.Fatalf("unexpected transport error: %v", err) + } + if result.Success { + t.Fatal("expected success=false for business error") + } + t.Logf("✅ /cli/sendCliAuthApply business error: errorCode=%s, errorMsg=%s", result.ErrorCode, result.ErrorMsg) +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index ffaf82ad..8b7a0d6c 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -167,14 +167,15 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err) } - // Check if CLI auth is enabled for this organization + // Check if CLI auth is enabled for this organization (fail-closed: block on error) dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0) authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) if authErr != nil { - if p.logger != nil { - p.logger.Warn("failed to check CLI auth status", "error", authErr) - } - // Continue anyway - fail open for better UX + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr) } else if authStatus.Success && !authStatus.Result.CLIAuthEnabled { // CLI auth is disabled - show detailed error with admin info _, _ = fmt.Fprintln(p.output(), "") diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index ca8344b5..0a2fceab 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -951,8 +951,34 @@ type SendApplyResponse struct { Result bool `json:"result"` } +// mcpRequestMaxRetries is the maximum number of attempts for MCP API calls +// (e.g. /cli/cliAuthEnabled, /cli/clientId, /cli/superAdmin, /cli/sendCliAuthApply) +// to tolerate transient network errors before propagating the failure. +const mcpRequestMaxRetries = 3 + // CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp. +// It retries up to mcpRequestMaxRetries times on transient errors to avoid +// false negatives caused by momentary network issues. func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + status, err := p.doCheckCLIAuthEnabled(ctx, accessToken) + if err == nil { + return status, nil + } + lastErr = err + } + return nil, fmt.Errorf("check CLI auth status failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { url := GetMCPBaseURL() + CLIAuthEnabledPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -983,7 +1009,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str } // GetSuperAdmins fetches the list of corp super admins. +// It retries up to mcpRequestMaxRetries times on transient errors. func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + result, err := doGetSuperAdmins(ctx, accessToken) + if err == nil { + return result, nil + } + lastErr = err + } + return nil, fmt.Errorf("get super admins failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { url := GetMCPBaseURL() + SuperAdminPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -1010,7 +1056,27 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons } // SendCliAuthApply sends a CLI auth apply request to the specified admin. +// It retries up to mcpRequestMaxRetries times on transient errors. func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID) + if err == nil { + return result, nil + } + lastErr = err + } + return nil, fmt.Errorf("send CLI auth apply failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -1046,7 +1112,27 @@ type ClientIDResponse struct { // FetchClientIDFromMCP fetches the CLI client ID from MCP server. // This is used when no client ID is provided via flags, config, or env vars. +// It retries up to mcpRequestMaxRetries times on transient errors. func FetchClientIDFromMCP(ctx context.Context) (string, error) { + var lastErr error + for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + id, err := doFetchClientIDFromMCP(ctx) + if err == nil { + return id, nil + } + lastErr = err + } + return "", fmt.Errorf("fetch client ID failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) +} + +func doFetchClientIDFromMCP(ctx context.Context) (string, error) { url := GetMCPBaseURL() + ClientIDPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 290323c7..071f9237 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -234,18 +234,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } callbackTokenMu.Unlock() - // Check CLI auth enabled status + // Check CLI auth enabled status (fail-closed: treat errors as disabled) authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) - cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled // Update CLI auth disabled state callbackTokenMu.Lock() - callbackAuthDisabled = cliAuthDisabled + callbackAuthDisabled = !cliAuthEnabled callbackTokenMu.Unlock() // Display appropriate HTML based on CLI auth status w.Header().Set("Content-Type", "text/html; charset=utf-8") - if cliAuthDisabled { + if !cliAuthEnabled { _, _ = fmt.Fprint(w, notEnabledHTML) } else { _, _ = fmt.Fprint(w, successHTML) @@ -256,7 +256,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Notify main goroutine with full result select { - case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}: + case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}: default: } }) diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 916f03ce..bfd36759 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -155,5 +155,20 @@ "返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken", "附件工作流": "Attachment workflow", "页码 (必填)": "page number (required)", + "⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status", + " 请检查网络连接后重试。": " Please check your network connection and retry.", + "检查 CLI 授权状态失败": "Failed to check CLI auth status", + "⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization", + " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".", + " 组织主管理员:": " Organization super admins: ", + " 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.", + " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings", + "该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it", + "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...", + "✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...", + "等待管理员审批中": "Waiting for admin approval", + "等待提交申请中": "Waiting to submit request", + "操作超时,请重新登录": "Operation timed out, please re-login", + "检查组织 CLI 授权状态...": "Checking organization CLI auth status...", "🔐 登录钉钉": "🔐 Login to DingTalk" } diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 2a029280..dbfe9e9b 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -155,5 +155,20 @@ "返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken", "附件工作流": "附件工作流", "页码 (必填)": "页码 (必填)", + "⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态", + " 请检查网络连接后重试。": " 请检查网络连接后重试。", + "检查 CLI 授权状态失败": "检查 CLI 授权状态失败", + "⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限", + " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。", + " 组织主管理员:": " 组织主管理员:", + " 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。", + " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings", + "该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启", + "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...", + "✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...", + "等待管理员审批中": "等待管理员审批中", + "等待提交申请中": "等待提交申请中", + "操作超时,请重新登录": "操作超时,请重新登录", + "检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...", "🔐 登录钉钉": "🔐 登录钉钉" }