package auth import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) // ─── endpoints.go ────────────────────────────────────────────────────── func TestClientID_RuntimeOverride(t *testing.T) { t.Setenv("DWS_CLIENT_ID", "env-id") SetClientID("flag-id") t.Cleanup(func() { SetClientID("") }) if got := ClientID(); got != "flag-id" { t.Fatalf("expected flag-id, got %s", got) } } func TestClientID_EnvFallback(t *testing.T) { SetClientID("") t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_ID", "env-id") if got := ClientID(); got != "env-id" { t.Fatalf("expected env-id, got %s", got) } } func TestClientID_Default(t *testing.T) { SetClientID("") t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_ID", "") // When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string if strings.HasPrefix(DefaultClientID, "<") { if got := ClientID(); got != "" { t.Fatalf("expected empty string for placeholder, got %s", got) } } else { if got := ClientID(); got != DefaultClientID { t.Fatalf("expected default, got %s", got) } } } func TestClientSecret_RuntimeOverride(t *testing.T) { t.Setenv("DWS_CLIENT_SECRET", "env-secret") SetClientSecret("flag-secret") t.Cleanup(func() { SetClientSecret("") }) if got := ClientSecret(); got != "flag-secret" { t.Fatalf("expected flag-secret, got %s", got) } } func TestClientSecret_EnvFallback(t *testing.T) { SetClientSecret("") t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_SECRET", "env-secret") if got := ClientSecret(); got != "env-secret" { t.Fatalf("expected env-secret, got %s", got) } } func TestClientSecret_Default(t *testing.T) { SetClientSecret("") t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_SECRET", "") if got := ClientSecret(); got != DefaultClientSecret { t.Fatalf("expected default, got %s", got) } } func TestClientID_ConcurrentAccess(t *testing.T) { var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(2) go func(n int) { defer wg.Done() SetClientID(fmt.Sprintf("id-%d", n)) }(i) go func() { defer wg.Done() _ = ClientID() }() } wg.Wait() SetClientID("") } // ─── identity.go ─────────────────────────────────────────────────────── func TestGenerateUUID_Format(t *testing.T) { t.Parallel() uuid := generateUUID() parts := strings.Split(uuid, "-") if len(parts) != 5 { t.Fatalf("expected 5 parts, got %d: %s", len(parts), uuid) } if len(uuid) != 36 { t.Fatalf("expected 36 chars, got %d: %s", len(uuid), uuid) } // Version 4 marker if uuid[14] != '4' { t.Fatalf("expected version 4 at position 14, got %c", uuid[14]) } } func TestIdentity_LoadMissing(t *testing.T) { t.Parallel() id := Load(t.TempDir()) if id != nil { t.Fatal("expected nil for missing identity") } } func TestIdentity_EnsureExistsCreatesNew(t *testing.T) { t.Parallel() dir := t.TempDir() id := EnsureExists(dir) if id == nil || id.AgentID == "" { t.Fatal("expected non-nil identity with agentID") } if id.Source != "dws" { t.Fatalf("expected source dws, got %s", id.Source) } } func TestIdentity_EnsureExistsLoadsExisting(t *testing.T) { t.Parallel() dir := t.TempDir() id1 := EnsureExists(dir) id2 := EnsureExists(dir) if id1.AgentID != id2.AgentID { t.Fatalf("expected same ID, got %s vs %s", id1.AgentID, id2.AgentID) } } func TestIdentity_LoadInvalidJSON(t *testing.T) { t.Parallel() dir := t.TempDir() _ = os.WriteFile(filepath.Join(dir, identityFile), []byte("not json"), 0o600) if Load(dir) != nil { t.Fatal("expected nil for invalid JSON") } } func TestIdentity_LoadEmptyAgentID(t *testing.T) { t.Parallel() dir := t.TempDir() data, _ := json.Marshal(Identity{AgentID: "", Source: "dws"}) _ = os.WriteFile(filepath.Join(dir, identityFile), data, 0o600) if Load(dir) != nil { t.Fatal("expected nil for empty agentID") } } func TestIdentity_Headers(t *testing.T) { t.Parallel() id := &Identity{AgentID: "test-uuid", Source: "dws"} h := id.Headers() if h["x-dws-agent-id"] != "test-uuid" { t.Fatalf("wrong agent-id header: %s", h["x-dws-agent-id"]) } if h["x-dws-source"] != "dws" { t.Fatalf("wrong source header: %s", h["x-dws-source"]) } if _, ok := h["x-dingtalk-scenario-code"]; !ok { t.Fatal("missing scenario-code header") } } func TestIdentity_Headers_Nil(t *testing.T) { t.Parallel() var id *Identity if h := id.Headers(); h != nil { t.Fatalf("expected nil headers for nil identity, got %v", h) } } // ─── manager.go ──────────────────────────────────────────────────────── func TestManager_SaveAndGetToken(t *testing.T) { t.Parallel() dir := t.TempDir() m := NewManager(dir, nil) if err := m.SaveToken("test-token"); err != nil { t.Fatalf("SaveToken error: %v", err) } token, source, err := m.GetToken() if err != nil { t.Fatalf("GetToken error: %v", err) } if token != "test-token" { t.Fatalf("expected test-token, got %s", token) } if source != "file" { t.Fatalf("expected file source, got %s", source) } } func TestManager_GetToken_Missing(t *testing.T) { t.Parallel() m := NewManager(t.TempDir(), nil) _, _, err := m.GetToken() if err == nil { t.Fatal("expected error for missing token") } } func TestManager_SaveAndGetMCPURL(t *testing.T) { t.Parallel() dir := t.TempDir() m := NewManager(dir, nil) if err := m.SaveMCPURL("https://example.com/mcp"); err != nil { t.Fatalf("SaveMCPURL error: %v", err) } url, err := m.GetMCPURL() if err != nil { t.Fatalf("GetMCPURL error: %v", err) } if url != "https://example.com/mcp" { t.Fatalf("expected url, got %s", url) } } func TestManager_GetMCPURL_Missing(t *testing.T) { t.Parallel() m := NewManager(t.TempDir(), nil) _, err := m.GetMCPURL() if err == nil { t.Fatal("expected error for missing MCP URL") } } // ─── token.go ────────────────────────────────────────────────────────── func TestTokenData_IsAccessTokenValid(t *testing.T) { t.Parallel() tests := []struct { name string data *TokenData want bool }{ {"nil", nil, false}, {"empty token", &TokenData{}, false}, {"expired", &TokenData{AccessToken: "t", ExpiresAt: time.Now().Add(-time.Hour)}, false}, {"valid", &TokenData{AccessToken: "t", ExpiresAt: time.Now().Add(time.Hour)}, true}, {"within buffer", &TokenData{AccessToken: "t", ExpiresAt: time.Now().Add(3 * time.Minute)}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.data.IsAccessTokenValid(); got != tt.want { t.Fatalf("IsAccessTokenValid() = %v, want %v", got, tt.want) } }) } } func TestTokenData_IsRefreshTokenValid(t *testing.T) { t.Parallel() tests := []struct { name string data *TokenData want bool }{ {"nil", nil, false}, {"empty", &TokenData{}, false}, {"expired", &TokenData{RefreshToken: "r", RefreshExpAt: time.Now().Add(-time.Hour)}, false}, {"valid", &TokenData{RefreshToken: "r", RefreshExpAt: time.Now().Add(time.Hour)}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.data.IsRefreshTokenValid(); got != tt.want { t.Fatalf("IsRefreshTokenValid() = %v, want %v", got, tt.want) } }) } } func TestTokenData_HasPersistentCode(t *testing.T) { t.Parallel() if (&TokenData{}).HasPersistentCode() { t.Fatal("expected false for empty") } if !(&TokenData{PersistentCode: "code"}).HasPersistentCode() { t.Fatal("expected true") } var nilData *TokenData if nilData.HasPersistentCode() { t.Fatal("expected false for nil") } } func TestRevokeTokenRemote(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer srv.Close() // Can't easily test since LogoutURL is a const. Just test that it doesn't panic with real URL. } func TestRevokeTokenRemoteForDataUsesExactTokenMetadata(t *testing.T) { configDir := t.TempDir() var got struct { ClientID string `json:"clientId"` AccessToken string `json:"accessToken"` } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != MCPRevokeTokenPath { t.Errorf("revoke path = %q, want %q", r.URL.Path, MCPRevokeTokenPath) } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Errorf("decode revoke body: %v", err) } w.WriteHeader(http.StatusOK) })) defer srv.Close() t.Setenv("DWS_CONFIG_DIR", configDir) if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(srv.URL), 0o600); err != nil { t.Fatalf("write mcp_url: %v", err) } SetClientID("wrong-global-client") t.Cleanup(func() { SetClientID("") }) data := &TokenData{ AccessToken: "exact-account-token", ClientID: "exact-account-client", Source: "mcp", } if err := RevokeTokenRemoteForData(t.Context(), data); err != nil { t.Fatalf("RevokeTokenRemoteForData() error = %v", err) } if got.ClientID != data.ClientID || got.AccessToken != data.AccessToken { t.Fatalf("revoke body = %#v, want exact token metadata", got) } } // ─── oauth_helpers.go ────────────────────────────────────────────────── type tokenResponse struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` PersistentCode string `json:"persistentCode"` ExpiresIn int64 `json:"expiresIn"` } func TestBuildTokenData_DefaultExpiry(t *testing.T) { resp := tokenResponse{ AccessToken: "access-123", RefreshToken: "refresh-456", ExpiresIn: 0, } data := buildTokenDataFromResponse(resp) if data == nil { t.Fatal("expected non-nil token data") } if data.AccessToken != "access-123" { t.Fatalf("wrong access token: %s", data.AccessToken) } } func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) { provider := &OAuthProvider{} data, err := provider.parseMCPTokenResponse([]byte(`{ "accessToken": "access-123", "refreshToken": "refresh-456", "expiresIn": 7200, "corpId": "ding123", "corpName": "钉钉(中国)信息技术有限公司" }`)) if err != nil { t.Fatalf("parseMCPTokenResponse() error = %v", err) } if data.CorpID != "ding123" { t.Fatalf("corp id = %q, want ding123", data.CorpID) } if data.CorpName != "钉钉(中国)信息技术有限公司" { t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName) } } func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) { provider := &OAuthProvider{} for _, tc := range []struct { name string body string want string }{ { name: "snake", body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`, want: "Snake Corp", }, { name: "orgName", body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`, want: "Org Corp", }, } { t.Run(tc.name, func(t *testing.T) { data, err := provider.parseMCPTokenResponse([]byte(tc.body)) if err != nil { t.Fatalf("parseMCPTokenResponse() error = %v", err) } if data.CorpName != tc.want { t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want) } }) } } func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) { authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target") if !strings.Contains(authURL, "corpId=ding-target") { t.Fatalf("auth URL missing target corpId: %s", authURL) } } func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) { authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational) if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") { t.Fatalf("auth URL = %s, want international authorize host", authURL) } } func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) { if !strings.Contains(notEnabledHTML, "status.authorizeUrl") { t.Fatal("not-enabled page must read the authorize URL from the regional login status") } if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) { t.Fatal("not-enabled page must not hard-code the domestic authorize URL") } } func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) { if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL { t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL) } if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL { t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL) } if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL { t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL) } if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL { t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL) } if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL { t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL) } } func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) { var nilProvider *OAuthProvider if got := nilProvider.loginRegion(); got != LoginRegionDefault { t.Fatalf("nil provider login region = %q", got) } nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) nilProvider.applyLoginRegionToToken(&TokenData{}) provider := &OAuthProvider{} provider.useTokenLoginRegion(nil) provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) if provider.LoginRegion != LoginRegionInternational { t.Fatalf("provider login region = %q, want international", provider.LoginRegion) } provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)}) provider.applyLoginRegionToToken(nil) token := &TokenData{} provider.applyLoginRegionToToken(token) if token.LoginRegion != string(LoginRegionInternational) { t.Fatalf("token login region = %q, want international", token.LoginRegion) } } func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) { restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/") defer restore() if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" { t.Fatalf("international MCP base URL = %q, want override", got) } } func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) { restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/") defer restore() if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" { t.Fatalf("international device base URL = %q, want override", got) } if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" { t.Fatalf("international authorize URL = %q, want override", got) } if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" { t.Fatalf("international user access token URL = %q, want override", got) } } func buildTokenDataFromResponse(resp tokenResponse) *TokenData { if resp.AccessToken == "" { return nil } now := time.Now() expiresIn := resp.ExpiresIn if expiresIn <= 0 { expiresIn = 7200 } return &TokenData{ AccessToken: resp.AccessToken, RefreshToken: resp.RefreshToken, ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second), RefreshExpAt: now.Add(30 * 24 * time.Hour), PersistentCode: resp.PersistentCode, } }