Files
dingtalk-workspace-cli/internal/app/pat_auth_retry_full_coverage_test.go
T

284 lines
13 KiB
Go

package app
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os/exec"
"strings"
"testing"
"time"
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"
)
func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T) {
typed := apperrors.NewAPI("ordinary", apperrors.WithHint("insufficient_scope"))
if !isPatScopeError(typed) {
t.Fatal("typed insufficient_scope was not recognized")
}
typed = apperrors.NewAPI("ordinary", apperrors.WithReason("reason"))
if got := extractPatScopeError(typed); !strings.Contains(got.Message, "reason") {
t.Fatalf("typed scope message = %q", got.Message)
}
var out bytes.Buffer
PrintPatAuthError(&out, &PatScopeError{Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login"})
if !strings.Contains(out.String(), "dws auth login") {
t.Fatalf("PAT output = %q", out.String())
}
if wantsStructuredPATOutputFromRunner(runnerCoverageFallback{}) {
t.Fatal("non-runtime runner requested structured PAT output")
}
if got := enrichPATErrorWithOpenBrowser("", true); got != "" {
t.Fatalf("empty enriched PAT = %q", got)
}
if got := enrichPATErrorWithOpenBrowser("not-json", true); got != "not-json" {
t.Fatalf("malformed enriched PAT = %q", got)
}
if got := enrichPATErrorWithOpenBrowser(`{"code":"x"}`, true); !strings.Contains(got, "openBrowser") {
t.Fatalf("missing data enrichment = %q", got)
}
if got := patAuthorizationURIFromData(map[string]any{"authorizationUrl": " final "}); got != "final" {
t.Fatalf("authorization URI = %q", got)
}
if err := openPATAuthorizationURI(""); err != nil {
t.Fatal(err)
}
oldTimeout := patAuthorizationTimeout
oldInterval := patAuthorizationPollInterval
oldResolve := patResolveAccessToken
t.Cleanup(func() {
patAuthorizationTimeout = oldTimeout
patAuthorizationPollInterval = oldInterval
patResolveAccessToken = oldResolve
})
patAuthorizationTimeout = 50 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patResolveAccessToken = func(context.Context, string, string) (string, error) {
return "token", nil
}
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok {
t.Fatal("valid token did not authorize")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
out.Reset()
if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = time.Millisecond
patAuthorizationPollInterval = time.Hour
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.T) {
oldWait := patWaitForAuthorization
oldPoll := patPollDeviceFlowWithInterval
oldSaveApp := patSaveAppConfig
oldExchange := patExchangeCodeForToken
oldSaveToken := patSaveTokenData
oldSleep := patSleep
oldOpen := openBrowserFunc
t.Cleanup(func() {
patWaitForAuthorization = oldWait
patPollDeviceFlowWithInterval = oldPoll
patSaveAppConfig = oldSaveApp
patExchangeCodeForToken = oldExchange
patSaveTokenData = oldSaveToken
patSleep = oldSleep
openBrowserFunc = oldOpen
})
t.Setenv(authpkg.AgentCodeEnv, "")
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
t.Fatal("PAT retry timeout succeeded")
}
wantErr := errors.New("runner failed")
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
t.Fatalf("authorized retry = %v", err)
}
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
if err := runDirectPATAuthCheck(context.Background(), nil, patErr, nil, io.Discard); !errors.Is(err, patErr) {
t.Fatalf("nil direct retry = %v", err)
}
if err := runDirectPATAuthCheckWithMode(context.Background(), nil, patErr, nil, io.Discard, true); !errors.Is(err, patErr) {
t.Fatalf("nil retry mode = %v", err)
}
badRetry := errors.New("retry callback")
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return authpkg.StatusApproved, "", nil
}
patSleep = func(time.Duration) {}
if err := runDirectPATAuthCheck(context.Background(), nil, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, func(context.Context) error { return badRetry }, io.Discard); !errors.Is(err, badRetry) {
t.Fatalf("direct retry callback = %v", err)
}
malformedPAT := &apperrors.PATError{RawJSON: `{`}
if _, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, executor.Invocation{}, malformedPAT, t.TempDir(), io.Discard); !errors.Is(err, malformedPAT) {
t.Fatalf("malformed PAT handler = %v", err)
}
openBrowserFunc = func(string) error { return nil }
for _, raw := range []string{
`{"code":"x","data":{"flowId":"f","authUrl":"https://auth.test","desc":"authorize"}}`,
`{"code":"x","data":{"flowId":"f","authorizationUrl":"https://auth2.test"}}`,
} {
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return "", "", wantErr
}
ctx := context.WithValue(context.Background(), patSuppressBrowserOpenKey, true)
if _, err := handlePatAuthCheck(ctx, &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: raw}, t.TempDir(), io.Discard); err == nil {
t.Fatal("poll failure returned nil")
}
}
statuses := []string{authpkg.StatusRejected, authpkg.StatusExpired, authpkg.StatusCancelled, "UNKNOWN"}
for _, status := range statuses {
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return status, "", nil
}
if _, err := handlePatAuthCheck(context.WithValue(context.Background(), patSuppressBrowserOpenKey, true), &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err == nil {
t.Fatalf("status %s returned nil", status)
}
}
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
return authpkg.StatusApproved, "code", nil
}
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return wantErr }
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) { return nil, wantErr }
skip := executor.Invocation{Params: map[string]any{"retryAfterApproval": false}}
if got, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, skip, &apperrors.PATError{RawJSON: patRaw("f", "client", "secret")}, t.TempDir(), io.Discard); err != nil || !got.Invocation.Implemented {
t.Fatalf("approved skip = %#v, %v", got, err)
}
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "token"}, nil
}
patSaveTokenData = func(string, *authpkg.TokenData) error { return wantErr }
r := &runtimeRunner{fallback: runnerCoverageFallback{result: executor.Result{Response: map[string]any{"ok": true}}}}
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "client", "")}, t.TempDir(), io.Discard); err != nil {
t.Fatalf("approved retry with save failure = %v", err)
}
patSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err != nil {
t.Fatalf("approved retry = %v", err)
}
for _, inv := range []executor.Invocation{{}, {Params: map[string]any{}}, {Params: map[string]any{"retryAfterApproval": "no"}}, {Params: map[string]any{"retryAfterApproval": true}}} {
if shouldSkipPATRetryAfterApproval(inv) {
t.Fatalf("unexpected skip for %#v", inv.Params)
}
}
if got := enrichPATErrorForHostControl(""); got != "" {
t.Fatalf("empty host PAT = %q", got)
}
if got := enrichPATErrorForHostControl("bad"); got != "bad" {
t.Fatalf("bad host PAT = %q", got)
}
if got := enrichPATErrorForHostControl(`{"value":1}`); !strings.Contains(got, "value") {
t.Fatalf("generic host PAT = %q", got)
}
if _, err := marshalSingleLineJSONNoHTMLEscape(map[string]any{"bad": func() {}}); err == nil {
t.Fatal("unsupported JSON value succeeded")
}
}
func patRaw(flowID, clientID, secret string) string {
return `{"code":"x","data":{"desc":"authorize","flowId":"` + flowID + `","uri":"https://auth.test","clientId":"` + clientID + `","clientSecret":"` + secret + `"}}`
}
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
oldDo := patPollHTTPDo
oldRequest := patPollNewRequest
oldResolve := patResolveAccessToken
oldBrowser := patBrowserOpenCommand
t.Cleanup(func() {
patPollHTTPDo = oldDo
patPollNewRequest = oldRequest
patResolveAccessToken = oldResolve
patBrowserOpenCommand = oldBrowser
})
patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
cancelled, cancelNow := context.WithCancel(context.Background())
cancelNow()
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("zero-interval cancelled poll = %q, %v", status, err)
}
expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancelExpired()
if status, _, err := pollPatDeviceFlowWithInterval(expired, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusExpired {
t.Fatalf("expired-context poll = %q, %v", status, err)
}
ctx, cancel := context.WithCancel(context.Background())
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
cancel()
return nil, errors.New("network")
}
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("network poll = %q, %v", status, err)
}
ctx, cancel = context.WithCancel(context.Background())
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
cancel()
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{"))}, nil
}
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("malformed poll = %q, %v", status, err)
}
ctx, cancel = context.WithCancel(context.Background())
patPollNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) {
cancel()
return nil, errors.New("request")
}
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
t.Fatalf("invalid request poll = %q, %v", status, err)
}
patPollNewRequest = oldRequest
var out bytes.Buffer
t.Setenv("DWS_DEBUG_PAT_POLL", "1")
printPATPollDebugResponse(&out, 500, nil)
if !strings.Contains(out.String(), "empty body") {
t.Fatalf("empty debug response = %q", out.String())
}
for _, goos := range []string{"darwin", "linux", "windows", "plan9"} {
_ = browserOpenCommand(goos, "https://example.test")
}
patBrowserOpenCommand = func(string, string) *exec.Cmd { return nil }
if err := tryOpenBrowser("https://example.test"); err != nil {
t.Fatal(err)
}
patBrowserOpenCommand = func(string, string) *exec.Cmd { return exec.Command("definitely-not-a-real-dws-command") }
if err := tryOpenBrowser("https://example.test"); err == nil {
t.Fatal("missing browser command started")
}
}