282 lines
13 KiB
Go
282 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
|
|
oldLoad := patLoadTokenData
|
|
t.Cleanup(func() {
|
|
patAuthorizationTimeout = oldTimeout
|
|
patAuthorizationPollInterval = oldInterval
|
|
patLoadTokenData = oldLoad
|
|
})
|
|
patAuthorizationTimeout = 50 * time.Millisecond
|
|
patAuthorizationPollInterval = time.Millisecond
|
|
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
|
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
|
}
|
|
out.Reset()
|
|
if !WaitForPatAuthorization(context.Background(), "", &out) {
|
|
t.Fatal("valid token did not authorize")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
out.Reset()
|
|
if WaitForPatAuthorization(ctx, "", &out) {
|
|
t.Fatal("cancelled authorization succeeded")
|
|
}
|
|
patAuthorizationTimeout = time.Millisecond
|
|
patAuthorizationPollInterval = time.Hour
|
|
out.Reset()
|
|
if WaitForPatAuthorization(context.Background(), "", &out) {
|
|
t.Fatal("timed out authorization succeeded")
|
|
}
|
|
patAuthorizationTimeout = 5 * time.Millisecond
|
|
patAuthorizationPollInterval = time.Millisecond
|
|
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
|
out.Reset()
|
|
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
|
|
t.Fatalf("invalid-token polling output = %q", 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 { return false }
|
|
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 { return true }
|
|
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
|
|
oldLoad := patLoadTokenData
|
|
oldBrowser := patBrowserOpenCommand
|
|
t.Cleanup(func() {
|
|
patPollHTTPDo = oldDo
|
|
patPollNewRequest = oldRequest
|
|
patLoadTokenData = oldLoad
|
|
patBrowserOpenCommand = oldBrowser
|
|
})
|
|
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "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")
|
|
}
|
|
}
|