Files

525 lines
16 KiB
Go

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,
}
}