525 lines
16 KiB
Go
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,
|
|
}
|
|
}
|