355 lines
14 KiB
Go
355 lines
14 KiB
Go
// 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 app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
)
|
|
|
|
func installAuthRefreshRunnerSeams(t *testing.T) {
|
|
t.Helper()
|
|
previousHooks := edition.Get()
|
|
previousCall := runnerCallTool
|
|
previousPreflight := runnerPreflightDocDownload
|
|
previousRefresh := runnerForceRefreshRejectedAccessToken
|
|
previousRetry := runnerExecuteAuthRetry
|
|
previousCapture := runnerCaptureRuntimeFailure
|
|
previousProfile := authpkg.RuntimeProfile()
|
|
|
|
pluginAuthMu.Lock()
|
|
previousPlugins := pluginAuthRegistry
|
|
pluginAuthRegistry = make(map[string]*PluginAuth)
|
|
pluginAuthMu.Unlock()
|
|
|
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
|
return nil
|
|
}
|
|
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
|
|
authpkg.SetRuntimeProfile("")
|
|
runtimeTokenManager.Invalidate()
|
|
t.Setenv("DWS_CONFIG_DIR", "")
|
|
t.Setenv("DWS_DEBUG_AUTH", "0")
|
|
|
|
t.Cleanup(func() {
|
|
edition.Override(previousHooks)
|
|
runnerCallTool = previousCall
|
|
runnerPreflightDocDownload = previousPreflight
|
|
runnerForceRefreshRejectedAccessToken = previousRefresh
|
|
runnerExecuteAuthRetry = previousRetry
|
|
runnerCaptureRuntimeFailure = previousCapture
|
|
authpkg.SetRuntimeProfile(previousProfile)
|
|
runtimeTokenManager.Invalidate()
|
|
pluginAuthMu.Lock()
|
|
pluginAuthRegistry = previousPlugins
|
|
pluginAuthMu.Unlock()
|
|
})
|
|
}
|
|
|
|
func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner {
|
|
return &runtimeRunner{
|
|
transport: transport.NewClient(nil),
|
|
globalFlags: flags,
|
|
auditSink: audit.NopSink{},
|
|
}
|
|
}
|
|
|
|
func authRefreshTestInvocation() executor.Invocation {
|
|
return executor.Invocation{
|
|
CanonicalProduct: "auth-retry-test-product",
|
|
Tool: "test_tool",
|
|
Params: map[string]any{"value": "safe"},
|
|
}
|
|
}
|
|
|
|
func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks {
|
|
return &edition.Hooks{
|
|
ConfigDir: func() string { return configDir },
|
|
TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
|
return *token, nil
|
|
},
|
|
ClassifyToolResult: classify,
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
configDir := t.TempDir()
|
|
token := "old-access"
|
|
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
|
|
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
|
|
if expired, _ := content["expired"].(bool); expired {
|
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
|
}
|
|
return nil
|
|
}))
|
|
|
|
var callTokens []string
|
|
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
|
callTokens = append(callTokens, client.AuthToken)
|
|
if len(callTokens) == 1 {
|
|
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
|
}
|
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
|
}
|
|
refreshCalls := 0
|
|
runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) {
|
|
refreshCalls++
|
|
if gotDir != configDir || rejected != "old-access" {
|
|
t.Fatalf("refresh input = dir %q token %q", gotDir, rejected)
|
|
}
|
|
token = "new-access"
|
|
return token, nil
|
|
}
|
|
|
|
result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" {
|
|
t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens)
|
|
}
|
|
content, _ := result.Response["content"].(map[string]any)
|
|
if content["value"] != "ok" || content["success"] != true {
|
|
t.Fatalf("result content = %#v", content)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
t.Setenv("DWS_DEBUG_AUTH", "1")
|
|
configDir := t.TempDir()
|
|
token := "old-access"
|
|
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
|
|
edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error {
|
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
|
}))
|
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
|
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
|
}
|
|
refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`)
|
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
|
return "", refreshErr
|
|
}
|
|
|
|
var logs bytes.Buffer
|
|
previousLogger := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
t.Cleanup(func() { slog.SetDefault(previousLogger) })
|
|
|
|
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
|
if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) {
|
|
t.Fatalf("error = %v, want rejection and refresh causes", err)
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" {
|
|
t.Fatalf("refresh envelope = %#v", typed)
|
|
}
|
|
var rendered bytes.Buffer
|
|
if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil {
|
|
t.Fatal(printErr)
|
|
}
|
|
for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} {
|
|
if !strings.Contains(rendered.String(), want) {
|
|
t.Fatalf("structured stderr missing %s: %s", want, rendered.String())
|
|
}
|
|
}
|
|
for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} {
|
|
if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) {
|
|
t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String())
|
|
}
|
|
}
|
|
for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} {
|
|
if !strings.Contains(logs.String(), want) {
|
|
t.Fatalf("safe refresh log missing %q: %s", want, logs.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
configDir := t.TempDir()
|
|
token := "old-access"
|
|
firstCause := errors.New("first rejection")
|
|
secondCause := errors.New("second rejection")
|
|
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
|
|
attempt, _ := content["attempt"].(int)
|
|
if attempt == 1 {
|
|
return &authretry.AuthRefreshRequired{Cause: firstCause}
|
|
}
|
|
return &authretry.AuthRefreshRequired{Cause: secondCause}
|
|
}))
|
|
calls := 0
|
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
|
calls++
|
|
return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil
|
|
}
|
|
refreshCalls := 0
|
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
|
refreshCalls++
|
|
token = "new-access"
|
|
return token, nil
|
|
}
|
|
|
|
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
|
if !errors.Is(err, secondCause) || errors.Is(err, firstCause) {
|
|
t.Fatalf("error = %v, want only second rejection cause", err)
|
|
}
|
|
if calls != 2 || refreshCalls != 1 {
|
|
t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) {
|
|
t.Run("http 401 marker retries once", func(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
configDir := t.TempDir()
|
|
token := "old-access"
|
|
rejection := errors.New("transport rejected token")
|
|
hookCalls := 0
|
|
hooks := authRefreshTokenHooks(configDir, &token, nil)
|
|
hooks.OnAuthError = func(string, error) error {
|
|
hookCalls++
|
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
|
}
|
|
edition.Override(hooks)
|
|
calls := 0
|
|
var callTokens []string
|
|
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
|
calls++
|
|
callTokens = append(callTokens, client.AuthToken)
|
|
if calls == 1 {
|
|
return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401"))
|
|
}
|
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
|
}
|
|
refreshCalls := 0
|
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
|
refreshCalls++
|
|
token = "new-access"
|
|
return token, nil
|
|
}
|
|
|
|
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" {
|
|
t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens)
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))},
|
|
{name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))},
|
|
} {
|
|
t.Run(tc.name+" does not enter hook", func(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
configDir := t.TempDir()
|
|
token := "old-access"
|
|
hookCalls := 0
|
|
hooks := authRefreshTokenHooks(configDir, &token, nil)
|
|
hooks.OnAuthError = func(string, error) error {
|
|
hookCalls++
|
|
return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")}
|
|
}
|
|
edition.Override(hooks)
|
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
|
return transport.ToolCallResult{}, tc.err
|
|
}
|
|
refreshCalls := 0
|
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
|
refreshCalls++
|
|
return "", nil
|
|
}
|
|
|
|
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
|
if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 {
|
|
t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
rejection := errors.New("explicit token rejected")
|
|
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
|
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
|
}})
|
|
calls := 0
|
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
|
calls++
|
|
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
|
}
|
|
refreshCalls := 0
|
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
|
refreshCalls++
|
|
return "", nil
|
|
}
|
|
|
|
_, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
|
if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 {
|
|
t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) {
|
|
installAuthRefreshRunnerSeams(t)
|
|
configDir := t.TempDir()
|
|
token := "old-access"
|
|
rejection := errors.New("preflight token rejected")
|
|
edition.Override(authRefreshTokenHooks(configDir, &token, nil))
|
|
preflightCalls := 0
|
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
|
preflightCalls++
|
|
if preflightCalls == 1 {
|
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
|
}
|
|
return nil
|
|
}
|
|
toolCalls := 0
|
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
|
toolCalls++
|
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
|
}
|
|
refreshCalls := 0
|
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
|
refreshCalls++
|
|
token = "new-access"
|
|
return token, nil
|
|
}
|
|
|
|
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 {
|
|
t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls)
|
|
}
|
|
}
|