diff --git a/cmd/main.go b/cmd/main.go
index 6b09bc95..4ac7a562 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -19,6 +19,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
)
+var exit = os.Exit
+
func main() {
- os.Exit(app.Execute())
+ exit(app.Execute())
}
diff --git a/cmd/main_test.go b/cmd/main_test.go
new file mode 100644
index 00000000..28081a82
--- /dev/null
+++ b/cmd/main_test.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+ "os"
+ "testing"
+)
+
+func TestMainExitsWithSuccessfulVersionCommand(t *testing.T) {
+ previousExit := exit
+ previousArgs := os.Args
+ t.Cleanup(func() {
+ exit = previousExit
+ os.Args = previousArgs
+ })
+
+ called := false
+ code := -1
+ exit = func(value int) {
+ called = true
+ code = value
+ }
+ os.Args = []string{"dws", "version"}
+ main()
+ if !called || code != 0 {
+ t.Fatalf("main exit = called %v, code %d", called, code)
+ }
+}
diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go
index 3f189b38..143409fe 100644
--- a/internal/apiclient/client.go
+++ b/internal/apiclient/client.go
@@ -49,6 +49,8 @@ var AllowedMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
+var newHTTPRequest = http.NewRequestWithContext
+
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
type RawAPIRequest struct {
Method string // GET, POST, PUT, PATCH, DELETE
@@ -112,7 +114,7 @@ func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse,
bodyReader = bytes.NewReader(data)
}
- httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
+ httpReq, err := newHTTPRequest(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)
}
diff --git a/internal/apiclient/coverage_edges_test.go b/internal/apiclient/coverage_edges_test.go
new file mode 100644
index 00000000..bb32255b
--- /dev/null
+++ b/internal/apiclient/coverage_edges_test.go
@@ -0,0 +1,293 @@
+package apiclient
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+)
+
+type failingReader struct{ err error }
+
+func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
+
+type failingWriter struct{ err error }
+
+func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
+
+func TestDryRunAndParseCoverageEdges(t *testing.T) {
+ for _, tc := range []struct {
+ base string
+ path string
+ }{
+ {DefaultBaseURL, "/v1.0/test"},
+ {LegacyBaseURL, "/topapi/test"},
+ } {
+ var out bytes.Buffer
+ err := PrintDryRun(&out, RawAPIRequest{
+ Method: "post", Path: tc.path,
+ Params: map[string]any{"page": 1}, Data: map[string]any{"name": "value"},
+ }, tc.base, "token-value")
+ if err != nil || !strings.Contains(out.String(), "Dry Run") || !strings.Contains(out.String(), "toke****") {
+ t.Fatalf("PrintDryRun(%s) = %q, %v", tc.base, out.String(), err)
+ }
+ }
+ var out bytes.Buffer
+ if err := PrintDryRun(&out, RawAPIRequest{Method: "get", Path: "/x", Params: map[string]any{"bad": make(chan int)}, Data: make(chan int)}, DefaultBaseURL, "tiny"); err != nil {
+ t.Fatalf("PrintDryRun unsupported preview: %v", err)
+ }
+
+ wantErr := errors.New("read failed")
+ if _, err := ParseJSONMap("-", "--params", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
+ t.Fatalf("ParseJSONMap read error = %v", err)
+ }
+ if got, err := ParseJSONMap("-", "--params", strings.NewReader(" \n")); err != nil || got != nil {
+ t.Fatalf("ParseJSONMap empty stdin = %#v, %v", got, err)
+ }
+ if _, err := ParseOptionalBody("POST", "-", failingReader{err: wantErr}); !errors.Is(err, wantErr) {
+ t.Fatalf("ParseOptionalBody read error = %v", err)
+ }
+ if got, err := ParseOptionalBody("POST", "-", strings.NewReader(" \n")); err != nil || got != nil {
+ t.Fatalf("ParseOptionalBody empty stdin = %#v, %v", got, err)
+ }
+ if _, err := ParseOptionalBody("POST", "{", strings.NewReader("")); err == nil {
+ t.Fatal("invalid optional body should fail")
+ }
+}
+
+func TestResponseHandlingCoverageEdges(t *testing.T) {
+ jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
+ textHeader := http.Header{"Content-Type": []string{"text/plain"}}
+ var out, errOut bytes.Buffer
+ opts := ResponseOptions{Format: output.FormatJSON, Out: &out, ErrOut: &errOut}
+
+ if err := HandleResponse(&RawAPIResponse{StatusCode: 500, Header: textHeader, Body: []byte(" failed ")}, opts); err == nil {
+ t.Fatal("plain HTTP error should fail")
+ }
+ for _, body := range [][]byte{nil, []byte("{")} {
+ if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: body}, opts); err == nil {
+ t.Errorf("invalid JSON body %q should fail", body)
+ }
+ }
+ out.Reset()
+ if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(`{"ok":true}`)}, opts); err != nil || !strings.Contains(out.String(), "ok") {
+ t.Fatalf("successful JSON response = %q, %v", out.String(), err)
+ }
+ for _, payload := range []string{
+ `{"errcode":1}`,
+ `{"message":"message failure"}`,
+ `{"error":"error failure"}`,
+ `{}`,
+ } {
+ status := 200
+ if !strings.Contains(payload, "errcode") {
+ status = 500
+ }
+ if err := HandleResponse(&RawAPIResponse{StatusCode: status, Header: jsonHeader, Body: []byte(payload)}, opts); err == nil {
+ t.Errorf("business/HTTP payload %s should fail", payload)
+ }
+ }
+ if err := checkDingTalkError([]any{1}, 200); err != nil || checkDingTalkError(map[string]any{"errcode": 0}, 200) != nil {
+ t.Fatal("successful DingTalk response classified as error")
+ }
+
+ if err := HandleResponse(&RawAPIResponse{StatusCode: 200, Header: textHeader, Body: []byte("binary")}, opts); err == nil {
+ t.Fatal("binary response without filename should fail")
+ }
+ invalidCD := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="unterminated`}}
+ if inferFilename(invalidCD) != "" {
+ t.Fatal("invalid content disposition should not infer filename")
+ }
+ if inferFilename(http.Header{}) != "" {
+ t.Fatal("missing content disposition should not infer filename")
+ }
+
+ dir := t.TempDir()
+ blockedParent := filepath.Join(dir, "file")
+ if err := os.WriteFile(blockedParent, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ opts.OutputPath = filepath.Join(blockedParent, "child.bin")
+ if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
+ t.Fatal("binary mkdir failure should fail")
+ }
+ opts.OutputPath = dir
+ if err := handleBinaryResponse(&RawAPIResponse{Header: textHeader, Body: []byte("x")}, opts); err == nil {
+ t.Fatal("binary write to directory should fail")
+ }
+ opts.OutputPath = ""
+ inferred := filepath.Join(dir, "inferred.bin")
+ header := http.Header{"Content-Type": []string{"application/octet-stream"}, "Content-Disposition": []string{`attachment; filename="` + inferred + `"`}}
+ if err := handleBinaryResponse(&RawAPIResponse{Header: header, Body: []byte("bytes")}, opts); err != nil {
+ t.Fatalf("inferred binary save: %v", err)
+ }
+ if !strings.Contains(errOut.String(), "已保存") {
+ t.Fatalf("binary status = %q", errOut.String())
+ }
+
+ for _, ct := range []string{" application/json; charset=utf-8 ", "text/json", "application/problem+json", "text/plain"} {
+ _ = isJSONContentType(ct)
+ }
+ for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), json.Number("bad"), "5"} {
+ _ = toFloat64(value)
+ }
+}
+
+func TestPaginationParsingAndInjectionEdges(t *testing.T) {
+ jsonHeader := http.Header{"Content-Type": []string{"application/json"}}
+ for _, resp := range []*RawAPIResponse{
+ {StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: []byte("x")},
+ {StatusCode: 200, Header: jsonHeader},
+ {StatusCode: 200, Header: jsonHeader, Body: []byte("{")},
+ {StatusCode: 500, Header: jsonHeader, Body: []byte(`{"message":"bad"}`)},
+ } {
+ if _, _, _, err := parsePaginatedResponse(resp); err == nil {
+ t.Errorf("parsePaginatedResponse(%#v) should fail", resp)
+ }
+ }
+ responses := []struct {
+ body string
+ more bool
+ token string
+ }{
+ {`{"result":{"has_more":true,"next_cursor":12}}`, true, "12"},
+ {`{"has_more":true,"next_cursor":13}`, true, "13"},
+ {`{"next_token":"next"}`, true, "next"},
+ {`{"result":[],"has_more":false}`, false, ""},
+ }
+ for _, tc := range responses {
+ _, more, token, err := parsePaginatedResponse(&RawAPIResponse{StatusCode: 200, Header: jsonHeader, Body: []byte(tc.body)})
+ if err != nil || more != tc.more || token != tc.token {
+ t.Errorf("pagination %s = %v, %q, %v", tc.body, more, token, err)
+ }
+ }
+
+ getCases := []RawAPIRequest{
+ {Method: "GET"},
+ {Method: "GET", Params: map[string]any{"cursor": "old"}},
+ {Method: "GET", Params: map[string]any{"next_token": "old"}},
+ {Method: "POST", Data: map[string]any{"cursor": "old"}},
+ {Method: "PUT", Data: map[string]any{}},
+ {Method: "POST", Data: "not-a-map"},
+ }
+ for _, req := range getCases {
+ _ = injectPageToken(req, "new")
+ }
+ logf(nil, "ignored")
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
+
+func jsonHTTPResponse(body string) *http.Response {
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}
+}
+
+func TestPaginationControlFlowEdges(t *testing.T) {
+ wantErr := errors.New("transport failed")
+ client := NewClient("token", DefaultBaseURL)
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
+ })
+ if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); err == nil {
+ t.Fatal("first page parse error should fail")
+ }
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
+ if _, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{}); !errors.Is(err, wantErr) {
+ t.Fatalf("first page transport error = %v", err)
+ }
+
+ calls := 0
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ calls++
+ if calls == 1 {
+ return jsonHTTPResponse(`{"next_token":"next"}`), nil
+ }
+ return nil, wantErr
+ })
+ if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1}); err == nil || len(pages) != 1 {
+ t.Fatalf("later transport error pages=%d err=%v", len(pages), err)
+ }
+
+ calls = 0
+ var logs bytes.Buffer
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ calls++
+ if calls == 1 {
+ return jsonHTTPResponse(`{"next_token":"next"}`), nil
+ }
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/plain"}}, Body: io.NopCloser(strings.NewReader("bad"))}, nil
+ })
+ if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "解析失败") {
+ t.Fatalf("later parse failure pages=%d logs=%q err=%v", len(pages), logs.String(), err)
+ }
+
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return jsonHTTPResponse(`{"next_token":"next"}`), nil
+ })
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if pages, err := client.PaginateAll(ctx, RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageDelay: 10}); !errors.Is(err, context.Canceled) || len(pages) != 1 {
+ t.Fatalf("pagination cancellation pages=%d err=%v", len(pages), err)
+ }
+ logs.Reset()
+ if pages, err := client.PaginateAll(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}, PaginationOptions{PageLimit: 1, PageDelay: 1, LogWriter: &logs}); err != nil || len(pages) != 1 || !strings.Contains(logs.String(), "安全上限") {
+ t.Fatalf("pagination safety cap pages=%d logs=%q err=%v", len(pages), logs.String(), err)
+ }
+}
+
+func TestClientAndValidationFailureEdges(t *testing.T) {
+ client := NewClient("token", DefaultBaseURL)
+ if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://api.dingtalk.com/%zz"}); err == nil {
+ t.Fatal("Do with invalid URL should fail")
+ }
+ if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "https://example.test/x"}); err == nil {
+ t.Fatal("Do to untrusted host should fail")
+ }
+ if _, err := client.Do(context.Background(), RawAPIRequest{Method: "POST", Path: "/x", Data: make(chan int)}); err == nil {
+ t.Fatal("unmarshalable request body should fail")
+ }
+ if _, err := client.buildURL("https://api.dingtalk.com/%zz", nil); err == nil {
+ t.Fatal("invalid URL should fail")
+ }
+ oldNewRequest := newHTTPRequest
+ t.Cleanup(func() { newHTTPRequest = oldNewRequest })
+ wantCreateErr := errors.New("request creation failed")
+ newHTTPRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, wantCreateErr }
+ if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantCreateErr) {
+ t.Fatalf("request creation error = %v", err)
+ }
+ newHTTPRequest = oldNewRequest
+ wantErr := errors.New("request failed")
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, wantErr })
+ if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
+ t.Fatalf("HTTP transport error = %v", err)
+ }
+ client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(failingReader{err: wantErr})}, nil
+ })
+ if _, err := client.Do(context.Background(), RawAPIRequest{Method: "GET", Path: "/x"}); !errors.Is(err, wantErr) {
+ t.Fatalf("response read error = %v", err)
+ }
+
+ if ValidateTargetHost("http://%zz") == nil {
+ t.Fatal("invalid target URL should fail")
+ }
+ for _, r := range []rune{0x200B, 0xFEFF, 0x202A, 0x2028, 0x2066, 0x061C, 0xFDD0} {
+ if !isDangerousUnicode(r) || ValidateUserInput("x"+string(r), "field") == nil {
+ t.Errorf("dangerous rune %U was accepted", r)
+ }
+ }
+ if isDangerousUnicode('中') || ValidateUserInput("safe\t\n中文", "field") != nil {
+ t.Fatal("safe Unicode/input was rejected")
+ }
+}
diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go
index 6aee387c..d61d630c 100644
--- a/internal/app/auth_command.go
+++ b/internal/app/auth_command.go
@@ -279,12 +279,13 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
var (
- authLoginGuideActionSelector = selectAuthLoginGuideAction
- authLoginGuideActionApplier = applyAuthLoginGuideAction
- loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
- loginRecommendProductSelector = selectLoginRecommendProducts
- authLoginInteractiveTerminal = isInteractiveTerminal
- migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
+ authLoginGuideActionSelector = selectAuthLoginGuideAction
+ authLoginGuideActionApplier = applyAuthLoginGuideAction
+ authLoginManualCredentialsPrompt = promptAuthLoginManualCredentials
+ loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
+ loginRecommendProductSelector = selectLoginRecommendProducts
+ authLoginInteractiveTerminal = isInteractiveTerminal
+ migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
)
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
@@ -315,7 +316,7 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录")
return nil
case authLoginGuideManualCredentials:
- clientID, clientSecret, err := promptAuthLoginManualCredentials()
+ clientID, clientSecret, err := authLoginManualCredentialsPrompt()
if err != nil {
return err
}
diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go
new file mode 100644
index 00000000..4c1d1a96
--- /dev/null
+++ b/internal/app/coverage_edges_test.go
@@ -0,0 +1,2311 @@
+package app
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
+ eventbus "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
+ eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
+ upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/spf13/cobra"
+)
+
+type coverageRunner struct {
+ result executor.Result
+ err error
+ last executor.Invocation
+}
+
+func (r *coverageRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
+ r.last = inv
+ return r.result, r.err
+}
+
+type coverageScanner struct{ report safety.Report }
+
+func (s coverageScanner) ScanPayload(any) safety.Report { return s.report }
+
+type coverageRawStderr string
+
+func (e coverageRawStderr) Error() string { return string(e) }
+func (e coverageRawStderr) RawStderr() string { return string(e) }
+
+func TestToolCallerAdapterCoverage(t *testing.T) {
+ runner := &coverageRunner{}
+ adapter := newToolCallerAdapter(runner, nil).(*toolCallerAdapter)
+ if adapter.Format() != "json" || adapter.DryRun() || adapter.Fields() != "" || adapter.JQ() != "" {
+ t.Fatal("nil flag adapter defaults changed")
+ }
+ adapter.flags = &GlobalFlags{Format: "raw", DryRun: true, Fields: "a", JQ: ".a"}
+ if adapter.Format() != "raw" || !adapter.DryRun() || adapter.Fields() != "a" || adapter.JQ() != ".a" {
+ t.Fatal("adapter flags were not forwarded")
+ }
+
+ runner.err = errors.New("runner failure")
+ if _, err := adapter.CallTool(context.Background(), "doc", "get", map[string]any{"x": 1}); !errors.Is(err, runner.err) {
+ t.Fatalf("adapter runner error = %v", err)
+ }
+ runner.err = nil
+ for name, response := range map[string]map[string]any{
+ "nil": nil,
+ "whole": {"value": 1},
+ "list": {"content": []any{map[string]any{"type": "text", "text": "hello"}, "skip"}},
+ "map": {"content": map[string]any{"value": 2}},
+ "primitive": {"content": 3},
+ } {
+ t.Run(name, func(t *testing.T) {
+ runner.result = executor.Result{Invocation: runner.last, Response: response}
+ got, err := adapter.CallTool(context.Background(), "doc", "get", nil)
+ if err != nil || got == nil {
+ t.Fatalf("adapter result = %#v, %v", got, err)
+ }
+ })
+ }
+ if strVal(map[string]any{"x": 1}, "x") != "" || strVal(map[string]any{"x": "v"}, "x") != "v" {
+ t.Fatal("strVal mismatch")
+ }
+}
+
+func TestRunnerPureCoverage(t *testing.T) {
+ for _, tc := range []struct {
+ content map[string]any
+ want string
+ }{
+ {nil, ""},
+ {map[string]any{"success": true}, ""},
+ {map[string]any{"success": "false"}, ""},
+ {map[string]any{"success": false, "errorMsg": " message "}, "message"},
+ {map[string]any{"success": false, "errorCode": " CODE "}, "business error: code CODE"},
+ {map[string]any{"success": false}, "business error: success=false"},
+ {map[string]any{"data": map[string]any{"success": false, "errorMsg": "nested"}}, "nested"},
+ {map[string]any{"result": []any{"skip", map[string]any{"success": false, "errorMsg": "array"}}}, "array"},
+ } {
+ if got := detectBusinessError(tc.content); got != tc.want {
+ t.Errorf("detectBusinessError(%#v) = %q, want %q", tc.content, got, tc.want)
+ }
+ }
+ deep := map[string]any{"success": false}
+ for range 10 {
+ deep = map[string]any{"data": deep}
+ }
+ _ = detectBusinessError(deep)
+
+ for _, tc := range []struct {
+ result transport.ToolCallResult
+ want string
+ }{
+ {transport.ToolCallResult{Blocks: []transport.ContentBlock{{Text: " block "}}}, "block"},
+ {transport.ToolCallResult{Content: map[string]any{"message": " message "}}, "message"},
+ {transport.ToolCallResult{Content: map[string]any{"error": " error "}}, "error"},
+ {transport.ToolCallResult{}, "MCP tool returned an error response"},
+ } {
+ if got := extractMCPErrorMessage(tc.result); got != tc.want {
+ t.Errorf("extractMCPErrorMessage = %q, want %q", got, tc.want)
+ }
+ }
+
+ if runtimeFlagEnabled("", false) || runtimeFlagEnabled("off", true) || !runtimeFlagEnabled("yes", false) {
+ t.Fatal("runtime flag parsing mismatch")
+ }
+ if isAuthError(nil) || !isAuthError(apperrors.NewAuth("auth")) || isAuthError(errors.New("plain")) {
+ t.Fatal("auth error classification mismatch")
+ }
+ t.Setenv("DINGTALK_MY_PRODUCT_MCP_URL", " https://override.test ")
+ if got, ok := productEndpointOverride("my-product"); !ok || got != "https://override.test" {
+ t.Fatalf("endpoint override = %q %v", got, ok)
+ }
+ if got, ok := productEndpointOverride("missing"); ok || got != "" {
+ t.Fatalf("missing endpoint override = %q %v", got, ok)
+ }
+
+ r := &runtimeRunner{}
+ if report, err := r.scanContent(nil); err != nil || report.Scanned {
+ t.Fatalf("nil scanner = %#v %v", report, err)
+ }
+ r.scanner = coverageScanner{report: safety.Report{Scanned: true}}
+ if report, err := r.scanContent(map[string]any{}); err != nil || !report.Scanned {
+ t.Fatalf("scanner = %#v %v", report, err)
+ }
+ r.scanner = coverageScanner{report: safety.Report{Scanned: true, Findings: []safety.Finding{{Pattern: "bad"}}}}
+ r.enforceContentScan = true
+ if _, err := r.scanContent(map[string]any{}); err == nil {
+ t.Fatal("enforced finding was accepted")
+ }
+ t.Setenv(runtimeContentScanEnv, "false")
+ if newRuntimeContentScanner() != nil {
+ t.Fatal("disabled scanner was created")
+ }
+ t.Setenv(runtimeContentScanEnv, "true")
+ if newRuntimeContentScanner() == nil {
+ t.Fatal("enabled scanner missing")
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ inv := executor.Invocation{CanonicalProduct: "doc", Tool: "get"}
+ logBusinessError(nil, "reason", inv, nil, apperrors.ServerDiagnostics{})
+ logBusinessError(logger, "reason", inv, map[string]any{"error": "e", "errorMsg": "em", "message": "m"}, apperrors.ServerDiagnostics{TraceID: "t", ServerErrorCode: "c", TechnicalDetail: "d"})
+
+ if len(generateExecutionID()) != 16 {
+ t.Fatal("execution ID length changed")
+ }
+ ResetRuntimeTokenCache()
+}
+
+func TestDocDownloadPureCoverage(t *testing.T) {
+ for _, inv := range []executor.Invocation{
+ {},
+ {CanonicalProduct: "DOC", Tool: docDownloadFileTool},
+ {CanonicalProduct: "doc", Tool: "other"},
+ } {
+ _ = isDocDownloadInvocation(inv)
+ }
+ for _, params := range []map[string]any{
+ nil,
+ {"nodeId": 1},
+ {"nodeId": " ", "node": " node "},
+ {"dentryUuid": "uuid"},
+ } {
+ _ = docDownloadNodeID(params)
+ }
+ _ = unsupportedAXLSDownloadError()
+ for _, content := range []map[string]any{
+ {"result": map[string]any{"extension": " axls "}},
+ {"data": map[string]any{"extension": "doc"}},
+ {"extension": "sheet"},
+ {"result": "bad"},
+ } {
+ _ = documentInfoExtension(content)
+ }
+ if stringAtPath(map[string]any{"x": map[string]any{"y": 1}}, "x", "y") != "" || stringAtPath("bad", "x") != "" {
+ t.Fatal("stringAtPath accepted a non-string path")
+ }
+}
+
+func TestRecoveryPureCoverage(t *testing.T) {
+ if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
+ t.Fatalf("array attempts = %#v %v", got, err)
+ }
+ if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
+ t.Fatal("malformed attempts succeeded")
+ }
+ if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
+ t.Fatalf("legacy attempts = %#v %v", got, err)
+ }
+ if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
+ t.Fatal("legacy attempts edge mismatch")
+ }
+
+ for _, args := range [][]string{
+ {"dws", "--debug", "doc", "get", "--node", "n"},
+ {"dws", "--format=json", "doc", "--", "ignored"},
+ {"dws", "--unknown", "value", "doc"},
+ } {
+ old := os.Args
+ os.Args = args
+ _ = currentCommandPath()
+ os.Args = old
+ }
+ for _, inv := range []executor.Invocation{
+ {LegacyPath: "legacy path"},
+ {CanonicalProduct: "doc", Tool: "get"},
+ {CanonicalProduct: "doc"},
+ {},
+ } {
+ old := os.Args
+ os.Args = []string{"dws"}
+ _ = runtimeCommandPath(inv)
+ os.Args = old
+ }
+ if cloneRecoveryArgs(nil) != nil {
+ t.Fatal("empty recovery args should clone to nil")
+ }
+ original := map[string]any{"x": 1}
+ clone := cloneRecoveryArgs(original)
+ clone["x"] = 2
+ if original["x"] != 1 {
+ t.Fatal("recovery args were not cloned")
+ }
+
+ if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
+ t.Fatalf("nil recovery search = %#v", got)
+ }
+ if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
+ t.Fatalf("blank recovery search = %#v", got)
+ }
+ if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
+ t.Fatal("nil recovery runtime call succeeded")
+ }
+ if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil {
+ t.Fatal("missing recovery endpoint succeeded")
+ }
+ loaderErr := errors.New("catalog")
+ runtime := &recoveryRuntime{loader: cli.CatalogLoaderFrom(cli.Catalog{}, loaderErr)}
+ if _, err := runtime.resolveEndpoint(context.Background(), "missing", "tool"); !errors.Is(err, loaderErr) {
+ t.Fatalf("catalog recovery error = %v", err)
+ }
+ runtime.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{{ID: "empty"}, {ID: "ok", Endpoint: " https://catalog.test "}}}}
+ if _, err := runtime.resolveEndpoint(context.Background(), "empty", "tool"); err == nil {
+ t.Fatal("empty catalog endpoint succeeded")
+ }
+ if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
+ t.Fatalf("catalog endpoint = %q %v", got, err)
+ }
+ if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
+ t.Fatal("recovery token mismatch")
+ }
+ if toRecoveryToolResponse(nil) != nil {
+ t.Fatal("nil recovery response should stay nil")
+ }
+ response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
+ if response == nil || !response.IsError || len(response.Content) != 1 {
+ t.Fatalf("recovery response = %#v", response)
+ }
+
+ items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
+ for _, payload := range []map[string]any{
+ nil,
+ {"items": items},
+ {"data": map[string]any{"items": items}},
+ {"result": map[string]any{"items": items}},
+ } {
+ _ = parseDocSearchItemsFromMap(payload)
+ }
+ if toDocSearchItems("bad") != nil {
+ t.Fatal("non-list doc items accepted")
+ }
+ result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
+ if got := parseDocSearchItems(result); len(got) != 1 {
+ t.Fatalf("block doc items = %#v", got)
+ }
+ if parseDocSearchItems(nil) != nil {
+ t.Fatal("nil doc result should be nil")
+ }
+ searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
+ if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
+ t.Fatal("doc search reranking mismatch")
+ }
+}
+
+func TestSmallAppRegistryAndRootCoverage(t *testing.T) {
+ RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
+ t.Cleanup(func() {
+ pluginAuthMu.Lock()
+ delete(pluginAuthRegistry, "coverage-registry")
+ pluginAuthMu.Unlock()
+ })
+ if got, ok := LookupPluginAuth("coverage-registry"); !ok || got.Token != "token" {
+ t.Fatalf("plugin auth = %#v %v", got, ok)
+ }
+ if _, ok := LookupPluginAuth("missing"); ok {
+ t.Fatal("missing plugin auth found")
+ }
+ configureOAuthProviderCompatibility(authpkg.NewOAuthProvider(t.TempDir(), nil), t.TempDir())
+ configureLegacyAuthManagerCompatibility(authpkg.NewManager(t.TempDir(), nil))
+ if IsAuthRetrying(nil) || !IsAuthRetrying(context.WithValue(context.Background(), authRetryingKey, true)) {
+ t.Fatal("auth retry context mismatch")
+ }
+ if IsAuthRetrying(context.WithValue(context.Background(), authRetryingKey, "bad")) {
+ t.Fatal("invalid auth retry value accepted")
+ }
+
+ oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
+ t.Cleanup(func() { version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit })
+ version, buildTime, gitCommit = "dev", "unknown", "unknown"
+ SetVersion("", "", "")
+ if Version() != "dev" {
+ t.Fatal("plain version mismatch")
+ }
+ SetVersion("1.2.3", "today", "abc")
+ if RawVersion() != "1.2.3" || BuildTime() != "today" || GitCommit() != "abc" || !strings.Contains(Version(), "abc") {
+ t.Fatal("version metadata mismatch")
+ }
+
+ for _, err := range []error{nil, errors.New("plain"), errors.New(`required flag(s) "email", "name" not set`), errors.New("required flag(s) not set")} {
+ _ = rewordRequiredFlagError(err)
+ _ = isUnknownCommandError(err)
+ }
+ if !isUnknownCommandError(errors.New("unknown command x")) {
+ t.Fatal("unknown command not recognized")
+ }
+ if resolveVerbosity(nil) != apperrors.VerbosityNormal {
+ t.Fatal("nil verbosity mismatch")
+ }
+ cmd := &cobra.Command{Use: "cmd"}
+ cmd.Flags().Bool("debug", false, "")
+ cmd.Flags().Bool("verbose", false, "")
+ _ = cmd.Flags().Set("verbose", "true")
+ if resolveVerbosity(cmd) != apperrors.VerbosityVerbose {
+ t.Fatal("verbose level mismatch")
+ }
+ _ = cmd.Flags().Set("debug", "true")
+ if resolveVerbosity(cmd) != apperrors.VerbosityDebug {
+ t.Fatal("debug level mismatch")
+ }
+ if wantsJSONErrors(nil) || commandRequestsJSONErrors(nil) {
+ t.Fatal("nil command requested JSON")
+ }
+ jsonCmd := &cobra.Command{Use: "root"}
+ jsonCmd.PersistentFlags().String("format", "table", "")
+ child := &cobra.Command{Use: "child"}
+ jsonCmd.AddCommand(child)
+ _ = jsonCmd.PersistentFlags().Set("format", "json")
+ if !wantsJSONErrors(child) || !commandRequestsJSONErrors(jsonCmd) {
+ t.Fatal("JSON format was not recognized")
+ }
+ jsonBool := &cobra.Command{Use: "root"}
+ jsonBool.Flags().Bool("json", false, "")
+ _ = jsonBool.Flags().Set("json", "true")
+ if !commandRequestsJSONErrors(jsonBool) {
+ t.Fatal("JSON boolean was not recognized")
+ }
+ var human, raw bytes.Buffer
+ if err := printExecutionError(nil, &human, &human, errors.New("plain")); err != nil {
+ t.Fatal(err)
+ }
+ rawErr := coverageRawStderr("raw line")
+ if err := printExecutionError(nil, &raw, &raw, rawErr); err != nil || !strings.Contains(raw.String(), "raw line") {
+ t.Fatalf("raw stderr = %q %v", raw.String(), err)
+ }
+ if err := printExecutionError(jsonBool, io.Discard, io.Discard, errors.New("json")); err != nil {
+ t.Fatal(err)
+ }
+
+ _ = MCPIdentityHeaders()
+ if noCredentialsError() == nil {
+ t.Fatal("standalone credentials error missing")
+ }
+ oldEdition := edition.Get()
+ defer edition.Override(oldEdition)
+ edition.Override(&edition.Hooks{IsEmbedded: true})
+ if !strings.Contains(noCredentialsError().Error(), "认证") {
+ t.Fatal("embedded credentials error mismatch")
+ }
+}
+
+func TestDirectRuntimeCoverage(t *testing.T) {
+ oldEdition := edition.Get()
+ t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
+ server := mcptypes.ServerDescriptor{
+ Endpoint: "https://one.test",
+ CLI: mcptypes.CLIOverlay{
+ ID: "one",
+ Command: "cmd",
+ Aliases: []string{"alias", " "},
+ Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
+ ToolOverrides: map[string]mcptypes.CLIToolOverride{
+ "override": {}, "skip": {ServerOverride: "other"}, " ": {},
+ },
+ },
+ }
+ SetDynamicServers([]mcptypes.ServerDescriptor{{CLI: mcptypes.CLIOverlay{Skip: true}}, server, {CLI: mcptypes.CLIOverlay{ID: "empty"}}})
+ if got, ok := directRuntimeToolEndpoint("tool"); !ok || got != "https://one.test" {
+ t.Fatalf("tool endpoint = %q %v", got, ok)
+ }
+ if _, ok := directRuntimeToolEndpoint(" "); ok {
+ t.Fatal("blank tool endpoint resolved")
+ }
+ for _, id := range []string{"one", "cmd", "alias"} {
+ if got, ok := directRuntimeEndpoint(id, ""); !ok || got != "https://one.test" {
+ t.Errorf("direct endpoint %q = %q %v", id, got, ok)
+ }
+ }
+ if normalizeDirectRuntimeProductID("alias") != "one" || normalizeDirectRuntimeProductID("tb") != "teambition" || normalizeDirectRuntimeProductID("plain") != "plain" {
+ t.Fatal("direct runtime alias mismatch")
+ }
+ if ids := DirectRuntimeProductIDs(); !ids["one"] || !ids[defaultPATProductID] || !ids[devappProductID] {
+ t.Fatalf("direct runtime IDs = %#v", ids)
+ }
+
+ t.Setenv(cli.CatalogFixtureEnv, "fixture")
+ if shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) {
+ t.Fatal("fixture should disable direct runtime")
+ }
+ t.Setenv(cli.CatalogFixtureEnv, "")
+ if !shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) || !shouldUseDirectRuntime(executor.Invocation{Kind: "compat_invocation"}) || shouldUseDirectRuntime(executor.Invocation{}) {
+ t.Fatal("direct runtime kind mismatch")
+ }
+
+ edition.Override(&edition.Hooks{
+ StaticServers: func() []edition.ServerInfo {
+ return []edition.ServerInfo{{ID: "", Endpoint: ""}, {ID: "static", Endpoint: " https://static.test "}}
+ },
+ SupplementServers: func() []edition.ServerInfo {
+ return []edition.ServerInfo{{ID: "supplement", Endpoint: "https://supp.test", Prefixes: []string{"prefix"}}}
+ },
+ })
+ for id, want := range map[string]string{"static": "https://static.test", "supplement": "https://supp.test", "prefix": "https://supp.test"} {
+ if got, ok := editionServerEndpoint(id); !ok || got != want {
+ t.Errorf("edition endpoint %q = %q %v", id, got, ok)
+ }
+ }
+ if _, ok := editionServerEndpoint(""); ok {
+ t.Fatal("blank edition server resolved")
+ }
+ if _, ok := endpointFromEditionServers("x", nil); ok {
+ t.Fatal("nil edition server function resolved")
+ }
+
+ SetDynamicServers(nil)
+ AppendDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}})
+ AppendDynamicServer(server)
+ AppendDynamicServer(mcptypes.ServerDescriptor{Endpoint: "https://two.test", CLI: mcptypes.CLIOverlay{ID: "two", Command: "cmd", Tools: []mcptypes.CLITool{{Name: "tool2"}}}})
+ if got, ok := directRuntimeEndpoint("cmd", ""); !ok || got != "https://one.test" {
+ t.Fatalf("append preserved command endpoint = %q %v", got, ok)
+ }
+ if got, ok := directRuntimeEndpoint("unknown", "tool2"); !ok || got != "https://two.test" {
+ t.Fatalf("tool fallback endpoint = %q %v", got, ok)
+ }
+ if _, ok := directRuntimeEndpoint("missing", "missing"); ok {
+ t.Fatal("missing direct endpoint resolved")
+ }
+ _ = devappMCPEndpoint()
+ _ = defaultPATServerDescriptor()
+ _ = defaultPATMCPEndpoint()
+}
+
+func TestRecoveryLoadExecutionCoverage(t *testing.T) {
+ if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("missing recovery execution succeeded")
+ }
+ path := filepath.Join(t.TempDir(), "execution.json")
+ if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadRecoveryExecution(path); err == nil {
+ t.Fatal("malformed recovery execution succeeded")
+ }
+ for name, body := range map[string]string{
+ "legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
+ "modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
+ t.Fatalf("loaded execution = %#v %v", got, err)
+ }
+ })
+ }
+}
+
+func TestRecoveryRuntimeHTTP(t *testing.T) {
+ var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ID int `json:"id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
+ }))
+ defer server.Close()
+ SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
+ t.Cleanup(func() { SetDynamicServers(nil) })
+ runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())}
+ got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
+ if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
+ t.Fatalf("recovery search = %#v %v", got, err)
+ }
+ result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
+ if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
+ t.Fatal("recovery MCP error succeeded")
+ }
+ server.Close()
+ if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
+ t.Fatal("recovery network error succeeded")
+ }
+}
+
+func TestEventCommandPureCoverage(t *testing.T) {
+ oldEdition := edition.Get()
+ t.Cleanup(func() { edition.Override(oldEdition) })
+ edition.Override(&edition.Hooks{})
+ if editionNameOrDefault() != "open" {
+ t.Fatal("default edition name changed")
+ }
+ edition.Override(&edition.Hooks{Name: "enterprise"})
+ if editionNameOrDefault() != "enterprise" {
+ t.Fatal("edition name not used")
+ }
+
+ for _, tc := range []struct {
+ value string
+ want string
+ err bool
+ }{{"", "user", false}, {" USER ", "user", false}, {"app", "", true}, {"bot", "", true}, {"bad", "", true}} {
+ got, err := normalizeEventAs(tc.value)
+ if got != tc.want || (err != nil) != tc.err {
+ t.Errorf("normalizeEventAs(%q) = %q, %v", tc.value, got, err)
+ }
+ }
+ if firstArg(nil) != "" || firstArg([]string{"one"}) != "one" {
+ t.Fatal("firstArg mismatch")
+ }
+ if got := eventTypesWithDefault([]string{"one"}); len(got) != 1 || got[0] != "one" {
+ t.Fatalf("explicit event types = %#v", got)
+ }
+ _ = eventTypesWithDefault(nil)
+ if sourceKindLabel("") != string(dwsevent.SourceKindAppStream) || sourceKindLabel(dwsevent.SourceKindPersonalStream) != string(dwsevent.SourceKindPersonalStream) {
+ t.Fatal("source kind labels mismatch")
+ }
+ if got := eventWorkDir("/tmp/config", "open", "", "hash"); !strings.Contains(got, "app_stream") {
+ t.Fatalf("default event workdir = %q", got)
+ }
+ if got := eventWorkDir("/tmp/config", "open", dwsevent.SourceKindPersonalStream, "hash"); !strings.Contains(got, "personal_stream") {
+ t.Fatalf("personal event workdir = %q", got)
+ }
+ _ = defaultIPCEndpoint(t.TempDir(), "open", dwsevent.SourceKindAppStream, "hash")
+
+ for _, opts := range []eventStreamTicketOptions{
+ {},
+ {Mode: " NORMAL ", SourceID: " source ", TicketURL: " https://ticket.test "},
+ {Mode: "websocket"},
+ } {
+ _ = opts.enabled()
+ _ = opts.normalizedMode()
+ _ = opts.usesPortalNormalMode()
+ _ = opts.spawnArgs()
+ }
+ if got := eventStreamTicketURL(" explicit "); got != "explicit" {
+ t.Fatalf("explicit ticket URL = %q", got)
+ }
+ if got := eventStreamTicketURL(""); !strings.HasSuffix(got, "/stream/connections/ticket") {
+ t.Fatalf("default ticket URL = %q", got)
+ }
+ t.Setenv("DWS_STREAM_SOURCE_ID", " env-source ")
+ if defaultEventStreamSourceID() != "env-source" || eventStreamSourceID("") != "env-source" || eventStreamSourceID("direct") != "direct" {
+ t.Fatal("event source ID precedence mismatch")
+ }
+ t.Setenv("DWS_STREAM_SOURCE_ID", "")
+ _ = defaultEventStreamSourceID()
+ if clientID, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{Mode: "normal", SourceID: "source"}); err != nil || secret != "" || !strings.HasPrefix(clientID, "portal-ticket-normal:") {
+ t.Fatalf("portal event credentials = %q %q %v", clientID, secret, err)
+ }
+ if got := eventStreamBusID(eventStreamTicketOptions{SourceID: "source"}); got != "portal-ticket-normal:source" {
+ t.Fatalf("event bus ID = %q", got)
+ }
+}
+
+func TestEventListAndStatusCoverage(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ cmd := &cobra.Command{Use: "event"}
+ entries, err := collectEntries(cmd, "client", false, false)
+ if err != nil || len(entries) != 1 || entries[0].Entry.State != busctl.BusStateNotRunning || entries[0].Entry.Meta == nil {
+ t.Fatalf("single event entry = %#v %v", entries, err)
+ }
+ if all, err := collectEntries(cmd, "", true, false); err != nil || len(all) != 0 {
+ t.Fatalf("all event entries = %#v %v", all, err)
+ }
+ if all, err := collectEntries(cmd, "", false, true); err != nil || len(all) != 0 {
+ t.Fatalf("all-edition event entries = %#v %v", all, err)
+ }
+ if got := queryAll([]busctl.BusEntry{{State: busctl.BusStateNotRunning}}); len(got) != 1 {
+ t.Fatalf("queryAll = %#v", got)
+ }
+
+ now := time.Now().Add(-time.Minute)
+ live := &eventtransport.StatusResp{
+ Bus: eventtransport.StatusBus{UptimeSecs: 60},
+ SourceState: eventtransport.StatusSource{State: "connected", Source: "hook", ReconnectCount: 2},
+ Consumers: []eventtransport.StatusConsumer{
+ {PID: 2, Received: 3, Dropped: 1},
+ {PID: 3, EventTypes: []string{"event.one"}, SubscribeID: "sub", Received: 4},
+ },
+ PerEventTypeCounters: map[string]eventtransport.Counters{"z": {Received: 1}, "a": {Dropped: 2}},
+ }
+ statuses := []busctl.EntryStatus{
+ {Entry: busctl.BusEntry{ClientIDHash: "hash", Edition: "open", State: busctl.BusStateNotRunning, WorkDir: dir}},
+ {Entry: busctl.BusEntry{ClientIDHash: "hash", Edition: "open", State: busctl.BusStateOrphan, HolderPID: 1, WorkDir: dir, Meta: &eventbus.Meta{ClientID: "client", SourceID: "source", StartedAt: now}}},
+ {Entry: busctl.BusEntry{ClientIDHash: "hash", Edition: "open", State: busctl.BusStateRunning, HolderPID: 2, WorkDir: dir, Meta: &eventbus.Meta{ClientID: "client", StartedAt: now}}},
+ {Entry: busctl.BusEntry{ClientIDHash: "hash", Edition: "open", State: busctl.BusStateRunning, HolderPID: 3, WorkDir: dir}, Live: live},
+ }
+ var output bytes.Buffer
+ if err := renderStatus(&output, statuses, "text"); err != nil || !strings.Contains(output.String(), "orphan") || !strings.Contains(output.String(), "Consumers") {
+ t.Fatalf("status text = %q %v", output.String(), err)
+ }
+ output.Reset()
+ if err := renderStatus(&output, statuses, "json"); err != nil || !strings.Contains(output.String(), "not_running") {
+ t.Fatalf("status JSON = %q %v", output.String(), err)
+ }
+
+ list := make([]listEntry, 0, len(statuses))
+ for _, status := range statuses {
+ list = append(list, buildListEntry(status))
+ }
+ list = append(list, listEntry{ClientIDHash: "unknown", BusState: busctl.BusStateRunning, Consumers: live.Consumers})
+ output.Reset()
+ if err := renderList(&output, list, "table"); err != nil || !strings.Contains(output.String(), "catch-all") || !strings.Contains(output.String(), "unknown") {
+ t.Fatalf("list table = %q %v", output.String(), err)
+ }
+ output.Reset()
+ if err := renderList(&output, list, "json"); err != nil || !strings.Contains(output.String(), "client_id_hash") {
+ t.Fatalf("list JSON = %q %v", output.String(), err)
+ }
+}
+
+func TestEventCommandValidationCoverage(t *testing.T) {
+ execute := func(t *testing.T, cmd *cobra.Command, args ...string) error {
+ t.Helper()
+ cmd.SilenceErrors = true
+ cmd.SilenceUsage = true
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ return cmd.Execute()
+ }
+ if err := execute(t, newEventCommand()); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"--as", "bad"}, {"--as", "app", "--debug-raw-events"}, {"--as", "app", "--subscribe-id", "x"}, {"--as", "app", "key"}} {
+ if err := execute(t, newEventConsumeCommand(), args...); err == nil {
+ t.Errorf("consume args %#v unexpectedly succeeded", args)
+ }
+ }
+ for _, maker := range []func() *cobra.Command{newEventListCommand, newEventStatusCommand, newEventStopCommand} {
+ if err := execute(t, maker(), "--as", "bad"); err == nil {
+ t.Fatal("invalid event identity succeeded")
+ }
+ }
+
+ flagCmd := &cobra.Command{Use: "flags"}
+ flagCmd.Flags().String("one", "", "")
+ flagCmd.Flags().String("two", "", "")
+ hideEventInternalFlags(flagCmd, "one", "missing")
+ if err := rejectChangedFlags(flagCmd, "user", "one", "missing"); err != nil {
+ t.Fatalf("unchanged flags rejected: %v", err)
+ }
+ _ = flagCmd.Flags().Set("one", "value")
+ if err := rejectChangedFlags(flagCmd, "user", "one", "missing"); err == nil {
+ t.Fatal("changed app flag accepted")
+ }
+ if err := rejectPersonalEventUnsupportedFlags(flagCmd, "one", "missing"); err == nil {
+ t.Fatal("changed personal flag accepted")
+ }
+}
+
+func TestVersionCacheCompletionCoverage(t *testing.T) {
+ oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
+ t.Cleanup(func() { version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit })
+ version, buildTime, gitCommit = "1.0", "unknown", "unknown"
+ for _, args := range [][]string{nil, {"--format", "json"}} {
+ cmd := newVersionCommand()
+ cmd.Flags().String("format", "table", "")
+ var output bytes.Buffer
+ cmd.SetOut(&output)
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil || output.Len() == 0 {
+ t.Fatalf("version %#v = %q %v", args, output.String(), err)
+ }
+ }
+ version, buildTime, gitCommit = "1.0", "today", "abc"
+ cmd := newVersionCommand()
+ cmd.SetOut(io.Discard)
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().String("format", "json", "")
+ for _, format := range []string{"json", "pretty", "table"} {
+ _ = root.PersistentFlags().Set("format", format)
+ child := &cobra.Command{Use: "child"}
+ root.AddCommand(child)
+ child.SetOut(io.Discard)
+ if err := printCacheCompatNotice(child, "status"); err != nil {
+ t.Fatalf("cache %s: %v", format, err)
+ }
+ root.RemoveCommand(child)
+ }
+ cache := newCacheCommand()
+ cache.SetOut(io.Discard)
+ cache.SetArgs([]string{"status"})
+ if err := cache.Execute(); err != nil {
+ t.Fatal(err)
+ }
+
+ completionRoot := &cobra.Command{Use: "dws"}
+ completionRoot.AddCommand(&cobra.Command{Use: "child"})
+ for _, shell := range []string{"bash", "zsh", "fish"} {
+ completion := newCompletionCommand(completionRoot)
+ completion.SetOut(io.Discard)
+ completion.SetArgs([]string{shell})
+ if err := completion.Execute(); err != nil {
+ t.Fatalf("completion %s: %v", shell, err)
+ }
+ }
+}
+
+func TestRuntimeRunnerRoutingCoverage(t *testing.T) {
+ inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool", Params: map[string]any{"x": 1}}
+ fallback := &coverageRunner{result: executor.Result{Response: map[string]any{"fallback": true}}}
+ r := &runtimeRunner{fallback: fallback}
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true {
+ t.Fatalf("fallback runner = %#v %v", got, err)
+ }
+
+ r.transport = transport.NewClient(nil)
+ r.loader = cli.StaticLoader{}
+ r.globalFlags = &GlobalFlags{Mock: true}
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["content"] == nil {
+ t.Fatalf("mock route = %#v %v", got, err)
+ }
+ t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test")
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://override.test" {
+ t.Fatalf("mock override route = %#v %v", got, err)
+ }
+ t.Setenv("DINGTALK_PRODUCT_MCP_URL", "")
+
+ r.globalFlags = &GlobalFlags{DryRun: true}
+ r.loader = cli.CatalogLoaderFrom(cli.Catalog{}, errors.New("load failure"))
+ if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "load failure") {
+ t.Fatalf("catalog failure = %v", err)
+ }
+ r.loader = cli.StaticLoader{}
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
+ t.Fatalf("dry catalog miss = %#v %v (invocation %#v)", got, err, fallback.last)
+ }
+ r.globalFlags = &GlobalFlags{}
+ if _, err := r.runSingle(context.Background(), inv, false); err == nil {
+ t.Fatal("catalog miss succeeded")
+ }
+ devInv := inv
+ devInv.CanonicalProduct = devappProductID
+ if _, err := r.handleCatalogMiss(context.Background(), devInv, "missing"); err == nil {
+ t.Fatal("devapp catalog miss succeeded")
+ }
+
+ product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
+ r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
+ r.globalFlags = &GlobalFlags{DryRun: true}
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://catalog.test" {
+ t.Fatalf("catalog dry route = %#v %v", got, err)
+ }
+ product.Tools = nil
+ r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
+ SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://direct.test", CLI: mcptypes.CLIOverlay{ID: "product", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
+ t.Cleanup(func() { SetDynamicServers(nil) })
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://direct.test" {
+ t.Fatalf("undeclared tool direct route = %#v %v", got, err)
+ }
+ SetDynamicServers(nil)
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
+ t.Fatalf("undeclared tool dry miss = %#v %v (invocation %#v)", got, err, fallback.last)
+ }
+
+ SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://owner.test", CLI: mcptypes.CLIOverlay{ID: "owner", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
+ product.Tools = []cli.ToolDescriptor{{RPCName: "tool"}}
+ r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
+ if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://owner.test" {
+ t.Fatalf("tool owner correction = %#v %v", got, err)
+ }
+
+ for _, result := range []executor.Result{{}, {Response: map[string]any{"content": "value"}}, {Response: map[string]any{"value": 1}}} {
+ _ = multiProfileResultPayload(result)
+ }
+ for _, err := range []error{errors.New("plain"), apperrors.NewAPI("api", apperrors.WithReason("reason"), apperrors.WithOperation("op"))} {
+ payload := multiProfileErrorPayload(err)
+ if payload["message"] == "" {
+ t.Fatalf("multi-profile error payload = %#v", payload)
+ }
+ }
+}
+
+func TestExecuteInvocationCoverage(t *testing.T) {
+ oldEdition := edition.Get()
+ t.Cleanup(func() { edition.Override(oldEdition) })
+ pluginAuthMu.Lock()
+ oldRegistry := pluginAuthRegistry
+ pluginAuthRegistry = make(map[string]*PluginAuth)
+ pluginAuthMu.Unlock()
+ t.Cleanup(func() {
+ pluginAuthMu.Lock()
+ pluginAuthRegistry = oldRegistry
+ pluginAuthMu.Unlock()
+ })
+ var rpcResult any = map[string]any{
+ "content": []map[string]any{{"type": "text", "text": "ok"}},
+ "structuredContent": map[string]any{"value": 1},
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var request struct {
+ ID int `json:"id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&request)
+ _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": rpcResult})
+ }))
+ defer server.Close()
+ r := &runtimeRunner{
+ transport: transport.NewClient(server.Client()),
+ globalFlags: &GlobalFlags{},
+ fallback: &coverageRunner{},
+ }
+ inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool", Params: map[string]any{"x": 1}}
+
+ inv.DryRun = true
+ if got, err := r.executeInvocation(context.Background(), server.URL, inv); err != nil || got.Response["dry_run"] != true {
+ t.Fatalf("HTTP dry-run = %#v %v", got, err)
+ }
+ inv.Params = map[string]any{"bad": func() {}}
+ if _, err := r.executeInvocation(context.Background(), server.URL, inv); err != nil {
+ t.Fatalf("unmarshalable dry-run = %v", err)
+ }
+ inv.Params = map[string]any{"x": 1}
+ inv.DryRun = false
+ r.globalFlags.Mock = true
+ if got, err := r.executeInvocation(context.Background(), server.URL, inv); err != nil || got.Response["content"] == nil {
+ t.Fatalf("HTTP mock = %#v %v", got, err)
+ }
+ r.globalFlags.Mock = false
+ if _, err := r.executeInvocation(context.Background(), server.URL, inv); err == nil || !isAuthError(err) {
+ t.Fatalf("unauthenticated execution = %v", err)
+ }
+
+ pluginAuthMu.Lock()
+ pluginAuthRegistry = map[string]*PluginAuth{
+ "product": {Token: "plugin-token", ExtraHeaders: map[string]string{"X-Test": "yes"}, TrustedDomains: []string{strings.TrimPrefix(server.URL, "http://")}},
+ }
+ pluginAuthMu.Unlock()
+ r.scanner = coverageScanner{report: safety.Report{Scanned: true}}
+ r.includeScanReport = true
+ if got, err := r.executeInvocation(context.Background(), server.URL, inv); err != nil || got.Response["safety"] == nil || !got.Invocation.Implemented {
+ t.Fatalf("successful execution = %#v %v", got, err)
+ }
+
+ rpcResult = map[string]any{"structuredContent": map[string]any{"success": false, "errorMsg": "business failed"}}
+ if _, err := r.executeInvocation(context.Background(), server.URL, inv); err == nil || !strings.Contains(err.Error(), "business failed") {
+ t.Fatalf("business error = %v", err)
+ }
+ rpcResult = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "tool failed"}}, "structuredContent": map[string]any{"traceId": "trace"}}
+ if _, err := r.executeInvocation(context.Background(), server.URL, inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
+ t.Fatalf("MCP tool error = %v", err)
+ }
+
+ hookErr := errors.New("edition classification")
+ edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return hookErr }})
+ rpcResult = map[string]any{"structuredContent": map[string]any{"value": 1}}
+ if _, err := r.executeInvocation(context.Background(), server.URL, inv); !errors.Is(err, hookErr) {
+ t.Fatalf("edition classification = %v", err)
+ }
+ edition.Override(&edition.Hooks{})
+
+ r.globalFlags.Timeout = 1
+ server.Close()
+ if _, err := r.executeInvocation(context.Background(), server.URL, inv); err == nil {
+ t.Fatal("network failure succeeded")
+ }
+
+ stdioInv := inv
+ stdioInv.DryRun = true
+ if got, err := r.executeInvocation(context.Background(), "stdio://plugin/server", stdioInv); err != nil || got.Response["transport"] != "stdio" {
+ t.Fatalf("stdio dry-run = %#v %v", got, err)
+ }
+ stdioInv.DryRun = false
+ if _, err := r.executeStdioInvocation(context.Background(), stdioInv); err == nil {
+ t.Fatal("missing stdio client succeeded")
+ }
+}
+
+func TestAuthCommandPureCoverage(t *testing.T) {
+ oldEdition := edition.Get()
+ oldPrompt := authLoginManualCredentialsPrompt
+ oldInteractive := authLoginInteractiveTerminal
+ t.Cleanup(func() {
+ edition.Override(oldEdition)
+ authLoginManualCredentialsPrompt = oldPrompt
+ authLoginInteractiveTerminal = oldInteractive
+ })
+ clearCompatCache()
+ _ = authLoginHuhTheme()
+ for _, value := range []time.Time{time.Time{}, time.Now().Add(-time.Hour), time.Now().Add(2 * time.Hour), time.Now().Add(48 * time.Hour)} {
+ _ = timeOrEmpty(value)
+ _ = authLoginFormatExpiry(value)
+ }
+ for _, data := range []*authpkg.TokenData{
+ nil,
+ {},
+ {ExpiresAt: time.Now().Add(time.Hour)},
+ {ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour)},
+ } {
+ _ = authLoginDisplayExpiry(data)
+ _ = authStatusAuthenticated(data)
+ _ = authStatusUpdatedAt(data)
+ }
+ for _, plan := range []*pat.LoginRecommendPlan{nil, {}, {AllGranted: true}, {Scopes: []string{"one"}}} {
+ _ = authLoginRecommendPlanSkipsInteractiveAuthorization(plan)
+ }
+ for _, product := range []pat.LoginRecommendProduct{
+ {ProductCode: "doc"},
+ {ProductCode: "doc", ProductName: "doc"},
+ {ProductCode: "doc", ProductName: "Document", Summary: strings.Repeat("说明", 30)},
+ } {
+ _ = loginRecommendProductLabel(product)
+ }
+ for _, limit := range []int{-1, 2, 20} {
+ _ = clipRunes("abcdef", limit)
+ }
+ _ = authLoginStatusLine("ok")
+ _ = authLoginInfoLine("key", "value")
+ _ = authLoginMutedStyle()
+
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("format", "table", "")
+ child := &cobra.Command{Use: "child"}
+ root.AddCommand(child)
+ if authLoginAllowsInteractiveDefault(nil, "table") || !authLoginAllowsInteractiveDefault(child, "table") {
+ t.Fatal("interactive auth default mismatch")
+ }
+ _ = root.PersistentFlags().Set("format", "json")
+ if authLoginAllowsInteractiveDefault(child, "json") {
+ t.Fatal("explicit JSON unexpectedly allowed interactive auth")
+ }
+ for _, interactive := range []bool{false, true} {
+ for _, recommend := range []bool{false, true} {
+ _ = authLoginShouldUsePostLoginTUIModeForTerminal(child, "table", recommend, interactive)
+ _ = authLoginShouldShowPostLoginTUIForTerminal(child, "table", recommend, interactive)
+ _ = authLoginShouldUseHumanAuthorizationModeForTerminal(child, "table", recommend, interactive)
+ }
+ }
+
+ for _, err := range []error{nil, errors.New("plain"), keychain.ErrDEKMissing, keychain.NewUnavailableError("read", errors.New("locked"))} {
+ _ = authStatusDiagnosticFromError(err)
+ }
+ var output bytes.Buffer
+ valid := &authpkg.TokenData{
+ AccessToken: "a", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour), RefreshExpAt: time.Now().Add(2 * time.Hour),
+ CorpID: "corp", CorpName: "Corp", UserID: "user", UserName: "Name",
+ }
+ for _, tc := range []struct {
+ authenticated bool
+ refreshed bool
+ data *authpkg.TokenData
+ diagnostic *authStatusDiagnostic
+ }{{false, false, nil, nil}, {false, false, nil, &authStatusDiagnostic{Reason: "reason", Message: "message", Hint: "hint"}}, {true, true, valid, nil}} {
+ output.Reset()
+ if err := writeAuthStatusJSON(&output, tc.authenticated, tc.refreshed, tc.data, tc.diagnostic); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, tc := range []struct {
+ data *authpkg.TokenData
+ forced bool
+ }{{nil, true}, {valid, true}, {valid, false}} {
+ output.Reset()
+ if err := writeAuthLoginJSON(&output, tc.data, tc.forced); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ for _, data := range [][]byte{
+ []byte("{"),
+ []byte(`{"result":[]}`),
+ []byte(`{"result":[{"orgEmployeeModel":{}}]}`),
+ []byte(`{"result":[{"orgEmployeeModel":{"corpId":"corp","orgName":"Corp","userid":"user","name":"Name"}}]}`),
+ } {
+ _, _ = contactProfileIdentityFromJSON(data)
+ }
+ for _, result := range []*edition.ToolResult{
+ nil,
+ {},
+ {Content: []edition.ContentBlock{{Text: " "}, {Text: `{"result":[{"orgEmployeeModel":{"corpId":"corp"}}]}`}}},
+ } {
+ _, _ = contactProfileIdentityFromToolResult(result)
+ }
+ if firstNonEmptyString(" ", " value ") != "value" || firstNonEmptyString(" ") != "" {
+ t.Fatal("first non-empty string mismatch")
+ }
+
+ cmd := &cobra.Command{Use: "auth"}
+ cmd.SetErr(io.Discard)
+ authLoginManualCredentialsPrompt = func() (string, string, error) { return "client", "secret", nil }
+ for _, action := range []authLoginGuideAction{authLoginGuideDirectCLI, authLoginGuideConfigureAgentApp, authLoginGuideManualCredentials, "unknown"} {
+ err := applyAuthLoginGuideAction(cmd, t.TempDir(), action)
+ if action == "unknown" && err == nil {
+ t.Fatal("unknown auth guide action succeeded")
+ }
+ }
+ authLoginManualCredentialsPrompt = func() (string, string, error) { return "", "", errors.New("prompt") }
+ if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
+ t.Fatal("manual credential prompt error succeeded")
+ }
+
+ inputPath := filepath.Join(t.TempDir(), "empty")
+ if err := os.WriteFile(inputPath, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ input, err := os.Open(inputPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldStdin := os.Stdin
+ os.Stdin = input
+ t.Cleanup(func() { os.Stdin = oldStdin; _ = input.Close() })
+ _, _ = selectAuthLoginGuideAction()
+ _, _, _ = promptAuthLoginManualCredentials()
+ _, _ = selectLoginRecommendScopeMode()
+ if selected, err := selectLoginRecommendProducts(nil); err != nil || selected != nil {
+ t.Fatalf("empty recommended products = %#v %v", selected, err)
+ }
+ if selected, err := selectLoginRecommendProducts([]pat.LoginRecommendProduct{{ProductName: "missing-code"}}); err != nil || selected != nil {
+ t.Fatalf("invalid recommended products = %#v %v", selected, err)
+ }
+ _, _ = selectLoginRecommendProducts([]pat.LoginRecommendProduct{{ProductCode: "doc", ProductName: "Document"}})
+}
+
+func TestAuthLoginTokenCommandCoverage(t *testing.T) {
+ oldInteractive := authLoginInteractiveTerminal
+ authLoginInteractiveTerminal = func() bool { return false }
+ t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
+ for _, format := range []string{"table", "json"} {
+ t.Run(format, func(t *testing.T) {
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().String("format", "table", "")
+ root.PersistentFlags().Bool("yes", false, "")
+ root.PersistentFlags().String("profile", "", "")
+ cmd := newAuthLoginCommand(nil)
+ root.AddCommand(cmd)
+ var output bytes.Buffer
+ root.SetOut(&output)
+ root.SetErr(io.Discard)
+ args := []string{"login", "--token", "manual-token", "--yes"}
+ if format == "json" {
+ args = append(args, "--format", "json")
+ }
+ root.SetArgs(args)
+ if err := root.Execute(); err != nil || output.Len() == 0 {
+ t.Fatalf("token login = %q %v", output.String(), err)
+ }
+ })
+ }
+
+ for _, hidden := range []bool{false, true} {
+ old := edition.Get()
+ edition.Override(&edition.Hooks{HideAuthLogin: hidden})
+ cmd := buildAuthCommand(nil)
+ found, _, err := cmd.Find([]string{"login"})
+ hasLogin := err == nil && found != nil && found.Name() == "login"
+ if hasLogin == hidden {
+ t.Fatalf("HideAuthLogin=%v command tree mismatch", hidden)
+ }
+ edition.Override(old)
+ }
+}
+
+func TestPluginCommandLifecycleCoverage(t *testing.T) {
+ home := t.TempDir()
+ work := t.TempDir()
+ t.Setenv("HOME", home)
+ oldWD, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(work); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
+
+ run := func(t *testing.T, cmd *cobra.Command, args ...string) (string, error) {
+ t.Helper()
+ var output bytes.Buffer
+ cmd.SetOut(&output)
+ cmd.SetErr(io.Discard)
+ cmd.SilenceErrors = true
+ cmd.SilenceUsage = true
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ return output.String(), err
+ }
+
+ if _, err := run(t, newPluginCreateCommand(), "Bad_Name"); err == nil {
+ t.Fatal("invalid plugin scaffold succeeded")
+ }
+ if output, err := run(t, newPluginCreateCommand(), "demo-plugin", "--description", "Demo"); err != nil || !strings.Contains(output, "Created plugin") {
+ t.Fatalf("plugin create = %q %v", output, err)
+ }
+ if _, err := run(t, newPluginCreateCommand(), "demo-plugin"); err == nil {
+ t.Fatal("duplicate plugin scaffold succeeded")
+ }
+ if _, err := run(t, newPluginValidateCommand(), "missing"); err == nil {
+ t.Fatal("missing plugin validation succeeded")
+ }
+ if output, err := run(t, newPluginValidateCommand(), "demo-plugin"); err != nil || !strings.Contains(output, "Valid") {
+ t.Fatalf("plugin validate = %q %v", output, err)
+ }
+ createdManifest, err := plugin.ParseManifest(filepath.Join("demo-plugin", "plugin.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ createdManifest.Build = nil
+ manifestData, err := json.Marshal(createdManifest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join("demo-plugin", "plugin.json"), manifestData, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := run(t, newPluginInstallCommand()); err == nil {
+ t.Fatal("source-less plugin install succeeded")
+ }
+ if _, err := run(t, newPluginInstallCommand(), "--dir", "missing"); err == nil {
+ t.Fatal("missing plugin install succeeded")
+ }
+ if output, err := run(t, newPluginInstallCommand(), "--dir", "demo-plugin"); err != nil || !strings.Contains(output, "Installed") {
+ t.Fatalf("plugin install = %q %v", output, err)
+ }
+
+ if output, err := run(t, newPluginListCommand()); err != nil || !strings.Contains(output, "demo-plugin") {
+ t.Fatalf("plugin list = %q %v", output, err)
+ }
+ if output, err := run(t, newPluginListCommand(), "--json"); err != nil || !strings.Contains(output, "demo-plugin") {
+ t.Fatalf("plugin list JSON = %q %v", output, err)
+ }
+ if output, err := run(t, newPluginInfoCommand(), "demo-plugin"); err != nil || !strings.Contains(output, "Description") {
+ t.Fatalf("plugin info = %q %v", output, err)
+ }
+ if _, err := run(t, newPluginInfoCommand(), "missing"); err == nil {
+ t.Fatal("missing plugin info succeeded")
+ }
+ if _, err := run(t, newPluginEnableCommand(), "missing"); err == nil {
+ t.Fatal("missing plugin enable succeeded")
+ }
+ if output, err := run(t, newPluginDisableCommand(), "demo-plugin"); err != nil || !strings.Contains(output, "disabled") {
+ t.Fatalf("plugin disable = %q %v", output, err)
+ }
+ if output, err := run(t, newPluginEnableCommand(), "demo-plugin"); err != nil || !strings.Contains(output, "enabled") {
+ t.Fatalf("plugin enable = %q %v", output, err)
+ }
+
+ if _, err := run(t, newPluginConfigSetCommand(), "missing", "KEY", "value"); err == nil {
+ t.Fatal("missing plugin config set succeeded")
+ }
+ if output, err := run(t, newPluginConfigSetCommand(), "demo-plugin", "API_KEY", "abcdefghijk"); err != nil || !strings.Contains(output, "saved") {
+ t.Fatalf("plugin config set = %q %v", output, err)
+ }
+ if output, err := run(t, newPluginConfigGetCommand(), "demo-plugin", "API_KEY"); err != nil || !strings.Contains(output, "abcdefghijk") {
+ t.Fatalf("plugin config get = %q %v", output, err)
+ }
+ if _, err := run(t, newPluginConfigGetCommand(), "demo-plugin", "missing"); err == nil {
+ t.Fatal("missing plugin config get succeeded")
+ }
+ for _, args := range [][]string{{"demo-plugin"}, {"demo-plugin", "--json"}, {"missing"}} {
+ if _, err := run(t, newPluginConfigListCommand(), args...); err != nil {
+ t.Fatalf("plugin config list %#v: %v", args, err)
+ }
+ }
+ if _, err := run(t, newPluginConfigUnsetCommand(), "demo-plugin", "missing"); err == nil {
+ t.Fatal("missing plugin config unset succeeded")
+ }
+ if output, err := run(t, newPluginConfigUnsetCommand(), "demo-plugin", "API_KEY"); err != nil || !strings.Contains(output, "removed") {
+ t.Fatalf("plugin config unset = %q %v", output, err)
+ }
+
+ if _, err := run(t, newPluginDevCommand(), "missing"); err == nil {
+ t.Fatal("missing dev plugin registration succeeded")
+ }
+ if output, err := run(t, newPluginDevCommand(), "demo-plugin"); err != nil || !strings.Contains(output, "registered") {
+ t.Fatalf("dev plugin register = %q %v", output, err)
+ }
+ if output, err := run(t, newPluginDevCommand(), "demo-plugin", "--off"); err != nil || !strings.Contains(output, "unregistered") {
+ t.Fatalf("dev plugin unregister = %q %v", output, err)
+ }
+ if _, err := run(t, newPluginDevCommand(), "missing", "--off"); err == nil {
+ t.Fatal("missing dev plugin unregister succeeded")
+ }
+
+ if _, err := run(t, newPluginBuildCommand(), "missing"); err == nil {
+ t.Fatal("missing plugin build succeeded")
+ }
+ if _, err := run(t, newPluginBuildCommand(), "demo-plugin"); err == nil {
+ t.Fatal("scaffold placeholder build unexpectedly succeeded")
+ }
+ buildDir := filepath.Join(work, "build-plugin")
+ if err := os.MkdirAll(buildDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ manifest := `{"name":"build-plugin","version":"0.1.0","type":"user","build":{"command":"mkdir -p bin && touch bin/server","output":"bin/server"}}`
+ if err := os.WriteFile(filepath.Join(buildDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if output, err := run(t, newPluginBuildCommand(), buildDir); err != nil || !strings.Contains(output, "succeeded") {
+ t.Fatalf("plugin build = %q %v", output, err)
+ }
+
+ loader := plugin.NewLoader(RawVersion())
+ if got := loadDeclaredUserConfig(loader, "missing"); got != nil {
+ t.Fatalf("missing declared config = %#v", got)
+ }
+ for _, value := range []string{"", "short", "abcdefghijk"} {
+ _ = maskSensitiveValue(value)
+ }
+ if statusStr(true) != "enabled" || statusStr(false) != "disabled" {
+ t.Fatal("plugin status labels mismatch")
+ }
+ _ = newPluginCommand()
+ _ = newPluginConfigCommand()
+
+ if output, err := run(t, newPluginRemoveCommand(), "demo-plugin", "--keep-data"); err != nil || !strings.Contains(output, "removed") {
+ t.Fatalf("plugin remove = %q %v", output, err)
+ }
+ if _, err := run(t, newPluginRemoveCommand(), "missing"); err == nil {
+ t.Fatal("missing plugin remove succeeded")
+ }
+ if output, err := run(t, newPluginListCommand()); err != nil || !strings.Contains(output, "No plugins") {
+ t.Fatalf("empty plugin list = %q %v", output, err)
+ }
+}
+
+func TestUpgradeCommandHTTPAndDryRunCoverage(t *testing.T) {
+ oldEdition := edition.Get()
+ oldVersion := version
+ t.Cleanup(func() { edition.Override(oldEdition); version = oldVersion })
+ t.Setenv("HOME", t.TempDir())
+ assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz"
+ if runtime.GOOS == "windows" {
+ assetName = "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".zip"
+ }
+ releases := []upgradepkg.GitHubRelease{
+ {TagName: "v9.9.9-beta.1", Prerelease: true, PublishedAt: "2026-01-02T03:04:05Z", Body: "* abcdef1 - beta change", Assets: []upgradepkg.GitHubAsset{{Name: assetName}}},
+ {TagName: "v9.9.9", PublishedAt: "2026-01-01T03:04:05Z", Body: "* abcdef1 - stable change", HTMLURL: "https://release.test", Assets: []upgradepkg.GitHubAsset{{Name: assetName}, {Name: "dws-skills.zip"}}},
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, "/releases/tags/") {
+ if len(releases) == 0 {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(releases[len(releases)-1])
+ return
+ }
+ _ = json.NewEncoder(w).Encode(releases)
+ }))
+ defer server.Close()
+ t.Setenv("DWS_UPGRADE_URL", server.URL)
+ t.Setenv("DWS_UPGRADE_REPOSITORY", "owner/repo")
+
+ cmd := &cobra.Command{Use: "upgrade"}
+ var output bytes.Buffer
+ cmd.SetOut(&output)
+ for _, format := range []string{"json", "table"} {
+ for _, track := range []upgradepkg.ReleaseTrack{upgradepkg.ReleaseTrackRelease, upgradepkg.ReleaseTrackBeta} {
+ output.Reset()
+ version = "1.0.0"
+ if err := runUpgradeCheck(cmd, format, track); err != nil {
+ t.Fatalf("upgrade check %s/%s: %v", format, track, err)
+ }
+ if err := runUpgradeList(cmd, format, 1, track); err != nil {
+ t.Fatalf("upgrade list %s/%s: %v", format, track, err)
+ }
+ if err := runUpgradeList(cmd, format, 0, track); err != nil {
+ t.Fatalf("upgrade list all %s/%s: %v", format, track, err)
+ }
+ }
+ }
+ version = "9.9.9"
+ if err := runUpgradeCheck(cmd, "table", upgradepkg.ReleaseTrackRelease); err != nil {
+ t.Fatal(err)
+ }
+ releases = nil
+ if err := runUpgradeList(cmd, "table", 10, upgradepkg.ReleaseTrackRelease); err != nil {
+ t.Fatal(err)
+ }
+ releases = []upgradepkg.GitHubRelease{{TagName: "v9.9.9", Assets: []upgradepkg.GitHubAsset{{Name: assetName}, {Name: "dws-skills.zip"}}}}
+ version = "1.0.0"
+ if err := runUpgrade(context.Background(), upgradeOptions{dryRun: true, track: upgradepkg.ReleaseTrackRelease}); err != nil {
+ t.Fatalf("upgrade dry-run: %v", err)
+ }
+ if err := runUpgrade(context.Background(), upgradeOptions{dryRun: true, targetVersion: "v9.9.9", skipSkills: true}); err != nil {
+ t.Fatalf("target upgrade dry-run: %v", err)
+ }
+ releases[0].Assets = nil
+ if err := runUpgrade(context.Background(), upgradeOptions{dryRun: true, force: true}); err == nil {
+ t.Fatal("upgrade dry-run without binary succeeded")
+ }
+ server.Close()
+ if err := runUpgradeCheck(cmd, "json", upgradepkg.ReleaseTrackRelease); err == nil {
+ t.Fatal("upgrade network failure succeeded")
+ }
+ if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); err == nil {
+ t.Fatal("upgrade list network failure succeeded")
+ }
+ if err := runUpgrade(context.Background(), upgradeOptions{dryRun: true}); err == nil {
+ t.Fatal("upgrade network failure succeeded")
+ }
+ if err := runUpgradeRollback(true); err == nil {
+ t.Fatal("rollback without backup succeeded")
+ }
+
+ edition.Override(&edition.Hooks{IsEmbedded: true, Name: "host"})
+ upgradeCmd := newUpgradeCommand()
+ upgradeCmd.SetArgs(nil)
+ if err := upgradeCmd.Execute(); err == nil || !strings.Contains(err.Error(), "嵌入") {
+ t.Fatalf("embedded upgrade = %v", err)
+ }
+ edition.Override(&edition.Hooks{})
+ upgradeCmd = newUpgradeCommand()
+ upgradeCmd.SetArgs([]string{"--beta", "--version", "v1.0.0"})
+ if err := upgradeCmd.Execute(); err == nil {
+ t.Fatal("conflicting upgrade track succeeded")
+ }
+}
+
+func TestUpgradeRemainingPureCoverage(t *testing.T) {
+ for _, beta := range []bool{false, true} {
+ track := upgradeTrack(beta)
+ _ = upgradeTrackSuffix(track)
+ _ = upgradeTrackVersionName(track)
+ _ = upgradeHintForTrack(track)
+ }
+ var output bytes.Buffer
+ writeDryRunPlan(&output, "1.0.0", "binary.tar.gz", false)
+ writeDryRunPlan(&output, "1.0.0", "binary.tar.gz", true)
+ if output.Len() == 0 {
+ t.Fatal("empty upgrade dry-run plan")
+ }
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("format", "table", "")
+ child := &cobra.Command{Use: "child"}
+ root.AddCommand(child)
+ if resolveUpgradeFormat(child) != "table" {
+ t.Fatal("default upgrade format mismatch")
+ }
+ _ = root.PersistentFlags().Set("format", " JSON ")
+ if resolveUpgradeFormat(child) != "json" {
+ t.Fatal("JSON upgrade format mismatch")
+ }
+ if err := writeJSON(io.Discard, map[string]any{"ok": true}); err != nil {
+ t.Fatal(err)
+ }
+
+ dir := t.TempDir()
+ archive := filepath.Join(dir, "archive.tar.gz")
+ file, err := os.Create(archive)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gz := gzip.NewWriter(file)
+ tw := tar.NewWriter(gz)
+ body := []byte("data")
+ _ = tw.WriteHeader(&tar.Header{Name: "nested/file", Mode: 0o600, Size: int64(len(body))})
+ _, _ = tw.Write(body)
+ _ = tw.Close()
+ _ = gz.Close()
+ _ = file.Close()
+ dest := filepath.Join(dir, "dest")
+ if err := extractTarGz(archive, dest); err != nil {
+ t.Fatalf("extract tar: %v", err)
+ }
+ if err := extractTarGz(filepath.Join(dir, "missing"), filepath.Join(dir, "bad")); err == nil {
+ t.Fatal("missing tar extraction succeeded")
+ }
+ _ = shortenHome(filepath.Join(os.Getenv("HOME"), "path"))
+}
+
+func TestPersonalEventPureCoverage(t *testing.T) {
+ cmd := &cobra.Command{Use: "personal"}
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ for _, opts := range []personalListOptions{
+ {Format: "json", IncludePending: true},
+ {Format: "table", Category: "im", EnabledOnly: true},
+ } {
+ out.Reset()
+ if err := runPersonalEventList(cmd, opts); err != nil || out.Len() == 0 {
+ t.Fatalf("personal list %#v = %q, %v", opts, out.String(), err)
+ }
+ }
+ def, ok := personal.Lookup(personal.EventMention)
+ if !ok {
+ t.Fatal("mention definition missing")
+ }
+ if err := renderPersonalSchema(io.Discard, def, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
+ t.Fatal("unsupported schema format succeeded")
+ }
+ for _, key := range []string{"", "unknown", personal.EventMention} {
+ if err := ensurePublicPersonalEvent(key); err != nil {
+ t.Fatalf("public event %q: %v", key, err)
+ }
+ }
+ if err := ensurePublicPersonalEvent(personal.EventFromUser); err == nil {
+ t.Fatal("private event accepted")
+ }
+
+ var cfg consume.Config
+ applyPersonalConsumeFilters(nil, personalConsumeOptions{}, "", "")
+ applyPersonalConsumeFilters(&cfg, personalConsumeOptions{
+ Common: personalCommonOptions([]string{"explicit"}, "filter"),
+ }, " sub ", personal.EventMention)
+ if len(cfg.EventTypes) != 1 || cfg.SubscribeID != "sub" || cfg.Filter != "filter" {
+ t.Fatalf("personal filters = %#v", cfg)
+ }
+ applyPersonalConsumeFilters(&cfg, personalConsumeOptions{DebugRawEvents: true}, "sub", personal.EventMention)
+ if cfg.EventTypes != nil || cfg.SubscribeID != "" || cfg.Filter != "" {
+ t.Fatalf("debug filters = %#v", cfg)
+ }
+
+ for _, tc := range []struct {
+ explicit []string
+ key string
+ wantNil bool
+ }{
+ {nil, "", true},
+ {nil, personal.EventMention, false},
+ {[]string{"x"}, personal.EventMention, false},
+ } {
+ got := personalEventTypes(tc.key, tc.explicit)
+ if (got == nil) != tc.wantNil {
+ t.Errorf("personalEventTypes(%q, %#v) = %#v", tc.key, tc.explicit, got)
+ }
+ }
+ identity := personal.Identity{CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "source"}
+ if len(redactedPersonalIdentity(identity, "hash")) != 5 || displayIdentityPart(" ") != "unknown" || displayIdentityPart(" value ") != "value" {
+ t.Fatal("personal identity display mismatch")
+ }
+ if firstNonEmptyPersonalString(" ", " x ") != "x" || firstNonEmptyPersonalString(" ") != "" {
+ t.Fatal("first nonempty mismatch")
+ }
+ if personalTokenSubject("access", "") != "" || !strings.HasPrefix(personalTokenSubject(" access ", " token "), "access:") {
+ t.Fatal("personal token subject mismatch")
+ }
+ if displayPersonalStatusValue(" ") != "-" || displayPersonalStatusValue(" x ") != "x" {
+ t.Fatal("personal status display mismatch")
+ }
+
+ args := personalBusSpawnArgs(identity, "custom", "https://ticket")
+ if len(args) != 8 || len(personalBusSpawnArgs(identity, "", "")) != 4 {
+ t.Fatalf("personal bus args = %#v", args)
+ }
+ if personalEventControlBaseURL(" https://control/ ", "") != "https://control" || personalEventStreamTicketURL(" https://ticket/ ", "") != "https://ticket" {
+ t.Fatal("personal explicit URL mismatch")
+ }
+ if personalEventStreamSourceID(" explicit ") != "explicit" {
+ t.Fatal("personal explicit source mismatch")
+ }
+ emptyDir := t.TempDir()
+ if configuredMCPBaseURL(emptyDir) != "" {
+ t.Fatal("missing MCP URL unexpectedly resolved")
+ }
+ if err := os.WriteFile(filepath.Join(emptyDir, "mcp_url"), []byte(" https://mcp.test/ \n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if configuredMCPBaseURL(emptyDir) != "https://mcp.test/" || personalEventMCPBaseURL(emptyDir) != "https://mcp.test" {
+ t.Fatal("configured MCP URL mismatch")
+ }
+
+ var rendered bytes.Buffer
+ printPersonalStopResult(&rendered, []string{"sub"}, true, "stopped")
+ printPersonalStopResult(&rendered, []string{"a", "b"}, false, "running")
+ qs := busctl.EntryStatus{Entry: busctl.BusEntry{WorkDir: "work", State: busctl.BusStateRunning, HolderPID: 42}, Live: &eventtransport.StatusResp{}}
+ renderPersonalStatusText(&rendered, identity, "hash", []personal.Subscription{{SubscribeID: "sub", EventKey: personal.EventMention, Status: "active"}}, qs)
+ if !strings.Contains(rendered.String(), "SUBSCRIBE_ID") || !strings.Contains(rendered.String(), "cancelled") {
+ t.Fatalf("personal rendering = %q", rendered.String())
+ }
+ renderPersonalConsumers(io.Discard, busctl.EntryStatus{Entry: busctl.BusEntry{State: busctl.BusStateRunning}, Live: &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{{PID: 7}}}})
+
+ if got, err := personalStopTargets(t.TempDir(), " sub ", false); err != nil || len(got) != 1 || got[0] != "sub" {
+ t.Fatalf("explicit stop targets = %#v, %v", got, err)
+ }
+ if _, err := personalStopTargets(t.TempDir(), "sub", true); err == nil {
+ t.Fatal("conflicting stop target succeeded")
+ }
+ if _, err := personalStopTargets(t.TempDir(), "", false); err == nil {
+ t.Fatal("missing stop target succeeded")
+ }
+ workDir := t.TempDir()
+ for _, id := range []string{"b", "a"} {
+ if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: id}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got, err := personalStopTargets(workDir, "", true); err != nil || strings.Join(got, ",") != "a,b" {
+ t.Fatalf("all stop targets = %#v, %v", got, err)
+ }
+ if err := interruptPersonalConsumers("", []string{"sub"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := interruptPersonalConsumers("bad-endpoint", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := interruptPersonalConsumers("bad-endpoint", []string{"sub"}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func personalCommonOptions(eventTypes []string, filter string) commonConsumeOptions {
+ return commonConsumeOptions{EventTypes: eventTypes, Filter: filter}
+}
+
+func TestPersonalSubscriptionAndSourceCoverage(t *testing.T) {
+ identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "source"}
+ var createCount, cancelCount int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/event/sublist":
+ _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "existing", "eventKey": personal.EventMention, "ruleType": "at", "status": "active"}}, "total": 1})
+ case "/subscription/user":
+ createCount++
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
+ case "/subscription/cancel":
+ cancelCount++
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+ client := personal.NewClient(server.URL, identity)
+
+ for _, tc := range []struct {
+ name string
+ opts personalConsumeOptions
+ ok bool
+ }{
+ {"lookup", personalConsumeOptions{SubscribeID: "existing"}, true},
+ {"create", personalConsumeOptions{EventKey: personal.EventMention, Name: "name", FilterJSON: `{"field":"content","op":"eq","value":"x"}`, QueryCSV: "a,b", TTL: time.Minute}, true},
+ {"missing", personalConsumeOptions{}, false},
+ {"private", personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "u"}, false},
+ {"bad-rule", personalConsumeOptions{EventKey: personal.EventMention, Rule: "other"}, false},
+ {"bad-filter", personalConsumeOptions{EventKey: personal.EventMention, FilterJSON: "{"}, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ sub, key, rule, err := ensurePersonalSubscription(context.Background(), client, identity, tc.opts)
+ if tc.ok && (err != nil || sub == nil || key == "" || rule == "") {
+ t.Fatalf("subscription = %#v %q %q, %v", sub, key, rule, err)
+ }
+ if !tc.ok && err == nil {
+ t.Fatal("invalid subscription succeeded")
+ }
+ })
+ }
+ if createCount != 1 {
+ t.Fatalf("create count = %d", createCount)
+ }
+
+ dir := t.TempDir()
+ if _, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: dir, Identity: identity, TicketMode: "bad"}); err == nil {
+ t.Fatal("invalid ticket mode succeeded")
+ }
+ if src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: dir, Identity: identity}); err != nil || src == nil {
+ t.Fatalf("normal source = %#v, %v", src, err)
+ }
+ t.Setenv(authpkg.EnvClientID, "resolved-client")
+ t.Setenv(authpkg.EnvClientSecret, "secret")
+ if src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: dir, Identity: personal.Identity{AccessToken: "token", SourceID: "source"}, TicketMode: "custom", ClientIDOverride: "override"}); err != nil || src == nil {
+ t.Fatalf("custom source = %#v, %v", src, err)
+ }
+ if cancelCount != 0 {
+ t.Fatalf("unexpected cancel count = %d", cancelCount)
+ }
+}
+
+func TestPersonalEventCommandRuntimeCoverage(t *testing.T) {
+ configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
+ AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour),
+ CorpID: "corp", UserID: "user", ClientID: "client",
+ })
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+ var cancelCount int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/event/sublist":
+ _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
+ case "/subscription/user":
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
+ case "/subscription/cancel":
+ cancelCount++
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ newCmd := func() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
+ cmd := &cobra.Command{Use: "event"}
+ cmd.SetContext(context.Background())
+ out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
+ cmd.SetOut(out)
+ cmd.SetErr(errOut)
+ cmd.Flags().String("format", "table", "")
+ cmd.Flags().String("output", "", "")
+ return cmd, out, errOut
+ }
+ for _, format := range []string{"json", "table"} {
+ cmd, out, _ := newCmd()
+ if err := runPersonalEventStatus(cmd, personalStatusOptions{Format: format, ControlBaseURL: server.URL}); err != nil || out.Len() == 0 {
+ t.Fatalf("personal status %s = %q, %v", format, out.String(), err)
+ }
+ }
+ cmd, _, _ := newCmd()
+ if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{DryRun: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL}); err != nil {
+ t.Fatalf("personal dry-run consume: %v", err)
+ }
+ cmd, _, _ = newCmd()
+ if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
+ t.Fatal("invalid foreground consume succeeded")
+ }
+ if cancelCount == 0 {
+ t.Fatal("failed foreground consume did not clean up subscription")
+ }
+
+ if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
+ t.Fatal("conflicting stop succeeded")
+ }
+ if err := runPersonalEventStop(cmd, personalStopOptions{ControlBaseURL: server.URL}); err == nil {
+ t.Fatal("missing stop target succeeded")
+ }
+ if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", ControlBaseURL: server.URL}); err != nil {
+ t.Fatalf("single stop: %v", err)
+ }
+}
+
+func TestDoctorCommandCoverage(t *testing.T) {
+ configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
+ AccessToken: "access", ExpiresAt: time.Now().Add(time.Hour), ClientID: "client",
+ })
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ oldVersion := version
+ oldDiagnose := doctorKeychainDiagnose
+ t.Cleanup(func() { version = oldVersion; doctorKeychainDiagnose = oldDiagnose })
+ version = "1.0.0"
+ doctorKeychainDiagnose = func() keychain.Diagnostic {
+ return keychain.Diagnostic{OK: true, Message: "available"}
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, "/releases/latest") {
+ _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.0.0"})
+ return
+ }
+ _, _ = io.WriteString(w, "ok")
+ }))
+ defer server.Close()
+ if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(server.URL), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("DWS_UPGRADE_URL", server.URL)
+ t.Setenv("DWS_UPGRADE_REPOSITORY", "owner/repo")
+ t.Setenv(PerfReportEnv, "auto")
+ tc := NewTimingCollector()
+ tc.Record("phase", 2*time.Millisecond)
+ tc.WriteReportIfEnabled("1.0.0", "doctor-test")
+
+ for _, args := range [][]string{{"--json", "--perf", "--timeout", "1"}, {"--timeout", "1"}} {
+ cmd := newDoctorCommand()
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil || out.Len() == 0 {
+ t.Fatalf("doctor %#v = %q, %v", args, out.String(), err)
+ }
+ }
+
+ for _, jsonOut := range []bool{false, true} {
+ if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
+ t.Fatal("cache check failed")
+ }
+ if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
+ t.Fatalf("perf check = %#v", got)
+ }
+ }
+ if p, w, f := countResults([]checkResult{{Status: statusPass}, {Status: statusWarn}, {Status: statusFail}, {Status: "other"}}); p != 1 || w != 1 || f != 1 {
+ t.Fatalf("doctor counts = %d/%d/%d", p, w, f)
+ }
+ for _, status := range []checkStatus{statusPass, statusWarn, statusFail, "other"} {
+ _ = statusIcon(status)
+ printCheckResult(io.Discard, checkResult{Status: status, Message: "message", Hint: "hint"})
+ }
+ printPerfReportSummary(io.Discard, &PerfReport{Command: "cmd", Timestamp: time.Now(), Phases: []PerfPhase{{Name: "slow", DurationMs: 3}}, Slowest: "slow", TotalMs: 4, OverheadMs: 1})
+
+ doctorKeychainDiagnose = func() keychain.Diagnostic {
+ return keychain.Diagnostic{OK: false, Message: "bad", Hint: "fix", Reason: "reason"}
+ }
+ if got := doctorCheckKeychain(io.Discard, false); got.Status != statusFail {
+ t.Fatal("keychain failure not reported")
+ }
+ doctorKeychainDiagnose = func() keychain.Diagnostic {
+ return keychain.Diagnostic{OK: false, Message: "bad", Reason: "reason", Detail: map[string]string{"backend": "test"}}
+ }
+ _ = doctorCheckKeychain(io.Discard, true)
+
+ badConfig := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", badConfig)
+ if err := os.WriteFile(filepath.Join(badConfig, "mcp_url"), []byte("://bad"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := doctorCheckNetwork(context.Background(), io.Discard, false, time.Second); got.Status != statusFail {
+ t.Fatalf("bad network check = %#v", got)
+ }
+ t.Setenv("DWS_UPGRADE_URL", "http://127.0.0.1:1")
+ if got := doctorCheckVersion(io.Discard, false, time.Millisecond); got.Status != statusFail {
+ t.Fatalf("bad version check = %#v", got)
+ }
+ missingHome := t.TempDir()
+ t.Setenv("HOME", missingHome)
+ if got := doctorCheckPerf(io.Discard, false); got.Status != statusWarn {
+ t.Fatalf("missing perf check = %#v", got)
+ }
+}
+
+func coverageSkillZip(t *testing.T) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ dir, err := zw.Create("demo/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = dir.Write(nil)
+ f, err := zw.Create("demo/SKILL.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = io.WriteString(f, "# demo")
+ if err := zw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buf.Bytes()
+}
+
+func TestSkillCommandHTTPCoverage(t *testing.T) {
+ configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{AccessToken: "access", ExpiresAt: time.Now().Add(time.Hour), ClientID: "client"})
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ zipData := coverageSkillZip(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/cli/find-skills":
+ switch r.URL.Query().Get("keyword") {
+ case "empty":
+ _ = json.NewEncoder(w).Encode(findSkillsResponse{Success: true})
+ case "business":
+ _ = json.NewEncoder(w).Encode(findSkillsResponse{ErrorCode: "E"})
+ case "malformed":
+ _, _ = io.WriteString(w, "{")
+ default:
+ _ = json.NewEncoder(w).Encode(findSkillsResponse{Success: true, Result: []CliSkillDTO{{SkillID: "id", Name: "name", Desc: "desc"}}})
+ }
+ case "/cli/install", "/package.zip":
+ w.Header().Set("Content-Disposition", `attachment; filename="demo.zip"`)
+ _, _ = w.Write(zipData)
+ case "/info":
+ _ = json.NewEncoder(w).Encode(downloadSkillResponse{Success: true, Result: &downloadSkillResult{DownloadURL: serverURL(r) + "/package.zip", FileName: "demo.zip"}})
+ case "/unauthorized":
+ w.WriteHeader(http.StatusUnauthorized)
+ case "/bad-request":
+ w.WriteHeader(http.StatusBadRequest)
+ case "/not-found":
+ w.WriteHeader(http.StatusNotFound)
+ case "/failure":
+ w.WriteHeader(http.StatusInternalServerError)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+ t.Setenv("DWS_SKILL_API_HOST", server.URL+"/")
+ oldEndpoint := skillDownloadEndpoint
+ skillDownloadEndpoint = server.URL + "/info"
+ t.Cleanup(func() { skillDownloadEndpoint = oldEndpoint })
+
+ run := func(cmd *cobra.Command, args ...string) (string, error) {
+ var out bytes.Buffer
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ cmd.SetOut(&out)
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ return out.String(), err
+ }
+ for _, args := range [][]string{{"--query", "demo", "--source", "DingtalkMarket"}, {"--query", "empty"}} {
+ if output, err := run(newSkillSearchCommand(), args...); err != nil || output == "" {
+ t.Fatalf("skill search %#v = %q, %v", args, output, err)
+ }
+ }
+ for _, query := range []string{"business", "malformed"} {
+ if _, err := run(newSkillSearchCommand(), "--query", query); err == nil {
+ t.Fatalf("skill search %s succeeded", query)
+ }
+ }
+ if output, err := run(newSkillGetCommand(), "--skill-id", "id"); err != nil || !strings.Contains(output, "dws-skill-") {
+ t.Fatalf("skill get = %q, %v", output, err)
+ }
+ if output, err := run(newSkillInstallCommand(), "id", "agents"); err != nil || !strings.Contains(output, "技能安装成功") {
+ t.Fatalf("skill install = %q, %v", output, err)
+ }
+ if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "demo", "SKILL.md")); err != nil {
+ t.Fatal(err)
+ }
+ for _, cmd := range []*cobra.Command{buildSkillCommand(), newSkillFindHintCommand(), newSkillAddHintCommand()} {
+ _, _ = run(cmd)
+ }
+ if _, err := run(newSkillInstallCommand(), "", "agents"); err == nil {
+ t.Fatal("empty skill ID succeeded")
+ }
+ if _, err := run(newSkillInstallCommand(), "id", "missing"); err == nil {
+ t.Fatal("bad target succeeded")
+ }
+}
+
+func serverURL(r *http.Request) string {
+ return "http://" + r.Host
+}
+
+func TestSkillHelpersAndErrorsCoverage(t *testing.T) {
+ oldEmbedded := edition.Get()
+ t.Cleanup(func() { edition.Override(oldEmbedded) })
+ for _, embedded := range []bool{false, true} {
+ edition.Override(&edition.Hooks{IsEmbedded: embedded})
+ _ = skillAuthError()
+ }
+ t.Setenv("DWS_SKILL_API_HOST", " https://host.test/ ")
+ if skillAPIHost() != "https://host.test" || supportedTargets() == "" || longestAgentTargetName() == 0 || formatAgentSkillPathsForHelp() == "" {
+ t.Fatal("skill helper mismatch")
+ }
+ if _, err := resolveSkillTargetPath(""); err == nil {
+ t.Fatal("empty target succeeded")
+ }
+ if _, err := resolveSkillTargetPath("missing"); err == nil {
+ t.Fatal("unknown target succeeded")
+ }
+ if got, err := resolveSkillTargetPath("."); err != nil || got == "" {
+ t.Fatalf("dot target = %q, %v", got, err)
+ }
+ if filenameFromDisposition(`attachment; filename="named.zip"`) != "named.zip" || filenameFromDisposition("bad;") != "skill.zip" || filenameFromDisposition("") != "skill.zip" {
+ t.Fatal("content disposition parsing mismatch")
+ }
+ for _, status := range []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError} {
+ _ = parseLegacySkillAPIError(&http.Response{StatusCode: status})
+ }
+ cleanupTempFile("")
+ missing := filepath.Join(t.TempDir(), "missing.zip")
+ cleanupTempFile(missing)
+ if err := extractSkillZip(missing, t.TempDir()); err == nil {
+ t.Fatal("missing skill zip succeeded")
+ }
+ zipPath := filepath.Join(t.TempDir(), "bad.zip")
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ f, _ := zw.Create("../escape")
+ _, _ = io.WriteString(f, "bad")
+ _ = zw.Close()
+ if err := os.WriteFile(zipPath, buf.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := extractSkillZip(zipPath, t.TempDir()); err == nil {
+ t.Fatal("zip slip succeeded")
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/ok":
+ _, _ = io.WriteString(w, "data")
+ case "/unauthorized":
+ w.WriteHeader(http.StatusUnauthorized)
+ case "/bad":
+ w.WriteHeader(http.StatusBadRequest)
+ case "/missing":
+ w.WriteHeader(http.StatusNotFound)
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ }))
+ defer server.Close()
+ if p, err := downloadSkillFile(context.Background(), server.URL+"/ok", ""); err != nil {
+ t.Fatal(err)
+ } else {
+ cleanupTempFile(p)
+ }
+ if _, err := downloadSkillFile(context.Background(), server.URL+"/bad", "x"); err == nil {
+ t.Fatal("bad download succeeded")
+ }
+ for _, path := range []string{"unauthorized", "bad", "missing", "failure"} {
+ if _, err := downloadSkillToTmpDir(context.Background(), server.URL+"/"+path, "token"); err == nil {
+ t.Fatalf("legacy download %s succeeded", path)
+ }
+ }
+ if _, err := downloadSkillToTmpDir(context.Background(), "://bad", "token"); err == nil {
+ t.Fatal("invalid legacy URL succeeded")
+ }
+ if _, err := downloadSkillFile(context.Background(), "://bad", "x"); err == nil {
+ t.Fatal("invalid download URL succeeded")
+ }
+}
+
+func TestProfileCommandAndModelCoverage(t *testing.T) {
+ configDir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+ now := time.Now().UTC()
+ cfg := &authpkg.ProfilesConfig{
+ Version: 1, PrimaryProfile: "corp-a", CurrentProfile: "corp-a", PreviousProfile: "corp-b",
+ Profiles: []authpkg.Profile{
+ {Name: "alpha", CorpID: "corp-a", CorpName: "Alpha 组织", UserID: "u-a", UserName: "Alice", LastLoginAt: now.Add(-time.Hour).Format(time.RFC3339)},
+ {Name: "beta", CorpID: "corp-b", UserID: "u-b", UpdatedAt: now.Format(time.RFC3339), Status: authpkg.ProfileStatusExpired},
+ {Name: "gamma", CorpID: "corp-c", LastUsedAt: "bad-time"},
+ },
+ }
+ if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
+ t.Fatal(err)
+ }
+ oldInteractive := profileSwitchInteractiveTerminal
+ oldSelector := profileSwitchSelector
+ t.Cleanup(func() {
+ profileSwitchInteractiveTerminal = oldInteractive
+ profileSwitchSelector = oldSelector
+ })
+ profileSwitchInteractiveTerminal = func() bool { return false }
+
+ run := func(cmd *cobra.Command, args ...string) (string, error) {
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("format", "table", "")
+ root.AddCommand(cmd)
+ var out bytes.Buffer
+ root.SetOut(&out)
+ root.SetErr(io.Discard)
+ root.SilenceUsage = true
+ root.SilenceErrors = true
+ root.SetArgs(append([]string{cmd.Name()}, args...))
+ err := root.Execute()
+ return out.String(), err
+ }
+ if output, err := run(newProfileListCommand()); err != nil || !strings.Contains(output, "corp-a") {
+ t.Fatalf("profile list = %q, %v", output, err)
+ }
+ rootJSON := &cobra.Command{Use: "root"}
+ rootJSON.PersistentFlags().String("format", "json", "")
+ listJSON := newProfileListCommand()
+ rootJSON.AddCommand(listJSON)
+ var out bytes.Buffer
+ rootJSON.SetOut(&out)
+ rootJSON.SetArgs([]string{"list"})
+ if err := rootJSON.Execute(); err != nil || !strings.Contains(out.String(), `"profiles"`) {
+ t.Fatalf("profile JSON = %q, %v", out.String(), err)
+ }
+ if output, err := run(newProfileSwitchCommand(), "corp-b"); err != nil || !strings.Contains(output, "corp-b") {
+ t.Fatalf("profile switch = %q, %v", output, err)
+ }
+ if output, err := run(newProfileUseCommand(), "-"); err != nil || !strings.Contains(output, "corp-a") {
+ t.Fatalf("profile previous = %q, %v", output, err)
+ }
+ if _, err := run(newProfileSwitchCommand()); err == nil {
+ t.Fatal("noninteractive empty profile switch succeeded")
+ }
+ profileSwitchSelector = func(*cobra.Command, string) (string, error) { return "corp-c", nil }
+ if output, err := run(newProfileSwitchCommand()); err != nil || !strings.Contains(output, "corp-c") {
+ t.Fatalf("profile injected selection = %q, %v", output, err)
+ }
+ if _, err := run(newProfileSwitchCommand(), "missing"); err == nil {
+ t.Fatal("missing profile switch succeeded")
+ }
+ _, _ = run(newProfileCommand())
+
+ selectorCmd := newProfileSwitchCommand()
+ if got, err := profileSwitchSelectorFromCommand(selectorCmd, nil); err != nil || got != "" {
+ t.Fatalf("empty selector = %q, %v", got, err)
+ }
+ _ = selectorCmd.Flags().Set("corpId", "corp-a")
+ if got, err := profileSwitchSelectorFromCommand(selectorCmd, []string{"corp-a"}); err != nil || got != "corp-a" {
+ t.Fatalf("matching selectors = %q, %v", got, err)
+ }
+ _ = selectorCmd.Flags().Set("name", "beta")
+ if _, err := profileSwitchSelectorFromCommand(selectorCmd, []string{"corp-a"}); err == nil {
+ t.Fatal("conflicting profile selectors succeeded")
+ }
+ if _, changed := changedStringFlag(nil, "x"); changed {
+ t.Fatal("nil command flag changed")
+ }
+
+ model := newProfileSwitchTUIModel(cfg, "corp-a")
+ _ = model.Init()
+ if model.View() == "" || model.tableView() == "" || model.selectedCorpID() == "" {
+ t.Fatal("profile model rendering empty")
+ }
+ for _, key := range []tea.KeyType{tea.KeyDown, tea.KeyUp, tea.KeyEnter, tea.KeyEsc} {
+ updated, _ := model.Update(tea.KeyMsg{Type: key})
+ model = updated.(profileSwitchTUIModel)
+ }
+ updated, _ := model.Update(struct{}{})
+ model = updated.(profileSwitchTUIModel)
+ for range 10 {
+ model.profiles = append(model.profiles, authpkg.Profile{CorpID: fmt.Sprintf("corp-%d", len(model.profiles))})
+ }
+ model.selected = 99
+ model.offset = -1
+ model.ensureSelectedVisible()
+ model.selected = -1
+ model.ensureSelectedVisible()
+ emptyModel := newProfileSwitchTUIModel(nil, "")
+ emptyModel.ensureSelectedVisible()
+ if emptyModel.selectedCorpID() != "" {
+ t.Fatal("empty profile model selected a profile")
+ }
+
+ _ = profileSwitchSortedProfiles(cfg.Profiles)
+ for _, raw := range []string{"", "bad", now.Format(time.RFC3339)} {
+ _, _ = parseProfileSwitchTime(raw)
+ }
+ for _, p := range cfg.Profiles {
+ _, _ = profileSwitchSortTime(p)
+ _ = profileSwitchOptionLabel(p, cfg)
+ _, _ = profileSwitchProfileCells(p, cfg)
+ }
+ _ = profileSwitchProfileIndex(cfg.Profiles, "missing")
+ _ = profileSwitchBorder("a", "b", "c")
+ _ = profileSwitchTableLine("org", "status")
+ _ = profileSwitchStyledTableLine("org", "status", profileSwitchNormalRowStyle())
+ _ = profileSwitchTableSeparator()
+ _ = profileSwitchTableCell("organization", 4)
+ _ = padProfileDisplayCell("long", 1)
+ _ = profileSwitchCellWidth(3)
+ _ = profileSwitchSelectedRowStyle()
+ _ = profileSwitchHeaderStyle()
+ _ = profileSwitchBorderStyle()
+ _ = profileSwitchTitleStyle()
+ _ = profileSwitchMutedStyle()
+
+ writeProfileListTable(io.Discard, nil)
+ writeProfileListTable(io.Discard, cfg)
+ if err := writeProfileListJSON(io.Discard, cfg); err != nil || writeProfileUseJSON(io.Discard, nil, nil) != nil || writeProfileUseJSON(io.Discard, &cfg.Profiles[0], cfg) != nil {
+ t.Fatal("profile JSON write failed")
+ }
+ _ = profileUseMessage(nil)
+ for _, p := range []authpkg.Profile{{CorpID: "corp"}, {Name: "name", CorpID: "corp"}, {CorpName: "org", CorpID: "corp"}} {
+ _ = profileUseMessage(&p)
+ _ = profileOrgName(p)
+ }
+ _ = profileViews(nil)
+ _ = profileViews(cfg)
+ for _, limit := range []int{0, 2, 5, 40} {
+ _ = clipProfileCell("abcdefgh", limit)
+ _ = clipProfileDisplayCell("中文abcdefgh", limit)
+ }
+}
+
+func TestSkillSetupRuntimeCoverage(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ mono := filepath.Join(t.TempDir(), "mono")
+ if err := os.MkdirAll(filepath.Join(mono, "references"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(mono, "references", "ref.md"), []byte("ref"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
+ multi := filepath.Join(t.TempDir(), "multi")
+ for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
+ dir := filepath.Join(multi, name)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ run := func(args ...string) (string, string, error) {
+ cmd := newSkillSetupCommand()
+ cmd.Flags().Bool("dry-run", false, "")
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ return out.String(), errOut.String(), err
+ }
+ if output, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes"); err != nil || !strings.Contains(output, "installed=1") {
+ t.Fatalf("mono setup = %q, %v", output, err)
+ }
+ if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
+ t.Fatal(err)
+ }
+ if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
+ t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
+ }
+ if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
+ t.Fatal(err)
+ }
+ if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
+ t.Fatalf("multi dry run = %q, %v", output, err)
+ }
+ for _, args := range [][]string{
+ {"--mode", "bad", "--source", mono, "--yes"},
+ {"--mode", "mono", "--source", mono, "--yes", "--skill", "a"},
+ {"--mode", "mono", "--source", filepath.Join(home, "missing"), "--yes"},
+ {"--mode", "mono", "--source", mono, "--target", "missing", "--yes"},
+ {"--mode", "multi", "--source", multi, "--yes", "--skill", "missing"},
+ {"--mode", "multi", "--source", multi, "--yes", "--skill", "a", "--exclude", "b"},
+ } {
+ if _, _, err := run(args...); err == nil {
+ t.Fatalf("invalid setup %#v succeeded", args)
+ }
+ }
+ if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
+ t.Fatalf("default mono setup: %v", err)
+ }
+}
+
+func TestSkillSetupPureCoverage(t *testing.T) {
+ all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
+ for _, tc := range []struct {
+ include []string
+ exclude []string
+ ok bool
+ }{
+ {nil, nil, true},
+ {[]string{"a", "DINGTALK-A", ""}, nil, true},
+ {nil, []string{"b"}, true},
+ {[]string{"missing"}, nil, false},
+ {nil, []string{"missing"}, false},
+ {[]string{"a"}, []string{"b"}, false},
+ {nil, all, false},
+ } {
+ if _, err := filterMultiSkillNames(all, tc.include, tc.exclude); (err == nil) != tc.ok {
+ t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
+ }
+ }
+ for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
+ _ = ensureMandatorySharedSkill(selected, all)
+ }
+ _ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
+ for _, name := range []string{"", " A ", "DINGTALK-B"} {
+ _ = normalizeMultiSkillName(name)
+ }
+ if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("missing multi source succeeded")
+ }
+ if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
+ t.Fatalf("default setup mode = %q, %v", mode, err)
+ }
+ if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
+ t.Fatal("bad setup mode succeeded")
+ }
+ for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti} {
+ if got, err := resolveSkillSetupMode(mode, false, io.Discard); err != nil || got != mode {
+ t.Fatalf("setup mode %s = %q, %v", mode, got, err)
+ }
+ }
+
+ root := t.TempDir()
+ mono := filepath.Join(root, "skills", "mono")
+ multi := filepath.Join(root, "skills", "multi", "dingtalk-a")
+ if err := os.MkdirAll(multi, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(mono, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ _ = os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("x"), 0o600)
+ _ = os.WriteFile(filepath.Join(multi, "SKILL.md"), []byte("x"), 0o600)
+ for _, tc := range []struct{ explicit, mode string }{{root, skillSetupModeMono}, {root, skillSetupModeMulti}, {mono, skillSetupModeMono}} {
+ if got, err := resolveSkillSetupSource(tc.explicit, tc.mode); err != nil || got == "" {
+ t.Fatalf("skill source %#v = %q, %v", tc, got, err)
+ }
+ }
+ if _, err := resolveSkillSetupSource(filepath.Join(root, "missing"), skillSetupModeMono); err == nil {
+ t.Fatal("bad explicit source succeeded")
+ }
+ _ = skillSourceCandidates(root, skillSetupModeMono)
+ for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
+ _ = isSkillSourceRoot(tc.path, tc.mode)
+ }
+ t.Setenv("HOME", t.TempDir())
+ for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
+ _, _ = resolveSkillSetupTargets(tc.target, tc.mode)
+ }
+ _ = agentHomeForMode("base", skillSetupModeMono)
+ _ = agentHomeForMode("base", skillSetupModeMulti)
+ _ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
+ for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
+ _, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
+ _ = mutualExclusionVictims(root, mode)
+ }
+ if isCharDevice(nil) || isInteractiveTerminal() {
+ t.Fatal("test process unexpectedly interactive")
+ }
+
+ monoDest := filepath.Join(t.TempDir(), "agent", "dws")
+ _ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
+ _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
+ multiDest := filepath.Join(t.TempDir(), "agent")
+ _ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
+ _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
+ cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
+ cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
+
+ badParent := filepath.Join(t.TempDir(), "file")
+ _ = os.WriteFile(badParent, []byte("x"), 0o600)
+ _, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
+ _, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
+ if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
+ t.Fatal("copy missing directory succeeded")
+ }
+ if err := copyFileContent(filepath.Join(root, "missing"), filepath.Join(t.TempDir(), "out"), 0o600); err == nil {
+ t.Fatal("copy missing file succeeded")
+ }
+
+ for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti} {
+ src, cleanup, err := materializeEmbeddedSkillSource(mode)
+ if err != nil || src == "" {
+ t.Fatalf("embedded %s = %q, %v", mode, src, err)
+ }
+ cleanup()
+ }
+ if _, _, err := materializeEmbeddedSkillSource("missing"); err == nil {
+ t.Fatal("missing embedded setup source succeeded")
+ }
+ if src, cleanup, err := resolveSkillSetupSourceOrEmbedded(root, skillSetupModeMono); err != nil || src == "" {
+ t.Fatalf("explicit embedded wrapper = %q, %v", src, err)
+ } else {
+ cleanup()
+ }
+}
diff --git a/internal/app/coverage_more_test.go b/internal/app/coverage_more_test.go
new file mode 100644
index 00000000..4850fef9
--- /dev/null
+++ b/internal/app/coverage_more_test.go
@@ -0,0 +1,433 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
+ authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
+ "github.com/spf13/cobra"
+)
+
+func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ID int `json:"id"`
+ Method string `json:"method"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ w.Header().Set("Content-Type", "application/json")
+ switch req.Method {
+ case "initialize":
+ if !initOK {
+ _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -32601, "message": "init"}})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"protocolVersion": "2025-03-26"}})
+ case "tools/list":
+ if !listOK {
+ _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -1, "message": "list"}})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "jsonrpc": "2.0",
+ "id": req.ID,
+ "result": map[string]any{
+ "tools": []any{map[string]any{
+ "name": "tool",
+ "description": "desc",
+ "inputSchema": map[string]any{
+ "properties": map[string]any{"id": map[string]any{"type": "string"}},
+ "required": []any{"id", 1, ""},
+ },
+ }},
+ },
+ })
+ default:
+ _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{}})
+ }
+ }))
+}
+
+func TestDynamicServerAndHelperFetcherCoverage(t *testing.T) {
+ p := &plugin.Plugin{Manifest: plugin.Manifest{Name: "demo-plugin", Description: "demo"}}
+ timeouts := pluginColdTimeouts{httpNoAuth: time.Second, httpAuth: time.Second, stdio: time.Second}
+ runner := &coverageRunner{}
+
+ for _, tc := range []struct {
+ initOK bool
+ listOK bool
+ want int
+ }{
+ {false, true, 0},
+ {true, false, 0},
+ {true, true, 1},
+ } {
+ srv := appRPCServer(t, tc.initOK, tc.listOK)
+ desc := mcptypes.ServerDescriptor{Key: "server", Endpoint: srv.URL, CLI: mcptypes.CLIOverlay{ID: "demo"}}
+ client := transport.NewClient(nil)
+ client.TrustedDomains = []string{"127.0.0.1"}
+ tools := discoverHTTPTools(p, desc, client, timeouts)
+ if len(tools) != tc.want {
+ t.Fatalf("HTTP tools = %d, want %d", len(tools), tc.want)
+ }
+ _ = registerHTTPServer(p, desc, client, runner, timeouts)
+ srv.Close()
+ }
+ if got := buildHTTPCommandsFromTools(mcptypes.ServerDescriptor{}, nil, runner); got != nil {
+ t.Fatalf("removed HTTP command builder = %#v", got)
+ }
+ base := transport.NewClient(nil)
+ if buildPluginAuthClient(base, mcptypes.ServerDescriptor{AuthHeaders: map[string]string{"X": "Y"}}) != base {
+ t.Fatal("client without bearer token was copied")
+ }
+ authClient := buildPluginAuthClient(base, mcptypes.ServerDescriptor{Endpoint: "https://api.example.test", AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
+ if authClient == base || authClient.AuthToken != "token" || len(authClient.TrustedDomains) != 2 || authClient.ExtraHeaders["X"] != "Y" {
+ t.Fatalf("plugin auth client = %#v", authClient)
+ }
+ registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
+ registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
+ registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
+ if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
+ t.Fatalf("registered plugin auth = %#v, %v", got, ok)
+ }
+
+ server := appRPCServer(t, true, true)
+ oldEdition := edition.Get()
+ edition.Override(&edition.Hooks{StaticServers: func() []edition.ServerInfo {
+ return []edition.ServerInfo{{ID: "devdoc", Endpoint: server.URL}}
+ }})
+ t.Cleanup(func() { edition.Override(oldEdition); server.Close() })
+ fetcher := newHelperToolFetcher()
+ for range 2 {
+ schemas, err := fetcher(context.Background(), "devdoc")
+ if err != nil || schemas["tool"].Name != "tool" {
+ t.Fatalf("helper schemas = %#v, %v", schemas, err)
+ }
+ }
+ if _, err := fetcher(context.Background(), "missing"); err == nil {
+ t.Fatal("missing helper source succeeded")
+ }
+ if _, err := helperSourceEndpoint("op-app"); err != nil {
+ t.Fatal(err)
+ }
+ if got := inputSchemaProperties(nil); got == nil || len(got) != 0 {
+ t.Fatalf("nil properties = %#v", got)
+ }
+ if got := inputSchemaProperties(map[string]any{"properties": "bad"}); got == nil || len(got) != 0 {
+ t.Fatalf("bad properties = %#v", got)
+ }
+ if got := inputSchemaRequired(nil); got != nil {
+ t.Fatalf("nil required = %#v", got)
+ }
+ if got := inputSchemaRequired(map[string]any{"required": "bad"}); got != nil {
+ t.Fatalf("bad required = %#v", got)
+ }
+}
+
+func TestStdioDiscoveryAndRegistryCoverage(t *testing.T) {
+ p := &plugin.Plugin{Manifest: plugin.Manifest{Name: "stdio-plugin", Description: "demo"}}
+ timeouts := pluginColdTimeouts{stdio: 2 * time.Second}
+ notStarted := transport.NewStdioClient("ignored", nil, nil)
+ if got := discoverStdioTools(p, plugin.StdioServerClient{Key: "bad", Client: notStarted}, timeouts); got != nil {
+ t.Fatalf("unstarted stdio tools = %#v", got)
+ }
+
+ script := filepath.Join(t.TempDir(), "server.sh")
+ body := `#!/bin/sh
+while IFS= read -r line; do
+ case "$line" in
+ *initialize*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}' ;;
+ *tools/list*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"echo","inputSchema":{}}]}}' ;;
+ esac
+done
+`
+ if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ client := transport.NewStdioClient(script, nil, nil)
+ if err := client.Start(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ defer client.Stop()
+ sc := plugin.StdioServerClient{Key: "local", Client: client}
+ tools := discoverStdioTools(p, sc, timeouts)
+ if len(tools) != 1 {
+ t.Fatalf("stdio tools = %#v", tools)
+ }
+ if got := registerStdioServer(p, sc, &coverageRunner{}, timeouts); got != nil {
+ t.Fatalf("stdio commands = %#v", got)
+ }
+ if got := buildStdioCommands(p, sc, nil, &coverageRunner{}); got != nil {
+ t.Fatalf("empty stdio commands = %#v", got)
+ }
+ if got, ok := LookupStdioClient("local"); !ok || got != client {
+ t.Fatalf("stdio suffix lookup = %#v, %v", got, ok)
+ }
+ if !StopStdioClient("stdio-plugin/local") || StopStdioClient("missing") {
+ t.Fatal("stdio stop result mismatch")
+ }
+ RegisterStdioClient("one", transport.NewStdioClient("ignored", nil, nil))
+ RegisterStdioClient("two", transport.NewStdioClient("ignored", nil, nil))
+ StopStdioClientsByPlugin("missing")
+ StopStdioClientsByPlugin("")
+ StopAllStdioClients()
+ if newPipelineEngine() == nil {
+ t.Fatal("pipeline engine is nil")
+ }
+}
+
+func TestRawAPIAndTokenCoverage(t *testing.T) {
+ cmd := &cobra.Command{Use: "api"}
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetContext(context.Background())
+
+ invalid := []struct {
+ args []string
+ gf GlobalFlags
+ af apiFlags
+ }{
+ {[]string{"GET", "/x?a=1"}, GlobalFlags{}, apiFlags{}},
+ {[]string{"TRACE", "/x"}, GlobalFlags{}, apiFlags{}},
+ {[]string{"GET", "../x"}, GlobalFlags{}, apiFlags{}},
+ {[]string{"GET", "/x"}, GlobalFlags{}, apiFlags{params: "x\x00"}},
+ {[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{data: "x\x00"}},
+ {[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{params: "-", data: "-"}},
+ {[]string{"GET", "/x"}, GlobalFlags{Output: "x"}, apiFlags{pageAll: true}},
+ {[]string{"GET", "/x"}, GlobalFlags{}, apiFlags{params: "{"}},
+ {[]string{"POST", "/x"}, GlobalFlags{}, apiFlags{data: "{"}},
+ {[]string{"GET", "https://evil.test/x"}, GlobalFlags{Token: "t"}, apiFlags{}},
+ }
+ for _, tc := range invalid {
+ if err := runAPI(cmd, tc.args, &tc.gf, &tc.af); err == nil {
+ t.Fatalf("invalid API %#v succeeded", tc)
+ }
+ }
+ for _, raw := range []string{"", "a=1&empty=&=x", "a=1&b=2"} {
+ if got := parseQueryStringToJSON(raw); got == "" {
+ t.Fatalf("query JSON %q empty", raw)
+ }
+ }
+ if got, err := resolveRawAPIToken(context.Background(), " token "); err != nil || got != "token" {
+ t.Fatalf("explicit raw token = %q, %v", got, err)
+ }
+ authpkg.SetClientID("")
+ authpkg.SetClientSecret("")
+ if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
+ t.Fatal("missing app credentials succeeded")
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if strings.Contains(r.URL.Path, "fail") {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = io.WriteString(w, `{"error":"bad"}`)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{1}, "hasMore": false})
+ }))
+ defer server.Close()
+ host := strings.TrimPrefix(server.URL, "http://")
+ host = strings.Split(host, ":")[0]
+ apiclient.AllowedHosts[host] = true
+ t.Cleanup(func() { delete(apiclient.AllowedHosts, host) })
+ gf := &GlobalFlags{Token: "token", DryRun: true, Format: "json", Timeout: 1}
+ af := &apiFlags{baseURL: server.URL}
+ if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
+ t.Fatalf("API dry run = %q, %v", out.String(), err)
+ }
+ out.Reset()
+ gf.DryRun = false
+ if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
+ t.Fatalf("API request = %q, %v", out.String(), err)
+ }
+ out.Reset()
+ af.pageAll = true
+ if err := runAPI(cmd, []string{"GET", "/ok"}, gf, af); err != nil || out.Len() == 0 {
+ t.Fatalf("API pagination = %q, %v", out.String(), err)
+ }
+ client := apiclient.NewClient("token", server.URL)
+ if err := runPaginated(context.Background(), client, apiclient.RawAPIRequest{Method: "GET", Path: "/fail"}, &apiFlags{}, apiclient.ResponseOptions{Out: io.Discard, ErrOut: io.Discard}); err == nil {
+ t.Fatal("failed pagination succeeded")
+ }
+
+ if _, err := ResolveAuxiliaryAccessToken(context.Background(), "", ""); err == nil {
+ t.Fatal("empty auxiliary config succeeded")
+ }
+ if got, err := ResolveAuxiliaryAccessToken(context.Background(), "ignored", " explicit "); err != nil || got != "explicit" {
+ t.Fatalf("explicit auxiliary token = %q, %v", got, err)
+ }
+ t.Setenv(keychain.StorageDirEnv, t.TempDir())
+ dir := t.TempDir()
+ if err := authpkg.SaveTokenData(dir, &authpkg.TokenData{AccessToken: "saved", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := resolveAccessTokenFromDir(context.Background(), dir); err != nil || got != "saved" {
+ t.Fatalf("saved access token = %q, %v", got, err)
+ }
+ t.Setenv(keychain.StorageDirEnv, t.TempDir())
+ missing := t.TempDir()
+ if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
+ t.Fatalf("missing access token = %q, %v", got, err)
+ }
+ if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
+ t.Fatal("missing auxiliary credentials succeeded")
+ }
+ if _, err := ForceRefreshAccessToken(context.Background(), ""); err == nil {
+ t.Fatal("empty force refresh config succeeded")
+ }
+ if _, err := ForceRefreshAccessToken(context.Background(), missing); err == nil {
+ t.Fatal("missing force refresh token succeeded")
+ }
+}
+
+func TestRootUtilityAndTimingCoverage(t *testing.T) {
+ _ = resolveVerbosity(nil)
+ for _, flags := range []struct {
+ debug bool
+ verbose bool
+ format string
+ json bool
+ }{{}, {verbose: true}, {debug: true}, {format: "json"}, {format: "table"}, {json: true}} {
+ flagCmd := &cobra.Command{Use: "flags"}
+ flagCmd.Flags().Bool("debug", flags.debug, "")
+ flagCmd.Flags().Bool("verbose", flags.verbose, "")
+ flagCmd.Flags().String("format", flags.format, "")
+ flagCmd.Flags().Bool("json", false, "")
+ if flags.json {
+ _ = flagCmd.Flags().Set("json", "true")
+ }
+ _ = resolveVerbosity(flagCmd)
+ _ = commandRequestsJSONErrors(flagCmd)
+ _ = wantsJSONErrors(flagCmd)
+ }
+ _ = commandRequestsJSONErrors(nil)
+ _ = wantsJSONErrors(nil)
+ if got, changed := normalizeProfileFlagArgs([]string{"--profile", "a,", "b"}); !changed || len(got) == 0 {
+ t.Fatalf("profile args = %#v, %v", got, changed)
+ }
+ if _, changed := normalizeProfileFlagArgs([]string{"--profile"}); changed {
+ t.Fatal("incomplete profile flag changed")
+ }
+ if preparseProfileFlag([]string{"--profile=a"}) != "a" || preparseProfileFlag([]string{"--profile", "b"}) != "b" || preparseProfileFlag(nil) != "" {
+ t.Fatal("profile preparse mismatch")
+ }
+ if !argsChanged([]string{"a"}, []string{"b"}) || argsChanged([]string{"a"}, []string{"a"}) {
+ t.Fatal("argsChanged mismatch")
+ }
+ for _, raw := range []string{"", "bad", "0", "15ms"} {
+ t.Setenv(cli.PluginColdTimeoutEnv, raw)
+ _ = resolvePluginColdTimeouts()
+ }
+
+ cmd := &cobra.Command{Use: "root"}
+ cmd.SetContext(context.Background())
+ cmd.Flags().String("output", "", "")
+ if err := configureOutputSink(cmd); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "nested", "out.txt")
+ _ = cmd.Flags().Set("output", path)
+ if err := configureOutputSink(cmd); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = io.WriteString(cmd.OutOrStdout(), "data")
+ if err := closeOutputSink(cmd); err != nil {
+ t.Fatal(err)
+ }
+ local := &cobra.Command{Use: "local"}
+ local.SetContext(context.Background())
+ local.SetOut(io.Discard)
+ local.Flags().String("output", "", "")
+ if err := configureOutputSink(local); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateOptionalPath("--x", ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateOptionalPath("--x", "bad\x00path"); err == nil {
+ t.Fatal("unsafe path succeeded")
+ }
+
+ root := &cobra.Command{Use: "root", Short: "root"}
+ bindPersistentFlags(root, &GlobalFlags{})
+ root.AddCommand(&cobra.Command{Use: "alpha", Short: "alpha"}, &cobra.Command{Use: "hidden", Hidden: true})
+ configureRootHelp(root)
+ var help bytes.Buffer
+ root.SetOut(&help)
+ _ = root.Help()
+ renderRootGlobalFlags(root)
+ _ = visiblePersistentFlags(root)
+ for _, command := range root.Commands() {
+ _ = commandShort(command)
+ }
+ _ = visibleMCPRootCommands(root)
+ _ = visibleUtilityRootCommands(root)
+
+ tc := NewTimingCollector()
+ tc.Record("a", time.Microsecond)
+ tc.Record("b", 2*time.Second)
+ for _, d := range []time.Duration{time.Nanosecond, time.Microsecond, time.Millisecond, time.Second} {
+ _ = formatDuration(d)
+ }
+ for _, debug := range []bool{false, true} {
+ if debug {
+ t.Setenv(PerfDebugEnv, "1")
+ } else {
+ t.Setenv(PerfDebugEnv, "")
+ }
+ tc.PrintIfEnabled()
+ }
+ var timingOut bytes.Buffer
+ tc.Print(&timingOut)
+ if timingOut.Len() == 0 {
+ t.Fatal("timing output empty")
+ }
+ t.Setenv("HOME", t.TempDir())
+ if defaultPerfReportPath() == "" {
+ t.Fatal("default perf path empty")
+ }
+ t.Setenv(PerfReportEnv, "auto")
+ tc.WriteReportIfEnabled("v", "cmd")
+ if _, err := LoadLatestReport(); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(PerfReportEnv, "")
+ tc.WriteReportIfEnabled("v", "cmd")
+ _ = exeRelativeConfigDir()
+
+ merged := mergeTopLevelCommands([]*cobra.Command{{Use: "a"}, {Use: "a"}, {Use: "b"}, nil})
+ if len(merged) != 2 {
+ t.Fatalf("merged commands = %#v", merged)
+ }
+ dedupRoot := &cobra.Command{Use: "root"}
+ dedupRoot.AddCommand(&cobra.Command{Use: "same"}, &cobra.Command{Use: "same"})
+ deduplicateCommands(dedupRoot)
+ addPluginCommandsSafe(dedupRoot, []*cobra.Command{{Use: "same"}, {Use: "new"}})
+ _ = newCompletionCommand(dedupRoot)
+ _ = newCatalogCommand(nil)
+ _ = newConfigCommand()
+ _ = newCacheCommand()
+ _ = newVersionCommand()
+ _ = newRecoveryCommand(context.Background(), nil, &GlobalFlags{})
+ _ = newAPICommand(&GlobalFlags{})
+ _ = NewRootCommand(context.Background())
+}
diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go
index 3c7f7839..4cd5d5bf 100644
--- a/internal/app/skill_command.go
+++ b/internal/app/skill_command.go
@@ -48,12 +48,14 @@ func init() {
const (
// legacySkillAPIHost is the legacy skill market host used by the old cli.
legacySkillAPIHost = "https://mcp.dingtalk.com"
- // skillDownloadEndpoint is the API endpoint for downloading skills.
- skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
// skillDownloadTimeout is the timeout for skill download operations.
skillDownloadTimeout = 5 * time.Minute
)
+// skillDownloadEndpoint is variable so tests and private distributions can
+// exercise the download flow without contacting the public service.
+var skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
+
// downloadSkillResponse represents the API response for skill download.
type downloadSkillResponse struct {
Success bool `json:"success"`
diff --git a/internal/auth/app_config.go b/internal/auth/app_config.go
index bd4cf88e..ab8b5ccb 100644
--- a/internal/auth/app_config.go
+++ b/internal/auth/app_config.go
@@ -55,6 +55,17 @@ var (
cachedAppConfigMu sync.RWMutex
)
+var (
+ appConfigStoreSecret = StoreSecret
+ appConfigMarshalIndent = json.MarshalIndent
+ appConfigAtomicWrite = helpers.AtomicWriteJSON
+ appConfigReadFile = os.ReadFile
+ appConfigRemove = os.Remove
+ appConfigLoad = LoadAppConfig
+ appConfigResolveSecret = ResolveSecret
+ appConfigBeforeResolveLock = func() {}
+)
+
// Cached resolved credentials (avoid repeated keychain access).
var (
cachedResolvedID string
@@ -98,7 +109,7 @@ func LoadAppConfig(configDir string) (*AppConfig, error) {
func SaveAppConfig(configDir string, config *AppConfig) error {
// Store plain secret in keychain, convert to reference
if config.ClientSecret.IsPlain() && config.ClientID != "" {
- storedRef, err := StoreSecret(config.ClientID, config.ClientSecret)
+ storedRef, err := appConfigStoreSecret(config.ClientID, config.ClientSecret)
if err != nil {
return fmt.Errorf("storing client secret: %w", err)
}
@@ -111,13 +122,13 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
}
config.UpdatedAt = time.Now()
- data, err := json.MarshalIndent(config, "", " ")
+ data, err := appConfigMarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
path := GetAppConfigPath(configDir)
- if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
+ if err := appConfigAtomicWrite(path, append(data, '\n')); err != nil {
return fmt.Errorf("writing app config: %w", err)
}
cleanupLegacySiblingAppConfig(configDir, config)
@@ -143,11 +154,7 @@ func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
}
legacyPath := filepath.Join(configDir, appConfigFile)
- if legacyPath == GetAppConfigPath(configDir) {
- return
- }
-
- data, err := os.ReadFile(legacyPath)
+ data, err := appConfigReadFile(legacyPath)
if err != nil {
return
}
@@ -160,7 +167,7 @@ func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
return
}
- if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
+ if err := appConfigRemove(legacyPath); err != nil && !os.IsNotExist(err) {
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
}
}
@@ -175,7 +182,7 @@ func DeleteAppConfig(configDir string) error {
// Remove config file
path := GetAppConfigPath(configDir)
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ if err := appConfigRemove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing app config: %w", err)
}
@@ -215,7 +222,7 @@ func GetCachedAppConfig(configDir string) *AppConfig {
// ReloadAppConfig forces a reload of the app configuration from disk.
// This should be called after SaveAppConfig to ensure the cache is updated.
func ReloadAppConfig(configDir string) (*AppConfig, error) {
- cfg, err := LoadAppConfig(configDir)
+ cfg, err := appConfigLoad(configDir)
if err != nil {
return nil, err
}
@@ -248,6 +255,7 @@ func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
cachedResolvedMu.RUnlock()
// Slow path: load and cache
+ appConfigBeforeResolveLock()
cachedResolvedMu.Lock()
defer cachedResolvedMu.Unlock()
// Double-check after acquiring write lock
@@ -258,7 +266,7 @@ func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
cfg := GetCachedAppConfig(configDir)
if cfg != nil {
cachedResolvedID = cfg.ClientID
- if secret, err := ResolveSecret(cfg.ClientSecret); err == nil {
+ if secret, err := appConfigResolveSecret(cfg.ClientSecret); err == nil {
cachedResolvedSecret = secret
}
}
diff --git a/internal/auth/app_token.go b/internal/auth/app_token.go
index 4dd07343..fee4da34 100644
--- a/internal/auth/app_token.go
+++ b/internal/auth/app_token.go
@@ -36,6 +36,15 @@ const (
tokenExpiryBuffer = 5 * time.Minute
)
+var (
+ appTokenMarshalIndent = json.MarshalIndent
+ appTokenMarshal = json.Marshal
+ appTokenNewRequest = http.NewRequestWithContext
+ appTokenKeychainSet = keychain.Set
+ appTokenKeychainGet = keychain.Get
+ appTokenKeychainRemove = keychain.Remove
+)
+
// AppTokenData stores the app-level access token obtained from the unified
// POST /v1.0/oauth2/accessToken endpoint. It works for both new-style
// (api.dingtalk.com) and legacy (oapi.dingtalk.com) APIs — the auth method
@@ -63,7 +72,7 @@ func SaveAppTokenData(data *AppTokenData) error {
return fmt.Errorf("clientID is required for saving app token data")
}
data.UpdatedAt = time.Now()
- jsonData, err := json.MarshalIndent(data, "", " ")
+ jsonData, err := appTokenMarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal app token data: %w", err)
}
@@ -74,7 +83,7 @@ func SaveAppTokenData(data *AppTokenData) error {
}()
account := appTokenPrefix + data.ClientID
- if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
+ if err := appTokenKeychainSet(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save app token to keychain: %w", err)
}
return nil
@@ -87,7 +96,7 @@ func LoadAppTokenData(clientID string) (*AppTokenData, error) {
return nil, fmt.Errorf("clientID is required for loading app token data")
}
account := appTokenPrefix + clientID
- jsonStr, err := keychain.Get(keychain.Service, account)
+ jsonStr, err := appTokenKeychainGet(keychain.Service, account)
if err != nil {
return nil, nil // Not found is not an error
}
@@ -108,7 +117,7 @@ func DeleteAppTokenData(clientID string) error {
return nil
}
account := appTokenPrefix + clientID
- return keychain.Remove(keychain.Service, account)
+ return appTokenKeychainRemove(keychain.Service, account)
}
// --- Token Fetch Function ---
@@ -125,12 +134,12 @@ func FetchAppToken(ctx context.Context, appKey, appSecret string) (token string,
"appKey": appKey,
"appSecret": appSecret,
}
- bodyBytes, err := json.Marshal(body)
+ bodyBytes, err := appTokenMarshal(body)
if err != nil {
return "", 0, fmt.Errorf("marshal request body: %w", err)
}
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
+ req, err := appTokenNewRequest(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", 0, fmt.Errorf("creating request: %w", err)
}
diff --git a/internal/auth/browser_unix.go b/internal/auth/browser_unix.go
index cbe422b6..06bf3e31 100644
--- a/internal/auth/browser_unix.go
+++ b/internal/auth/browser_unix.go
@@ -22,7 +22,15 @@ import (
"runtime"
)
+var browserStartCommand = func(name string, args ...string) error {
+ return exec.Command(name, args...).Start()
+}
+
func openBrowser(rawURL string) error {
+ return openBrowserForOS(runtime.GOOS, rawURL)
+}
+
+func openBrowserForOS(goos, rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
@@ -31,14 +39,14 @@ func openBrowser(rawURL string) error {
return fmt.Errorf("refused to open URL with disallowed scheme %q", parsed.Scheme)
}
- var cmd *exec.Cmd
- switch runtime.GOOS {
+ var command string
+ switch goos {
case "darwin":
- cmd = exec.Command("open", rawURL)
+ command = "open"
case "linux":
- cmd = exec.Command("xdg-open", rawURL)
+ command = "xdg-open"
default:
- return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
+ return fmt.Errorf("unsupported platform: %s", goos)
}
- return cmd.Start()
+ return browserStartCommand(command, rawURL)
}
diff --git a/internal/auth/coverage_edges_test.go b/internal/auth/coverage_edges_test.go
new file mode 100644
index 00000000..f014ec2e
--- /dev/null
+++ b/internal/auth/coverage_edges_test.go
@@ -0,0 +1,2924 @@
+package auth
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type errorReader struct{}
+
+func (errorReader) Read([]byte) (int, error) { return 0, errors.New("read") }
+
+type duplicateFailAfterWriter struct {
+ remaining int
+}
+
+func (w *duplicateFailAfterWriter) Write(p []byte) (int, error) {
+ if w.remaining <= 0 {
+ return 0, errors.New("write")
+ }
+ if len(p) > w.remaining {
+ n := w.remaining
+ w.remaining = 0
+ return n, errors.New("write")
+ }
+ w.remaining -= len(p)
+ return len(p), nil
+}
+
+type duplicateFakePortableTemp struct {
+ name string
+ writeErr error
+ chmodErr error
+ syncErr error
+ closeErr error
+ closeCall int
+}
+
+func (f *duplicateFakePortableTemp) Write(p []byte) (int, error) {
+ if f.writeErr != nil {
+ return 0, f.writeErr
+ }
+ return len(p), nil
+}
+func (f *duplicateFakePortableTemp) Name() string { return f.name }
+func (f *duplicateFakePortableTemp) Chmod(os.FileMode) error { return f.chmodErr }
+func (f *duplicateFakePortableTemp) Sync() error { return f.syncErr }
+func (f *duplicateFakePortableTemp) Close() error { f.closeCall++; return f.closeErr }
+
+type fakeSecureTemp struct {
+ writeErr error
+ syncErr error
+ closeErr error
+}
+
+func (f *fakeSecureTemp) Write(p []byte) (int, error) {
+ if f.writeErr != nil {
+ return 0, f.writeErr
+ }
+ return len(p), nil
+}
+func (f *fakeSecureTemp) Sync() error { return f.syncErr }
+func (f *fakeSecureTemp) Close() error { return f.closeErr }
+
+type failAfterWriter struct {
+ remaining int
+}
+
+func (w *failAfterWriter) Write(p []byte) (int, error) {
+ if w.remaining <= 0 {
+ return 0, errors.New("write")
+ }
+ if len(p) > w.remaining {
+ n := w.remaining
+ w.remaining = 0
+ return n, errors.New("write")
+ }
+ w.remaining -= len(p)
+ return len(p), nil
+}
+
+type fakePortableTemp struct {
+ name string
+ writeErr error
+ chmodErr error
+ syncErr error
+ closeErr error
+ closeCall int
+}
+
+func (f *fakePortableTemp) Write(p []byte) (int, error) {
+ if f.writeErr != nil {
+ return 0, f.writeErr
+ }
+ return len(p), nil
+}
+func (f *fakePortableTemp) Name() string { return f.name }
+func (f *fakePortableTemp) Chmod(os.FileMode) error { return f.chmodErr }
+func (f *fakePortableTemp) Sync() error { return f.syncErr }
+func (f *fakePortableTemp) Close() error { f.closeCall++; return f.closeErr }
+
+func TestAppTokenEdges(t *testing.T) {
+ if err := SaveAppTokenData(&AppTokenData{}); err == nil {
+ t.Fatal("empty client ID save succeeded")
+ }
+ if _, err := LoadAppTokenData(""); err == nil {
+ t.Fatal("empty client ID load succeeded")
+ }
+ if err := DeleteAppTokenData(""); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := LoadAppTokenData("missing"); err != nil || got != nil {
+ t.Fatalf("missing app token = %#v, %v", got, err)
+ }
+ data := &AppTokenData{ClientID: "app-one", AccessToken: "cached", ExpiresAt: time.Now().Add(time.Hour)}
+ if err := SaveAppTokenData(data); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := LoadAppTokenData("app-one")
+ if err != nil || loaded.AccessToken != "cached" || loaded.UpdatedAt.IsZero() {
+ t.Fatalf("loaded app token = %#v, %v", loaded, err)
+ }
+ if err := keychain.Set(keychain.Service, appTokenPrefix+"malformed", "{"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadAppTokenData("malformed"); err == nil {
+ t.Fatal("malformed app token succeeded")
+ }
+ if err := DeleteAppTokenData("app-one"); err != nil {
+ t.Fatal(err)
+ }
+
+ oldClient := appTokenHTTPClient
+ t.Cleanup(func() { appTokenHTTPClient = oldClient })
+ response := func(status int, body string) {
+ appTokenHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }, nil
+ })}
+ }
+ response(http.StatusOK, "{\"accessToken\":\"fresh\",\"expireIn\":0}")
+ token, expires, err := FetchAppToken(context.Background(), "key", "secret")
+ if err != nil || token != "fresh" || expires <= 0 {
+ t.Fatalf("FetchAppToken = %q %d %v", token, expires, err)
+ }
+ response(http.StatusBadGateway, strings.Repeat("x", 250))
+ if _, _, err := FetchAppToken(context.Background(), "key", "secret"); err == nil || !strings.Contains(err.Error(), "...") {
+ t.Fatalf("HTTP app token error = %v", err)
+ }
+ response(http.StatusOK, "{")
+ if _, _, err := FetchAppToken(context.Background(), "key", "secret"); err == nil {
+ t.Fatal("malformed app token response succeeded")
+ }
+ response(http.StatusOK, "{}")
+ if _, _, err := FetchAppToken(context.Background(), "key", "secret"); err == nil {
+ t.Fatal("empty app token response succeeded")
+ }
+ appTokenHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("network")
+ })}
+ if _, _, err := FetchAppToken(context.Background(), "key", "secret"); err == nil {
+ t.Fatal("network app token request succeeded")
+ }
+ appTokenHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(errorReader{}), Header: make(http.Header)}, nil
+ })}
+ if _, _, err := FetchAppToken(context.Background(), "key", "secret"); err == nil {
+ t.Fatal("app token read failure succeeded")
+ }
+
+ provider := &AppTokenProvider{}
+ if _, err := provider.GetToken(context.Background()); err == nil {
+ t.Fatal("missing app credentials succeeded")
+ }
+ cached := &AppTokenData{ClientID: "cache-key", AccessToken: "cached", ExpiresAt: time.Now().Add(time.Hour)}
+ if err := SaveAppTokenData(cached); err != nil {
+ t.Fatal(err)
+ }
+ provider = &AppTokenProvider{AppKey: "cache-key", AppSecret: "secret"}
+ if got, err := provider.GetToken(context.Background()); err != nil || got != "cached" {
+ t.Fatalf("cached provider token = %q, %v", got, err)
+ }
+ response(http.StatusOK, "{\"accessToken\":\"provider-fresh\",\"expireIn\":7200}")
+ provider = &AppTokenProvider{AppKey: "fresh-key", AppSecret: "secret"}
+ if got, err := provider.GetToken(context.Background()); err != nil || got != "provider-fresh" {
+ t.Fatalf("fresh provider token = %q, %v", got, err)
+ }
+ response(http.StatusInternalServerError, "down")
+ provider = &AppTokenProvider{AppKey: "failed-key", AppSecret: "secret"}
+ if _, err := provider.GetToken(context.Background()); err == nil {
+ t.Fatal("failed provider fetch succeeded")
+ }
+}
+
+func TestDeviceFlowRequestAndOutputEdges(t *testing.T) {
+ var buf bytes.Buffer
+ p := &DeviceFlowProvider{Output: &buf, clientID: "client", scope: "scope", httpClient: http.DefaultClient}
+ p.SetScope("custom")
+ (*DeviceFlowProvider)(nil).SetScope("ignored")
+ if p.scope != "custom" || p.output() != &buf || (*DeviceFlowProvider)(nil).output() != io.Discard {
+ t.Fatal("device flow setters/output failed")
+ }
+
+ type deviceMode string
+ var mode deviceMode
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch mode {
+ case "malformed":
+ _, _ = io.WriteString(w, "{")
+ case "service-error":
+ writeServiceResult(w, false, nil, "bad", "failed")
+ case "bad-result":
+ _, _ = io.WriteString(w, "{\"success\":true,\"result\":\"bad\"}")
+ case "empty":
+ writeServiceResult(w, true, DeviceAuthResponse{}, "", "")
+ case "defaults":
+ writeServiceResult(w, true, DeviceAuthResponse{DeviceCode: "dc", UserCode: "uc", Interval: 99, ExpiresIn: -1}, "", "")
+ case "token-ok":
+ writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "code"}, "", "")
+ case "poll-fail":
+ _ = json.NewEncoder(w).Encode(DevicePollResponse{Success: false, Code: "bad", Message: "failed"})
+ case "poll-business":
+ _ = json.NewEncoder(w).Encode(DevicePollResponse{Success: false, Data: DevicePollData{Status: StatusRejected}})
+ default:
+ http.Error(w, "down", http.StatusBadGateway)
+ }
+ }))
+ defer srv.Close()
+ p.SetBaseURL(srv.URL)
+ p.SetTerminalBaseURL(srv.URL)
+ p.httpClient = srv.Client()
+
+ for _, m := range []deviceMode{"malformed", "service-error", "bad-result", "empty"} {
+ mode = m
+ if _, err := p.requestDeviceCode(context.Background()); err == nil {
+ t.Errorf("requestDeviceCode mode %q succeeded", m)
+ }
+ }
+ mode = "defaults"
+ if got, err := p.requestDeviceCode(context.Background()); err != nil || got.Interval != defaultPollInterval || got.ExpiresIn != 900 {
+ t.Fatalf("request defaults = %#v, %v", got, err)
+ }
+ for _, m := range []deviceMode{"malformed", "service-error", "bad-result"} {
+ mode = m
+ if _, err := p.pollDeviceToken(context.Background(), "dc"); err == nil {
+ t.Errorf("pollDeviceToken mode %q succeeded", m)
+ }
+ }
+ mode = "token-ok"
+ if got, err := p.pollDeviceToken(context.Background(), "dc"); err != nil || got.AuthCode != "code" {
+ t.Fatalf("poll token = %#v, %v", got, err)
+ }
+ mode = "malformed"
+ if _, err := p.pollDeviceStatus(context.Background(), "flow"); err == nil {
+ t.Fatal("malformed poll status succeeded")
+ }
+ mode = "poll-fail"
+ if _, err := p.pollDeviceStatus(context.Background(), "flow"); err == nil {
+ t.Fatal("failed poll status succeeded")
+ }
+ mode = "poll-business"
+ if got, err := p.pollDeviceStatus(context.Background(), "flow"); err != nil || got.Data.Status != StatusRejected {
+ t.Fatalf("business poll status = %#v, %v", got, err)
+ }
+
+ badClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("network")
+ })}
+ p.httpClient = badClient
+ if _, err := p.postForm(context.Background(), "://bad", url.Values{}); err == nil {
+ t.Fatal("invalid form URL succeeded")
+ }
+ if _, err := p.postForm(context.Background(), "https://example.test", url.Values{}); err == nil {
+ t.Fatal("form network error succeeded")
+ }
+ if _, err := p.doGet(context.Background(), "://bad"); err == nil {
+ t.Fatal("invalid GET URL succeeded")
+ }
+ if _, err := p.doGet(context.Background(), "https://example.test"); err == nil {
+ t.Fatal("GET network error succeeded")
+ }
+ readClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(errorReader{}), Header: make(http.Header)}, nil
+ })}
+ p.httpClient = readClient
+ if _, err := p.postForm(context.Background(), "https://example.test", url.Values{}); err == nil {
+ t.Fatal("form read error succeeded")
+ }
+ if _, err := p.doGet(context.Background(), "https://example.test"); err == nil {
+ t.Fatal("GET read error succeeded")
+ }
+
+ if got := truncateBody([]byte("short"), 10); got != "short" {
+ t.Fatalf("short truncate = %q", got)
+ }
+ if got := truncateBody([]byte("long-value"), 4); got != "long...(truncated)" {
+ t.Fatalf("long truncate = %q", got)
+ }
+ dfPrintStep(&buf, 1, "step", 2)
+ dfPrintDeviceCodeBox(&buf, &DeviceAuthResponse{UserCode: "code", VerificationURI: "url", VerificationURIComplete: "complete", ExpiresIn: 1})
+ dfPrintBox(&buf, []string{strings.Repeat("x", 55)})
+ dfPrintPollResult(&buf, "authorized", "ok")
+ dfPrintPollResult(&buf, "pending", "wait")
+ dfPrintPollResult(&buf, "unknown", "bad")
+ dfPrintWarning(&buf, "warning")
+ if isInvalidGrantError(nil) || !isInvalidGrantError(errors.New("invalid_grant")) ||
+ !isInvalidGrantError(errors.New("code expired")) || isInvalidGrantError(errors.New("other")) {
+ t.Fatal("invalid grant detection failed")
+ }
+}
+
+func TestDeviceFlowWaitImmediateEdges(t *testing.T) {
+ p := &DeviceFlowProvider{Output: io.Discard}
+ if _, err := p.waitForAuthorizationByFlowID(context.Background(), &DeviceAuthResponse{ExpiresIn: 0}); err == nil {
+ t.Fatal("flow deadline succeeded")
+ }
+ if _, err := p.waitForAuthorizationByDeviceCode(context.Background(), &DeviceAuthResponse{ExpiresIn: 0}); err == nil {
+ t.Fatal("device deadline succeeded")
+ }
+ oldAfter := deviceFlowAfter
+ t.Cleanup(func() { deviceFlowAfter = oldAfter })
+ deviceFlowAfter = func(time.Duration) <-chan time.Time {
+ return make(chan time.Time)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := p.waitForAuthorizationByFlowID(ctx, &DeviceAuthResponse{ExpiresIn: 10, Interval: 1, FlowID: "f"}); err == nil {
+ t.Fatal("canceled flow wait succeeded")
+ }
+ if _, err := p.waitForAuthorizationByDeviceCode(ctx, &DeviceAuthResponse{ExpiresIn: 10, Interval: 1, DeviceCode: "d"}); err == nil {
+ t.Fatal("canceled device wait succeeded")
+ }
+}
+
+func TestDeviceFlowLoginRetry(t *testing.T) {
+ oldClient := oauthHTTPClient
+ oldAfter := deviceFlowAfter
+ oldBrowser := deviceOpenBrowser
+ t.Cleanup(func() {
+ oauthHTTPClient = oldClient
+ deviceFlowAfter = oldAfter
+ deviceOpenBrowser = oldBrowser
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ SetClientID("")
+ })
+ deviceFlowAfter = func(time.Duration) <-chan time.Time {
+ ch := make(chan time.Time, 1)
+ ch <- time.Now()
+ return ch
+ }
+ deviceOpenBrowser = func(string) error { return errors.New("browser") }
+ var exchanges int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.HasSuffix(r.URL.Path, ClientIDPath):
+ _ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "device-client"})
+ case strings.HasSuffix(r.URL.Path, DeviceCodePath):
+ writeServiceResult(w, true, DeviceAuthResponse{
+ DeviceCode: "dc", UserCode: "uc", VerificationURI: "https://verify",
+ VerificationURIComplete: "https://verify/full", ExpiresIn: 60, Interval: 1, FlowID: "flow",
+ }, "", "")
+ case strings.HasSuffix(r.URL.Path, DevicePollPath):
+ _ = json.NewEncoder(w).Encode(DevicePollResponse{Success: true, Data: DevicePollData{Status: StatusApproved, AuthCode: "code"}})
+ case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
+ exchanges++
+ if exchanges < 3 {
+ http.Error(w, "invalid_grant code expired", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accessToken": "device-access", "refreshToken": "refresh", "expiresIn": 7200, "corpId": "corp-device",
+ })
+ case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
+ _ = json.NewEncoder(w).Encode(CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+ dir := setupMCPConfigDir(t, srv.URL)
+ oauthHTTPClient = srv.Client()
+ p := NewDeviceFlowProvider(dir, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ p.SetBaseURL(srv.URL)
+ p.SetTerminalBaseURL(srv.URL)
+ p.httpClient = srv.Client()
+ p.Output = io.Discard
+ token, err := p.Login(context.Background())
+ if err != nil || token.AccessToken != "device-access" || exchanges != 3 {
+ t.Fatalf("device Login = %#v, %v, exchanges=%d", token, err, exchanges)
+ }
+}
+
+func TestPortablePathHelpers(t *testing.T) {
+ if _, err := cleanPortableName("../escape"); err == nil {
+ t.Fatal("unsafe portable name succeeded")
+ }
+ if got, err := cleanPortableName("./a/b"); err != nil || got != "a/b" {
+ t.Fatalf("clean portable name = %q, %v", got, err)
+ }
+ root := t.TempDir()
+ if _, err := safeJoin(root, "../escape"); err == nil {
+ t.Fatal("unsafe join succeeded")
+ }
+ if got, err := safeJoin(root, "a/b"); err != nil || got != filepath.Join(root, "a", "b") {
+ t.Fatalf("safe join = %q, %v", got, err)
+ }
+ if rel := mustPortableRel(root, filepath.Join(root, "a", "b")); rel != "a/b" {
+ t.Fatalf("portable rel = %q", rel)
+ }
+
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+ if err := writePortableBytes(tw, "file", []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
+ hdr, err := tr.Next()
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(t.TempDir(), "nested", "file")
+ if err := extractPortableEntry(target, hdr, tr); err != nil {
+ t.Fatal(err)
+ }
+ if data, err := os.ReadFile(target); err != nil || string(data) != "x" {
+ t.Fatalf("extracted file = %q, %v", data, err)
+ }
+ if err := extractPortableEntry(target, &tar.Header{Typeflag: tar.TypeSymlink}, strings.NewReader("")); err == nil {
+ t.Fatal("unsupported portable entry succeeded")
+ }
+}
+
+func TestProfilesLifecycleEdges(t *testing.T) {
+ dir := t.TempDir()
+ cfg, err := LoadProfiles(dir)
+ if err != nil || cfg.Version != 1 || len(cfg.Profiles) != 0 {
+ t.Fatalf("missing profiles = %#v, %v", cfg, err)
+ }
+ if err := os.WriteFile(ProfilesPath(dir), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err = LoadProfiles(dir)
+ if err != nil || len(cfg.Profiles) != 0 {
+ t.Fatalf("corrupt profiles = %#v, %v", cfg, err)
+ }
+ if matches, _ := filepath.Glob(ProfilesPath(dir) + ".corrupt-*"); len(matches) != 1 {
+ t.Fatalf("quarantined profiles = %#v", matches)
+ }
+ if err := SaveProfiles(dir, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg = &ProfilesConfig{
+ PrimaryProfile: "missing",
+ CurrentProfile: "missing",
+ PreviousProfile: "missing",
+ Profiles: []Profile{
+ {CorpID: " corp-a ", CorpName: "Acme"},
+ {CorpID: "corp-a", Name: "duplicate"},
+ {CorpID: ""},
+ },
+ }
+ normalizeProfilesConfig(cfg)
+ if len(cfg.Profiles) != 1 || cfg.Profiles[0].Name != "Acme" ||
+ cfg.PrimaryProfile != "corp-a" || cfg.CurrentProfile != "corp-a" || cfg.PreviousProfile != "" {
+ t.Fatalf("normalized profiles = %#v", cfg)
+ }
+
+ if err := upsertProfileFromToken(dir, cfg, nil, true); err != nil {
+ t.Fatal(err)
+ }
+ if err := upsertProfileFromToken(dir, cfg, &TokenData{}, true); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ first := &TokenData{
+ CorpID: "corp-a", CorpName: "Acme Updated", UserID: "u1", UserName: "User",
+ ClientID: "client", ExpiresAt: now.Add(time.Hour), RefreshExpAt: now.Add(24 * time.Hour),
+ }
+ if err := UpsertProfileFromToken(dir, first); err != nil {
+ t.Fatal(err)
+ }
+ second := &TokenData{CorpID: "corp-b", CorpName: "Acme Updated", AccessToken: "b"}
+ if err := UpsertProfileFromTokenWithCurrent(dir, second, true); err != nil {
+ t.Fatal(err)
+ }
+ profilesForAmbiguity, err := LoadProfiles(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := range profilesForAmbiguity.Profiles {
+ profilesForAmbiguity.Profiles[i].Name = "profile-" + profilesForAmbiguity.Profiles[i].CorpID
+ profilesForAmbiguity.Profiles[i].CorpName = "Shared Corp"
+ }
+ if err := SaveProfiles(dir, profilesForAmbiguity); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := ResolveProfile(dir, "Shared Corp"); err == nil || got != nil {
+ t.Fatalf("ambiguous corp name = %#v, %v", got, err)
+ }
+ if got, err := ResolveProfile(dir, "corp-b"); err != nil || got.CorpID != "corp-b" {
+ t.Fatalf("explicit profile = %#v, %v", got, err)
+ }
+ if got, err := ResolveProfile(dir, "missing"); err == nil || got != nil {
+ t.Fatalf("missing profile = %#v, %v", got, err)
+ }
+ if got, err := ResolveProfile(dir, ""); err != nil || got == nil {
+ t.Fatalf("default profile = %#v, %v", got, err)
+ }
+
+ if _, err := SetCurrentProfile(dir, "missing"); err == nil {
+ t.Fatal("setting missing profile succeeded")
+ }
+ if _, err := SetCurrentProfile(dir, "corp-a"); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := UsePreviousProfile(dir); err != nil || got.CorpID != "corp-b" {
+ t.Fatalf("previous profile = %#v, %v", got, err)
+ }
+ if err := MarkProfileStatus(dir, "", ProfileStatusExpired); err != nil {
+ t.Fatal(err)
+ }
+ if err := MarkProfileStatus(dir, "missing", ProfileStatusExpired); err != nil {
+ t.Fatal(err)
+ }
+ if err := MarkProfileStatus(dir, "corp-a", ProfileStatusExpired); err != nil {
+ t.Fatal(err)
+ }
+ if removed, err := RemoveProfile(dir, "corp-b"); err != nil || removed.CorpID != "corp-b" {
+ t.Fatalf("removed profile = %#v, %v", removed, err)
+ }
+ if _, err := RemoveProfile(dir, "missing"); err == nil {
+ t.Fatal("removing missing profile succeeded")
+ }
+ if _, err := RemoveProfile(dir, "corp-a"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := UsePreviousProfile(dir); err == nil {
+ t.Fatal("empty previous profile succeeded")
+ }
+
+ nameCfg := &ProfilesConfig{Profiles: []Profile{
+ {CorpID: "one", Name: "Acme"},
+ {CorpID: "two", Name: "Acme-12345678"},
+ {CorpID: "three", Name: "Acme-12345678-2"},
+ }}
+ if got := chooseProfileName(nameCfg, &TokenData{CorpID: "corp-12345678", CorpName: "Acme"}); got != "Acme-12345678-3" {
+ t.Fatalf("collision profile name = %q", got)
+ }
+ if chooseProfileName(&ProfilesConfig{}, &TokenData{}) != "profile" {
+ t.Fatal("fallback profile name failed")
+ }
+ if shouldRefreshProfileName(nil, first) || shouldRefreshProfileName(&Profile{}, nil) ||
+ !shouldRefreshProfileName(&Profile{}, first) {
+ t.Fatal("profile refresh-name decisions failed")
+ }
+ if findProfile(nil, "x") != nil || findProfile(cfg, "") != nil ||
+ profileIndexByCorpID(nil, "x") != -1 || firstProfileCorpID(nil) != "" {
+ t.Fatal("nil profile helper failed")
+ }
+ if shortCorpID("short") != "short" || shortCorpID("corp-12345678") != "12345678" ||
+ timeOrRFC3339(time.Time{}) != "" {
+ t.Fatal("profile formatting helpers failed")
+ }
+}
+
+func TestProfilesMigrationAndMirror(t *testing.T) {
+ dir := t.TempDir()
+ _ = DeleteTokenDataKeychain()
+ legacy := &TokenData{CorpID: "legacy-corp", CorpName: "Legacy", AccessToken: "token"}
+ if err := SaveTokenDataKeychain(legacy); err != nil {
+ t.Fatal(err)
+ }
+ if err := EnsureProfilesMigration(dir); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := LoadProfiles(dir)
+ if err != nil || len(cfg.Profiles) != 1 {
+ t.Fatalf("migrated profiles = %#v, %v", cfg, err)
+ }
+ if err := SyncLegacyTokenMirror(dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteTokenDataKeychainForCorpID("legacy-corp"); err != nil {
+ t.Fatal(err)
+ }
+ if err := SyncLegacyTokenMirror(dir); err != nil {
+ t.Fatal(err)
+ }
+
+ emptyDir := t.TempDir()
+ _ = DeleteTokenDataKeychain()
+ if err := EnsureProfilesMigration(emptyDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := SyncLegacyTokenMirror(emptyDir); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := resolveProfileForLoad(emptyDir, "missing"); err == nil {
+ t.Fatal("missing load profile succeeded")
+ }
+}
+
+func TestTokenEditionHooksAndCleanup(t *testing.T) {
+ oldHooks := edition.Get()
+ t.Cleanup(func() {
+ edition.Override(oldHooks)
+ SetRuntimeProfile("")
+ })
+ dir := t.TempDir()
+ var saved []byte
+ deleted := false
+ edition.Override(&edition.Hooks{
+ SaveToken: func(_ string, data []byte) error {
+ saved = append([]byte(nil), data...)
+ return nil
+ },
+ LoadToken: func(string) ([]byte, error) { return saved, nil },
+ DeleteToken: func(string) error {
+ deleted = true
+ return nil
+ },
+ })
+ data := &TokenData{AccessToken: "hook-token"}
+ if err := SaveTokenData(dir, data); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := LoadTokenData(dir); err != nil || got.AccessToken != "hook-token" {
+ t.Fatalf("hook load = %#v, %v", got, err)
+ }
+ if _, err := LoadTokenDataForProfile(dir, "profile"); err == nil {
+ t.Fatal("hook profile selection succeeded")
+ }
+ if err := DeleteTokenDataForProfile(dir, "profile"); err == nil {
+ t.Fatal("hook profile delete succeeded")
+ }
+ if err := DeleteAllTokenData(dir); err != nil || !deleted {
+ t.Fatalf("hook delete all = %v, deleted=%v", err, deleted)
+ }
+ edition.Override(&edition.Hooks{LoadToken: func(string) ([]byte, error) { return []byte("{"), nil }})
+ if _, err := LoadTokenData(dir); err == nil {
+ t.Fatal("malformed hook token succeeded")
+ }
+ edition.Override(&edition.Hooks{LoadToken: func(string) ([]byte, error) { return nil, errors.New("load") }})
+ if _, err := LoadTokenData(dir); err == nil {
+ t.Fatal("hook token read error succeeded")
+ }
+}
+
+func TestTokenAndKeychainEdges(t *testing.T) {
+ dir := t.TempDir()
+ if err := SaveTokenDataKeychainForCorpID("", &TokenData{}); err == nil {
+ t.Fatal("empty corp token save succeeded")
+ }
+ if _, err := LoadTokenDataKeychainForCorpID(""); err == nil {
+ t.Fatal("empty corp token load succeeded")
+ }
+ if err := DeleteTokenDataKeychainForCorpID(""); err == nil {
+ t.Fatal("empty corp token delete succeeded")
+ }
+ if TokenDataExistsKeychainForCorpID("") {
+ t.Fatal("empty corp token exists")
+ }
+ if _, err := loadTokenDataKeychainAccount("missing-account"); err == nil {
+ t.Fatal("missing keychain account loaded")
+ }
+ if err := keychain.Set(keychain.Service, "bad-token", "{"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadTokenDataKeychainAccount("bad-token"); err == nil {
+ t.Fatal("malformed keychain token loaded")
+ }
+ if err := SaveClientSecret("", "secret"); err != nil {
+ t.Fatal(err)
+ }
+ if err := SaveClientSecret("client", ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := SaveClientSecret("client", "secret"); err != nil || LoadClientSecret("client") != "secret" {
+ t.Fatal("client secret round trip failed")
+ }
+ if LoadClientSecret("") != "" || LoadClientSecret("missing") != "" {
+ t.Fatal("missing client secret was returned")
+ }
+ if err := DeleteClientSecret(""); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteClientSecret("client"); err != nil {
+ t.Fatal(err)
+ }
+
+ migrationOnce = sync.Once{}
+ migrationDone = false
+ EnsureMigration(dir, slog.Default())
+ if !IsMigrationDone() {
+ t.Fatal("migration state not recorded")
+ }
+
+ badDir := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(badDir, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteTokenMarker(badDir); err == nil {
+ t.Fatal("token marker in file dir succeeded")
+ }
+ if err := DeleteTokenMarker(dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteAllTokenData(dir); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestAppConfigDeleteReloadEdges(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.Mkdir(GetAppConfigPath(dir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadAppConfig(dir); err == nil {
+ t.Fatal("directory app config loaded")
+ }
+ if err := os.RemoveAll(GetAppConfigPath(dir)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(GetAppConfigPath(dir), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadAppConfig(dir); err == nil {
+ t.Fatal("malformed app config loaded")
+ }
+ if err := os.Remove(GetAppConfigPath(dir)); err != nil {
+ t.Fatal(err)
+ }
+ config := &AppConfig{ClientID: "client", ClientSecret: PlainSecret("secret")}
+ if err := SaveAppConfig(dir, config); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := ReloadAppConfig(dir); err != nil || got.ClientID != "client" {
+ t.Fatalf("reload app config = %#v, %v", got, err)
+ }
+ if err := DeleteAppConfig(dir); err != nil {
+ t.Fatal(err)
+ }
+ if HasAppConfig(dir) {
+ t.Fatal("app config remains after delete")
+ }
+ if err := DeleteAppConfig(dir); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBasicAuthCoverageEdges(t *testing.T) {
+ t.Run("channel and status", func(t *testing.T) {
+ t.Setenv(AgentCodeEnv, " agent ")
+ if !AgentCodeEnvPresent() || !HostOwnsPATFlow() {
+ t.Fatal("host-owned PAT flow was not detected")
+ }
+ for _, status := range []string{StatusApproved, StatusRejected, StatusExpired, StatusPending, StatusCancelled} {
+ if ParseDeviceFlowStatus(status, true) != status {
+ t.Fatalf("status %q was not preserved", status)
+ }
+ }
+ if ParseDeviceFlowStatus("", false) != StatusExpired || ParseDeviceFlowStatus("future", true) != "future" {
+ t.Fatal("device status fallback failed")
+ }
+ })
+
+ t.Run("browser", func(t *testing.T) {
+ oldStart := browserStartCommand
+ t.Cleanup(func() { browserStartCommand = oldStart })
+ var calls []string
+ browserStartCommand = func(name string, args ...string) error {
+ calls = append(calls, name+":"+strings.Join(args, ","))
+ return nil
+ }
+ if err := openBrowserForOS("darwin", "https://example.test"); err != nil {
+ t.Fatal(err)
+ }
+ if err := openBrowserForOS("linux", "http://example.test"); err != nil {
+ t.Fatal(err)
+ }
+ if len(calls) != 2 || !strings.HasPrefix(calls[0], "open:") || !strings.HasPrefix(calls[1], "xdg-open:") {
+ t.Fatalf("browser calls = %v", calls)
+ }
+ if err := openBrowserForOS("plan9", "https://example.test"); err == nil {
+ t.Fatal("unsupported browser platform succeeded")
+ }
+ if err := openBrowserForOS("darwin", "://bad"); err == nil {
+ t.Fatal("invalid browser URL succeeded")
+ }
+ if err := openBrowserForOS("darwin", "file:///tmp/x"); err == nil {
+ t.Fatal("unsafe browser URL succeeded")
+ }
+ browserStartCommand = func(string, ...string) error { return errors.New("start") }
+ if err := openBrowserForOS("darwin", "https://example.test"); err == nil {
+ t.Fatal("browser start error was ignored")
+ }
+ })
+
+ t.Run("endpoints and sources", func(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+ resetAppConfigCache()
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ oldHome := authUserHomeDir
+ oldEdition := edition.Get()
+ t.Cleanup(func() {
+ authUserHomeDir = oldHome
+ edition.Override(oldEdition)
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ resetAppConfigCache()
+ })
+
+ if err := os.WriteFile(filepath.Join(dir, "terminal_url"), []byte("https://terminal.test"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if GetTerminalBaseURL() != "https://terminal.test" || !strings.Contains(GetDeveloperSettingsURL(), "terminal.test") {
+ t.Fatal("terminal endpoint override failed")
+ }
+ if GetUserAccessTokenURL() != UserAccessTokenURL || GetRefreshTokenURL() != UserAccessTokenURL || GetRevokeTokenURL() != "" {
+ t.Fatal("direct token endpoints failed")
+ }
+ SetClientIDFromMCP("mcp-client")
+ if !strings.HasSuffix(GetUserAccessTokenURL(), MCPOAuthTokenPath) || !strings.HasSuffix(GetRefreshTokenURL(), MCPRefreshTokenPath) || !strings.HasSuffix(GetRevokeTokenURL(), MCPRevokeTokenPath) {
+ t.Fatal("MCP token endpoints failed")
+ }
+ resetClientIDFromMCP()
+ SetClientID("")
+
+ SetClientSecret("runtime-secret")
+ if resolveCredentialSource() != "flag" || !HasValidClientSecret() {
+ t.Fatal("runtime credential source failed")
+ }
+ SetClientSecret("")
+ if HasValidClientSecret() {
+ t.Fatal("placeholder secret was accepted")
+ }
+ t.Setenv("DWS_CLIENT_ID", "env-id")
+ if resolveCredentialSource() != "env" {
+ t.Fatal("environment credential source failed")
+ }
+ t.Setenv("DWS_CLIENT_ID", "")
+ resetAppConfigCache()
+ if resolveCredentialSource() != "default" {
+ t.Fatal("default credential source failed")
+ }
+
+ edition.Override(&edition.Hooks{Name: "coverage", AuthClientID: "edition-id"})
+ if ClientID() != "edition-id" {
+ t.Fatal("edition client ID failed")
+ }
+ edition.Override(oldEdition)
+ resetAppConfigCache()
+ if err := os.WriteFile(GetAppConfigPath(dir), []byte(`{"clientId":"app-id","clientSecret":"plain"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReloadAppConfig(dir); err != nil {
+ t.Fatal(err)
+ }
+ resetAppConfigCache()
+ if resolveCredentialSource() != "app" {
+ t.Fatal("app credential source failed")
+ }
+
+ t.Setenv("DWS_CONFIG_DIR", "")
+ authUserHomeDir = func() (string, error) { return "", errors.New("home") }
+ if getDefaultConfigDir() != ".dws" {
+ t.Fatal("home-directory fallback failed")
+ }
+ })
+
+ t.Run("strict credentials", func(t *testing.T) {
+ t.Setenv(EnvClientID, "env-id")
+ t.Setenv(EnvClientSecret, "env-secret")
+ if id, secret, idSource, secretSource, err := ResolveAppCredentialsStrict(t.TempDir()); err != nil || id != "env-id" || secret != "env-secret" || idSource != CredentialSourceEnv || secretSource != CredentialSourceEnv {
+ t.Fatalf("env strict credentials = %q %q %q %q %v", id, secret, idSource, secretSource, err)
+ }
+ if EnvHalfSet() {
+ t.Fatal("complete env pair reported as half set")
+ }
+ t.Setenv(EnvClientSecret, "")
+ if !EnvHalfSet() {
+ t.Fatal("half-set env pair was not detected")
+ }
+ t.Setenv(EnvClientID, "")
+
+ badPath := filepath.Join(t.TempDir(), "config-file")
+ if err := os.WriteFile(badPath, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, _, err := ResolveAppCredentialsStrict(badPath); err == nil {
+ t.Fatal("unreadable app config succeeded")
+ }
+ missing := t.TempDir()
+ if _, _, _, _, err := ResolveAppCredentialsStrict(missing); !errors.Is(err, ErrAppConfigMissing) {
+ t.Fatalf("missing app config error = %v", err)
+ }
+ writeConfig := func(t *testing.T, value string) string {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.WriteFile(GetAppConfigPath(dir), []byte(value), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+ }
+ if _, _, _, _, err := ResolveAppCredentialsStrict(writeConfig(t, `{"clientId":"","clientSecret":"secret"}`)); !errors.Is(err, ErrClientIDEmpty) {
+ t.Fatalf("empty client ID error = %v", err)
+ }
+ if _, _, _, _, err := ResolveAppCredentialsStrict(writeConfig(t, `{"clientId":"id","clientSecret":""}`)); !errors.Is(err, ErrClientSecretEmpty) {
+ t.Fatalf("empty client secret error = %v", err)
+ }
+ if _, _, _, _, err := ResolveAppCredentialsStrict(writeConfig(t, `{"clientId":"id","clientSecret":{"source":"file","id":"/missing/secret"}}`)); !errors.Is(err, ErrSecretResolve) {
+ t.Fatalf("secret resolve error = %v", err)
+ }
+ secretFile := filepath.Join(t.TempDir(), "secret")
+ if err := os.WriteFile(secretFile, []byte(" file-secret \n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ dir := writeConfig(t, `{"clientId":"id","clientSecret":{"source":"file","id":`+fmt.Sprintf("%q", secretFile)+`}}`)
+ if id, secret, idSource, secretSource, err := ResolveAppCredentialsStrict(dir); err != nil || id != "id" || secret != "file-secret" || idSource != CredentialSourceAppConfig || secretSource != CredentialSourcePlainConfig {
+ t.Fatalf("file strict credentials = %q %q %q %q %v", id, secret, idSource, secretSource, err)
+ }
+ })
+
+ t.Run("manager and secret", func(t *testing.T) {
+ dir := t.TempDir()
+ m := NewManager(dir, slog.Default())
+ if err := m.SaveToken("1234567890"); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.SaveMCPURL("https://mcp.test"); err != nil {
+ t.Fatal(err)
+ }
+ if token, source, err := m.GetToken(); err != nil || token != "1234567890" || source != "file" || !m.IsAuthenticated() {
+ t.Fatalf("manager token = %q %q %v", token, source, err)
+ }
+ if rawURL, err := m.GetMCPURL(); err != nil || rawURL != "https://mcp.test" {
+ t.Fatalf("manager MCP URL = %q %v", rawURL, err)
+ }
+ if ok, source, masked := m.Status(); !ok || source != "file" || masked != "1234...7890" {
+ t.Fatalf("manager status = %v %q %q", ok, source, masked)
+ }
+ if maskToken("short") != "****" {
+ t.Fatal("short token masking failed")
+ }
+ if err := m.DeleteToken(); err != nil || m.IsAuthenticated() {
+ t.Fatal("manager token deletion failed")
+ }
+ if ok, _, _ := m.Status(); ok {
+ t.Fatal("missing manager token reported authenticated")
+ }
+ if _, err := NewManager(t.TempDir(), nil).GetMCPURL(); err == nil {
+ t.Fatal("missing manager MCP URL succeeded")
+ }
+
+ fileDir := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(fileDir, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ badManager := NewManager(filepath.Join(fileDir, "child"), nil)
+ if err := badManager.SaveToken("x"); err == nil {
+ t.Fatal("token mkdir failure succeeded")
+ }
+ if err := badManager.SaveMCPURL("x"); err == nil {
+ t.Fatal("MCP URL mkdir failure succeeded")
+ }
+ writeDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(writeDir, tokenFileName), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := NewManager(writeDir, nil).SaveToken("x"); err == nil {
+ t.Fatal("token write failure succeeded")
+ }
+ if err := os.Mkdir(filepath.Join(writeDir, "mcp_url"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := NewManager(writeDir, nil).SaveMCPURL("x"); err == nil {
+ t.Fatal("MCP URL write failure succeeded")
+ }
+ deleteDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(deleteDir, tokenFileName), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(deleteDir, tokenFileName, "child"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := NewManager(deleteDir, nil).DeleteToken(); err == nil {
+ t.Fatal("token remove failure succeeded")
+ }
+
+ if !PlainSecret("").IsZero() || PlainSecret("x").IsZero() {
+ t.Fatal("secret zero detection failed")
+ }
+ secretPath := filepath.Join(t.TempDir(), "secret")
+ if err := os.WriteFile(secretPath, []byte(" value \n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if value, err := ResolveSecret(SecretInput{Ref: &SecretRef{Source: "file", ID: secretPath}}); err != nil || value != "value" {
+ t.Fatalf("file secret = %q %v", value, err)
+ }
+ if _, err := ResolveSecret(SecretInput{Ref: &SecretRef{Source: "file", ID: filepath.Join(t.TempDir(), "missing")}}); err == nil {
+ t.Fatal("missing file secret succeeded")
+ }
+ if _, err := ResolveSecret(SecretInput{Ref: &SecretRef{Source: "future", ID: "x"}}); err == nil {
+ t.Fatal("unknown secret source succeeded")
+ }
+ ref := SecretInput{Ref: &SecretRef{Source: "file", ID: secretPath}}
+ if stored, err := StoreSecret("id", ref); err != nil || stored.Ref != ref.Ref {
+ t.Fatal("existing secret reference changed")
+ }
+ })
+
+ t.Run("headers and stale token", func(t *testing.T) {
+ applyEditionEnterpriseCredentialHeaders(nil)
+ oldEdition := edition.Get()
+ t.Cleanup(func() { edition.Override(oldEdition) })
+ edition.Override(&edition.Hooks{Name: "coverage"})
+ req, _ := http.NewRequest(http.MethodGet, "https://example.test", nil)
+ applyEditionEnterpriseCredentialHeaders(req)
+ edition.Override(&edition.Hooks{Name: "coverage", EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
+ return map[string]string{" X-Test ": " value ", "": "ignored", "Empty": " "}
+ }})
+ applyEditionEnterpriseCredentialHeaders(req)
+ if req.Header.Get("X-Test") != "value" || req.Header.Get("Empty") != "" {
+ t.Fatalf("enterprise headers = %v", req.Header)
+ }
+
+ dir := t.TempDir()
+ if err := MarkAccessTokenStale(dir); err == nil {
+ t.Fatal("missing token stale mark succeeded")
+ }
+ if err := SaveTokenData(dir, &TokenData{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := MarkAccessTokenStale(dir); err != nil {
+ t.Fatal(err)
+ }
+ data := &TokenData{AccessToken: "access", ExpiresAt: time.Now().Add(time.Hour)}
+ if err := SaveTokenData(dir, data); err != nil {
+ t.Fatal(err)
+ }
+ if err := MarkAccessTokenStale(dir); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := LoadTokenData(dir)
+ if err != nil || !loaded.ExpiresAt.Before(time.Now()) {
+ t.Fatalf("stale token = %#v %v", loaded, err)
+ }
+ })
+}
+
+func TestPortableAuthBundleCoverageEdges(t *testing.T) {
+ makeBundle := func(t *testing.T, entries []tar.Header, bodies []string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gz)
+ for i := range entries {
+ hdr := entries[i]
+ body := bodies[i]
+ if hdr.Typeflag == 0 {
+ hdr.Typeflag = tar.TypeReg
+ }
+ if hdr.Typeflag == tar.TypeReg {
+ hdr.Size = int64(len(body))
+ }
+ if err := tw.WriteHeader(&hdr); err != nil {
+ t.Fatal(err)
+ }
+ if body != "" {
+ if _, err := tw.Write([]byte(body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gz.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buf.Bytes()
+ }
+
+ t.Run("platform and populated states", func(t *testing.T) {
+ oldGOOS := portableRuntimeGOOS
+ t.Cleanup(func() { portableRuntimeGOOS = oldGOOS })
+ portableRuntimeGOOS = func() string { return "linux" }
+ if !PortableExportSupported() {
+ t.Fatal("Linux portable export was disabled")
+ }
+ portableRuntimeGOOS = func() string { return "darwin" }
+ t.Setenv(keychain.DisableKeychainEnv, "")
+ if PortableExportSupported() {
+ t.Fatal("macOS system-keychain export was enabled")
+ }
+ t.Setenv(keychain.DisableKeychainEnv, "1")
+ if !PortableExportSupported() {
+ t.Fatal("macOS file-keychain export was disabled")
+ }
+
+ keyDir := t.TempDir()
+ t.Setenv(keychain.StorageDirEnv, keyDir)
+ configDir := t.TempDir()
+ _ = DeleteTokenData(configDir)
+ if PortableAuthTargetPopulated(configDir) || PortableAuthSourceReady() {
+ t.Fatal("empty portable auth target reported populated")
+ }
+ if err := os.WriteFile(ProfilesPath(configDir), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if !PortableAuthTargetPopulated(configDir) {
+ t.Fatal("profiles target was not detected")
+ }
+ if err := os.Remove(ProfilesPath(configDir)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(configDir, "app.json"), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if !PortableAuthTargetPopulated(configDir) {
+ t.Fatal("app target was not detected")
+ }
+ if err := os.Remove(filepath.Join(configDir, "app.json")); err != nil {
+ t.Fatal(err)
+ }
+ encPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
+ if err := os.MkdirAll(filepath.Dir(encPath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(encPath, []byte("encrypted"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if !PortableAuthTargetPopulated(configDir) || !PortableAuthSourceReady() {
+ t.Fatal("encrypted token source was not detected")
+ }
+ })
+
+ t.Run("export and import", func(t *testing.T) {
+ oldGOOS := portableRuntimeGOOS
+ t.Cleanup(func() { portableRuntimeGOOS = oldGOOS })
+ portableRuntimeGOOS = func() string { return "linux" }
+ keyRoot := t.TempDir()
+ t.Setenv(keychain.StorageDirEnv, keyRoot)
+ t.Setenv(keychain.DisableKeychainEnv, "1")
+ configDir := t.TempDir()
+ if err := ExportPortableAuthBundle(configDir, nil); err == nil {
+ t.Fatal("nil portable writer succeeded")
+ }
+ portableRuntimeGOOS = func() string { return "darwin" }
+ t.Setenv(keychain.DisableKeychainEnv, "")
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("unsupported portable export succeeded")
+ }
+ portableRuntimeGOOS = func() string { return "linux" }
+ t.Setenv(keychain.DisableKeychainEnv, "1")
+ t.Setenv(keychain.StorageDirEnv, filepath.Join(t.TempDir(), "missing"))
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("missing keychain export succeeded")
+ }
+ emptyKeyRoot := t.TempDir()
+ t.Setenv(keychain.StorageDirEnv, emptyKeyRoot)
+ if err := os.MkdirAll(keychain.StorageDir(keychain.Service), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("empty keychain export succeeded")
+ }
+
+ readyKeyRoot := t.TempDir()
+ t.Setenv(keychain.StorageDirEnv, readyKeyRoot)
+ keyDir := keychain.StorageDir(keychain.Service)
+ if err := os.MkdirAll(filepath.Join(keyDir, "nested"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ for name, value := range map[string]string{keychain.AccountToken + ".enc": "cipher", "dek": "key", "nested/value": "nested"} {
+ if err := os.WriteFile(filepath.Join(keyDir, name), []byte(value), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
+ t.Fatal(err)
+ }
+ for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} {
+ if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Mkdir(filepath.Join(configDir, "app-dir.json"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ var bundle bytes.Buffer
+ if err := ExportPortableAuthBundle(configDir, &bundle); err != nil {
+ t.Fatal(err)
+ }
+
+ importKeyRoot := t.TempDir()
+ t.Setenv(keychain.StorageDirEnv, importKeyRoot)
+ importDir := t.TempDir()
+ report, err := ImportPortableAuthBundle(importDir, bytes.NewReader(bundle.Bytes()))
+ if err != nil || report.BundleOS != "linux" || report.OSMismatch {
+ t.Fatalf("portable import report = %#v %v", report, err)
+ }
+ if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
+ t.Fatal("config file was not imported")
+ }
+ if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
+ t.Fatal("encrypted token was not imported")
+ }
+ })
+
+ t.Run("import validation", func(t *testing.T) {
+ t.Setenv(keychain.StorageDirEnv, t.TempDir())
+ if _, err := ImportPortableAuthBundle(t.TempDir(), nil); err == nil {
+ t.Fatal("nil portable reader succeeded")
+ }
+ if _, err := ImportPortableAuthBundle(t.TempDir(), strings.NewReader("not gzip")); err == nil {
+ t.Fatal("invalid gzip bundle succeeded")
+ }
+ var corrupt bytes.Buffer
+ gz := gzip.NewWriter(&corrupt)
+ _, _ = gz.Write([]byte("not a tar stream"))
+ _ = gz.Close()
+ if _, err := ImportPortableAuthBundle(t.TempDir(), bytes.NewReader(corrupt.Bytes())); err == nil {
+ t.Fatal("invalid tar bundle succeeded")
+ }
+
+ cases := []struct {
+ name string
+ header tar.Header
+ body string
+ wantErr bool
+ }{
+ {"bad manifest", tar.Header{Name: portableAuthManifest}, "{", true},
+ {"unsafe parent", tar.Header{Name: "../evil"}, "x", true},
+ {"unsafe absolute", tar.Header{Name: "/evil"}, "x", true},
+ {"unsupported path", tar.Header{Name: "other/value"}, "x", true},
+ {"unsupported type", tar.Header{Name: "config/link", Typeflag: tar.TypeSymlink, Linkname: "x"}, "", true},
+ {"directory", tar.Header{Name: "config/nested", Typeflag: tar.TypeDir}, "", false},
+ {"no manifest", tar.Header{Name: "config/value"}, "x", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := ImportPortableAuthBundle(t.TempDir(), bytes.NewReader(makeBundle(t, []tar.Header{tc.header}, []string{tc.body})))
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("import error = %v, want error %v", err, tc.wantErr)
+ }
+ })
+ }
+ manifest := `{"version":1,"os":"other-os","keychain_service":"dws-cli"}`
+ report, err := ImportPortableAuthBundle(t.TempDir(), bytes.NewReader(makeBundle(t, []tar.Header{{Name: portableAuthManifest}}, []string{manifest})))
+ if err != nil || report.BundleOS != "other-os" || !report.OSMismatch {
+ t.Fatalf("mismatched OS report = %#v %v", report, err)
+ }
+ })
+
+ t.Run("helper failures", func(t *testing.T) {
+ if _, err := cleanPortableName("."); err == nil {
+ t.Fatal("empty portable name succeeded")
+ }
+ if _, err := cleanPortableName("../x"); err == nil {
+ t.Fatal("parent portable name succeeded")
+ }
+ if got, err := cleanPortableName(" config/value "); err != nil || got != "config/value" {
+ t.Fatalf("clean portable name = %q %v", got, err)
+ }
+ for _, rel := range []string{"", ".", "..", "../x", "/absolute"} {
+ if _, err := safeJoin(t.TempDir(), rel); err == nil {
+ t.Errorf("unsafe relative path %q succeeded", rel)
+ }
+ }
+ if got, err := safeJoin("/root", "nested/value"); err != nil || got != "/root/nested/value" {
+ t.Fatalf("safe join = %q %v", got, err)
+ }
+ oldInRoot := portablePathInRoot
+ portablePathInRoot = func(string, string) bool { return false }
+ if _, err := safeJoin("/root", "value"); err == nil {
+ t.Fatal("out-of-root path succeeded")
+ }
+ portablePathInRoot = oldInRoot
+
+ if _, err := portableConfigFiles("["); err == nil {
+ t.Fatal("invalid config glob succeeded")
+ }
+ dir := t.TempDir()
+ file := filepath.Join(dir, "app.json")
+ if err := os.WriteFile(file, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ oldStat := portableStat
+ portableStat = func(string) (os.FileInfo, error) { return nil, errors.New("stat") }
+ if files, err := portableConfigFiles(dir); err != nil || len(files) != 0 {
+ t.Fatalf("stat-skipped config files = %v %v", files, err)
+ }
+ portableStat = oldStat
+ oldRel := portableRel
+ portableRel = func(string, string) (string, error) { return "", errors.New("rel") }
+ if _, err := portableConfigFiles(dir); err == nil {
+ t.Fatal("config relative-path failure succeeded")
+ }
+ if got := mustPortableRel("root", "file"); got != "file" {
+ t.Fatalf("portable relative fallback = %q", got)
+ }
+ portableRel = oldRel
+
+ oldMarshal := portableJSONMarshal
+ portableJSONMarshal = func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") }
+ if err := writePortableManifest(tar.NewWriter(io.Discard), portableAuthBundleManifest{}); err == nil {
+ t.Fatal("manifest marshal failure succeeded")
+ }
+ portableJSONMarshal = oldMarshal
+
+ closed := tar.NewWriter(io.Discard)
+ _ = closed.Close()
+ if err := writePortableBytes(closed, "value", []byte("x"), 0o600); err == nil {
+ t.Fatal("closed tar byte write succeeded")
+ }
+ dataFail := &failAfterWriter{remaining: 512}
+ if err := writePortableBytes(tar.NewWriter(dataFail), "value", []byte("x"), 0o600); err == nil {
+ t.Fatal("tar data write failure succeeded")
+ }
+
+ if err := addPortableDir(tar.NewWriter(io.Discard), filepath.Join(t.TempDir(), "missing"), "keychain"); err == nil {
+ t.Fatal("missing portable directory succeeded")
+ }
+ walkDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(walkDir, "nested"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ portableRel = func(string, string) (string, error) { return "", errors.New("rel") }
+ if err := addPortableDir(tar.NewWriter(io.Discard), walkDir, "keychain"); err == nil {
+ t.Fatal("directory relative-path failure succeeded")
+ }
+ portableRel = oldRel
+
+ if err := addPortableFile(tar.NewWriter(io.Discard), filepath.Join(t.TempDir(), "missing"), "value"); err == nil {
+ t.Fatal("missing portable file succeeded")
+ }
+ if err := addPortableFile(tar.NewWriter(io.Discard), t.TempDir(), "dir"); err != nil {
+ t.Fatal(err)
+ }
+ oldOpen := portableOpen
+ portableOpen = func(string) (io.ReadCloser, error) { return nil, errors.New("open") }
+ if err := addPortableFile(tar.NewWriter(io.Discard), file, "value"); err == nil {
+ t.Fatal("portable file open failure succeeded")
+ }
+ portableOpen = oldOpen
+ if err := addPortableFile(closed, file, "value"); err == nil {
+ t.Fatal("closed tar file write succeeded")
+ }
+ portableOpen = func(string) (io.ReadCloser, error) { return io.NopCloser(errorReader{}), nil }
+ if err := addPortableFile(tar.NewWriter(io.Discard), file, "value"); err == nil {
+ t.Fatal("portable file copy failure succeeded")
+ }
+ portableOpen = oldOpen
+
+ oldMkdir := portableMkdirAll
+ oldChmod := portableChmod
+ oldCreate := portableCreateTemp
+ oldRename := portableRename
+ oldRemove := portableRemove
+ t.Cleanup(func() {
+ portableStat = oldStat
+ portableRel = oldRel
+ portableOpen = oldOpen
+ portableJSONMarshal = oldMarshal
+ portablePathInRoot = oldInRoot
+ portableMkdirAll = oldMkdir
+ portableChmod = oldChmod
+ portableCreateTemp = oldCreate
+ portableRename = oldRename
+ portableRemove = oldRemove
+ })
+ portableMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
+ if err := extractPortableEntry(filepath.Join(t.TempDir(), "dir"), &tar.Header{Typeflag: tar.TypeDir}, strings.NewReader("")); err == nil {
+ t.Fatal("portable directory mkdir failure succeeded")
+ }
+ if err := extractPortableEntry(filepath.Join(t.TempDir(), "file"), &tar.Header{Typeflag: tar.TypeReg}, strings.NewReader("x")); err == nil {
+ t.Fatal("portable file mkdir failure succeeded")
+ }
+ portableMkdirAll = oldMkdir
+ portableChmod = func(string, os.FileMode) error { return errors.New("chmod") }
+ if err := extractPortableEntry(t.TempDir(), &tar.Header{Typeflag: tar.TypeDir}, strings.NewReader("")); err == nil {
+ t.Fatal("portable directory chmod failure succeeded")
+ }
+ portableChmod = oldChmod
+ portableCreateTemp = func(string, string) (portableTempFile, error) { return nil, errors.New("create") }
+ if err := extractPortableEntry(filepath.Join(t.TempDir(), "file"), &tar.Header{Typeflag: tar.TypeReg}, strings.NewReader("x")); err == nil {
+ t.Fatal("portable temp create failure succeeded")
+ }
+ fake := func() *fakePortableTemp { return &fakePortableTemp{name: filepath.Join(t.TempDir(), "tmp")} }
+ for name, configure := range map[string]func(*fakePortableTemp){
+ "chmod": func(f *fakePortableTemp) { f.chmodErr = errors.New("chmod") },
+ "write": func(f *fakePortableTemp) { f.writeErr = errors.New("write") },
+ "sync": func(f *fakePortableTemp) { f.syncErr = errors.New("sync") },
+ "close": func(f *fakePortableTemp) { f.closeErr = errors.New("close") },
+ } {
+ t.Run(name, func(t *testing.T) {
+ f := fake()
+ configure(f)
+ portableCreateTemp = func(string, string) (portableTempFile, error) { return f, nil }
+ if err := extractPortableEntry(filepath.Join(t.TempDir(), "file"), &tar.Header{Typeflag: tar.TypeReg}, strings.NewReader("x")); err == nil {
+ t.Fatalf("portable temp %s failure succeeded", name)
+ }
+ })
+ }
+ f := fake()
+ portableCreateTemp = func(string, string) (portableTempFile, error) { return f, nil }
+ portableRename = func(string, string) error { return errors.New("rename") }
+ portableRemove = func(string) error { return errors.New("remove") }
+ if err := extractPortableEntry(filepath.Join(t.TempDir(), "file"), &tar.Header{Typeflag: tar.TypeReg}, strings.NewReader("x")); err == nil {
+ t.Fatal("portable rename failure succeeded")
+ }
+ })
+}
+
+func TestTokenStorageAndRevocationCoverageEdges(t *testing.T) {
+ oldMarshalIndent := tokenJSONMarshalIndent
+ oldMarshal := tokenJSONMarshal
+ oldMkdir := tokenMkdirAll
+ oldWrite := tokenWriteFile
+ oldRename := tokenRename
+ oldRemove := tokenRemove
+ oldGlob := tokenGlob
+ oldSaveCorp := tokenSaveKeychainForCorpID
+ oldSaveLegacy := tokenSaveKeychain
+ oldLoadCorp := tokenLoadKeychainForCorpID
+ oldLoadLegacy := tokenLoadKeychain
+ oldExists := tokenKeychainExists
+ oldDeleteCorp := tokenDeleteKeychainForCorpID
+ oldDeleteLegacy := tokenDeleteKeychain
+ oldLoadSecure := tokenLoadSecure
+ oldDeleteSecure := tokenDeleteSecure
+ oldResolve := tokenResolveProfile
+ oldUpsert := tokenUpsertProfile
+ oldRemoveProfile := tokenRemoveProfile
+ oldSync := tokenSyncLegacyMirror
+ oldLoadProfiles := tokenLoadProfiles
+ oldWriteMarker := tokenWriteMarker
+ oldDeleteMarker := tokenDeleteMarker
+ oldParseURL := tokenParseURL
+ oldNewRequest := tokenNewRequest
+ oldDefaultDir := tokenDefaultConfigDir
+ oldLoadData := tokenLoadData
+ oldRevokeURL := tokenRevokeURL
+ oldLogoutURL := tokenLogoutURL
+ oldLogoutContinue := tokenLogoutContinueURL
+ oldLogoutClient := tokenLogoutHTTPClient
+ oldRevokeClient := tokenRevokeHTTPClient
+ oldEdition := edition.Get()
+ t.Cleanup(func() {
+ tokenJSONMarshalIndent = oldMarshalIndent
+ tokenJSONMarshal = oldMarshal
+ tokenMkdirAll = oldMkdir
+ tokenWriteFile = oldWrite
+ tokenRename = oldRename
+ tokenRemove = oldRemove
+ tokenGlob = oldGlob
+ tokenSaveKeychainForCorpID = oldSaveCorp
+ tokenSaveKeychain = oldSaveLegacy
+ tokenLoadKeychainForCorpID = oldLoadCorp
+ tokenLoadKeychain = oldLoadLegacy
+ tokenKeychainExists = oldExists
+ tokenDeleteKeychainForCorpID = oldDeleteCorp
+ tokenDeleteKeychain = oldDeleteLegacy
+ tokenLoadSecure = oldLoadSecure
+ tokenDeleteSecure = oldDeleteSecure
+ tokenResolveProfile = oldResolve
+ tokenUpsertProfile = oldUpsert
+ tokenRemoveProfile = oldRemoveProfile
+ tokenSyncLegacyMirror = oldSync
+ tokenLoadProfiles = oldLoadProfiles
+ tokenWriteMarker = oldWriteMarker
+ tokenDeleteMarker = oldDeleteMarker
+ tokenParseURL = oldParseURL
+ tokenNewRequest = oldNewRequest
+ tokenDefaultConfigDir = oldDefaultDir
+ tokenLoadData = oldLoadData
+ tokenRevokeURL = oldRevokeURL
+ tokenLogoutURL = oldLogoutURL
+ tokenLogoutContinueURL = oldLogoutContinue
+ tokenLogoutHTTPClient = oldLogoutClient
+ tokenRevokeHTTPClient = oldRevokeClient
+ edition.Override(oldEdition)
+ SetRuntimeProfile("")
+ SetClientID("")
+ resetClientIDFromMCP()
+ })
+
+ t.Run("marker", func(t *testing.T) {
+ dir := t.TempDir()
+ tokenMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
+ if err := WriteTokenMarker(dir); err == nil {
+ t.Fatal("marker mkdir failure succeeded")
+ }
+ tokenMkdirAll = oldMkdir
+ tokenWriteFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
+ if err := WriteTokenMarker(dir); err == nil {
+ t.Fatal("marker write failure succeeded")
+ }
+ tokenWriteFile = oldWrite
+ tokenRename = func(string, string) error { return errors.New("rename") }
+ if err := WriteTokenMarker(dir); err == nil {
+ t.Fatal("marker rename failure succeeded")
+ }
+ tokenRename = oldRename
+ tokenRemove = func(string) error { return errors.New("remove") }
+ if err := DeleteTokenMarker(dir); err == nil {
+ t.Fatal("marker delete failure succeeded")
+ }
+ tokenRemove = oldRemove
+ })
+
+ t.Run("save branches", func(t *testing.T) {
+ dir := t.TempDir()
+ data := &TokenData{CorpID: "corp", AccessToken: "access"}
+ fail := errors.New("fail")
+ tokenSaveKeychainForCorpID = func(string, *TokenData) error { return fail }
+ if err := saveTokenDataLocked(dir, data); !errors.Is(err, fail) {
+ t.Fatalf("corp keychain error = %v", err)
+ }
+ tokenSaveKeychainForCorpID = oldSaveCorp
+ tokenUpsertProfile = func(string, *TokenData, bool) error { return fail }
+ if err := saveTokenDataLocked(dir, data); !errors.Is(err, fail) {
+ t.Fatalf("profile upsert error = %v", err)
+ }
+ tokenUpsertProfile = oldUpsert
+ tokenSaveKeychain = func(*TokenData) error { return fail }
+ if err := saveTokenDataLocked(dir, data); !errors.Is(err, fail) {
+ t.Fatalf("legacy keychain error = %v", err)
+ }
+ tokenSaveKeychain = oldSaveLegacy
+ SetRuntimeProfile("other")
+ tokenSyncLegacyMirror = func(string) error { return fail }
+ if err := saveTokenDataLocked(dir, data); !errors.Is(err, fail) {
+ t.Fatalf("legacy mirror error = %v", err)
+ }
+ tokenSyncLegacyMirror = oldSync
+ tokenWriteMarker = func(string) error { return fail }
+ if err := saveTokenDataLocked(dir, data); !errors.Is(err, fail) {
+ t.Fatalf("corp marker error = %v", err)
+ }
+ SetRuntimeProfile("")
+ if err := saveTokenDataLocked(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("legacy marker error = %v", err)
+ }
+ tokenWriteMarker = oldWriteMarker
+
+ edition.Override(&edition.Hooks{Name: "coverage", SaveToken: func(string, []byte) error { return nil }})
+ if err := saveTokenDataLocked(dir, data); err != nil {
+ t.Fatal(err)
+ }
+ tokenJSONMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := saveTokenViaHook(edition.Get(), dir, data); err == nil {
+ t.Fatal("hook marshal failure succeeded")
+ }
+ tokenJSONMarshalIndent = oldMarshalIndent
+ edition.Override(oldEdition)
+ })
+
+ t.Run("load branches", func(t *testing.T) {
+ dir := t.TempDir()
+ fail := errors.New("fail")
+ selected := &Profile{CorpID: "corp"}
+ tokenResolveProfile = func(string, string) (*Profile, error) { return nil, fail }
+ if _, err := LoadTokenDataForProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("profile resolution error = %v", err)
+ }
+ tokenResolveProfile = func(string, string) (*Profile, error) { return selected, nil }
+ tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, fail }
+ if _, err := LoadTokenDataForProfile(dir, "explicit"); !errors.Is(err, fail) {
+ t.Fatalf("explicit profile load error = %v", err)
+ }
+ tokenLoadKeychain = func() (*TokenData, error) { return &TokenData{CorpID: "corp", AccessToken: "legacy"}, nil }
+ if got, err := LoadTokenDataForProfile(dir, ""); err != nil || got.AccessToken != "legacy" {
+ t.Fatalf("matching legacy fallback = %#v %v", got, err)
+ }
+ tokenLoadKeychain = func() (*TokenData, error) { return &TokenData{CorpID: "other"}, nil }
+ if _, err := LoadTokenDataForProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("mismatched legacy fallback error = %v", err)
+ }
+
+ tokenResolveProfile = func(string, string) (*Profile, error) { return nil, nil }
+ tokenKeychainExists = func() bool { return true }
+ tokenLoadKeychain = func() (*TokenData, error) { return nil, fail }
+ if _, err := LoadTokenDataForProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("legacy slot load error = %v", err)
+ }
+ tokenKeychainExists = func() bool { return false }
+ tokenLoadSecure = func(string) (*TokenData, error) { return nil, fail }
+ if _, err := LoadTokenDataForProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("secure load error = %v", err)
+ }
+ legacy := &TokenData{AccessToken: "secure"}
+ tokenLoadSecure = func(string) (*TokenData, error) { return legacy, nil }
+ tokenSaveKeychain = func(*TokenData) error { return fail }
+ if got, err := LoadTokenDataForProfile(dir, ""); err != nil || got != legacy {
+ t.Fatalf("failed migration load = %#v %v", got, err)
+ }
+ tokenSaveKeychain = func(*TokenData) error { return nil }
+ deleted := false
+ tokenDeleteSecure = func(string) error { deleted = true; return nil }
+ if _, err := LoadTokenDataForProfile(dir, ""); err != nil || !deleted {
+ t.Fatal("successful secure migration did not clean up")
+ }
+
+ edition.Override(&edition.Hooks{Name: "coverage", LoadToken: func(string) ([]byte, error) { return nil, fail }})
+ if _, err := LoadTokenDataForProfile(dir, "selected"); err == nil {
+ t.Fatal("hook profile selection succeeded")
+ }
+ if _, err := LoadTokenDataForProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("hook load error = %v", err)
+ }
+ edition.Override(&edition.Hooks{Name: "coverage", LoadToken: func(string) ([]byte, error) { return []byte("{"), nil }})
+ if _, err := LoadTokenDataForProfile(dir, ""); err == nil {
+ t.Fatal("malformed hook token succeeded")
+ }
+ edition.Override(oldEdition)
+ })
+
+ t.Run("delete branches", func(t *testing.T) {
+ dir := t.TempDir()
+ fail := errors.New("fail")
+ selected := &Profile{CorpID: "corp"}
+ tokenResolveProfile = func(string, string) (*Profile, error) { return nil, fail }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("delete resolution error = %v", err)
+ }
+ tokenResolveProfile = func(string, string) (*Profile, error) { return selected, nil }
+ tokenDeleteKeychainForCorpID = func(string) error { return fail }
+ tokenRemoveProfile = func(string, string) (*Profile, error) { return selected, nil }
+ tokenSyncLegacyMirror = func(string) error { return nil }
+ tokenDeleteSecure = func(string) error { return nil }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("delete corp keychain error = %v", err)
+ }
+ tokenDeleteKeychainForCorpID = func(string) error { return nil }
+ tokenRemoveProfile = func(string, string) (*Profile, error) { return nil, fail }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("remove profile error = %v", err)
+ }
+ tokenRemoveProfile = func(string, string) (*Profile, error) { return selected, nil }
+ tokenSyncLegacyMirror = func(string) error { return fail }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("sync mirror error = %v", err)
+ }
+ tokenSyncLegacyMirror = func(string) error { return nil }
+ tokenDeleteSecure = func(string) error { return fail }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("secure cleanup error = %v", err)
+ }
+
+ tokenResolveProfile = func(string, string) (*Profile, error) { return nil, nil }
+ tokenDeleteKeychain = func() error { return fail }
+ tokenDeleteMarker = func(string) error { return nil }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("legacy keychain delete error = %v", err)
+ }
+ tokenDeleteKeychain = func() error { return nil }
+ tokenDeleteSecure = func(string) error { return fail }
+ if err := deleteTokenDataForProfileLocked(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("legacy secure delete error = %v", err)
+ }
+
+ edition.Override(&edition.Hooks{Name: "coverage", DeleteToken: func(string) error { return fail }})
+ if err := DeleteTokenDataForProfile(dir, "selected"); err == nil {
+ t.Fatal("hook profile deletion succeeded")
+ }
+ if err := DeleteTokenDataForProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("hook token deletion error = %v", err)
+ }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("hook delete-all error = %v", err)
+ }
+ edition.Override(oldEdition)
+ })
+
+ t.Run("delete all", func(t *testing.T) {
+ fail := errors.New("fail")
+ base := func() string {
+ tokenLoadProfiles = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
+ tokenDeleteKeychainForCorpID = func(string) error { return nil }
+ tokenRemove = func(string) error { return os.ErrNotExist }
+ tokenGlob = func(string) ([]string, error) { return nil, nil }
+ tokenDeleteKeychain = func() error { return nil }
+ tokenDeleteSecure = func(string) error { return nil }
+ tokenDeleteMarker = func(string) error { return nil }
+ return t.TempDir()
+ }
+ dir := base()
+ tokenLoadProfiles = func(string) (*ProfilesConfig, error) {
+ return &ProfilesConfig{Profiles: []Profile{{CorpID: "corp"}}}, nil
+ }
+ tokenDeleteKeychainForCorpID = func(string) error { return fail }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("delete-all corp error = %v", err)
+ }
+ dir = base()
+ tokenRemove = func(string) error { return fail }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("delete-all profiles error = %v", err)
+ }
+ dir = base()
+ corrupt := filepath.Join(dir, "profiles.json.corrupt-x")
+ tokenGlob = func(string) ([]string, error) { return []string{corrupt}, nil }
+ tokenRemove = func(name string) error {
+ if name == corrupt {
+ return fail
+ }
+ return os.ErrNotExist
+ }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("delete-all quarantine error = %v", err)
+ }
+ dir = base()
+ tokenDeleteKeychain = func() error { return fail }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("delete-all legacy keychain error = %v", err)
+ }
+ dir = base()
+ tokenDeleteSecure = func(string) error { return fail }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("delete-all secure error = %v", err)
+ }
+ dir = base()
+ tokenDeleteMarker = func(string) error { return fail }
+ if err := DeleteAllTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("delete-all marker error = %v", err)
+ }
+ })
+
+ t.Run("remote revoke", func(t *testing.T) {
+ resetClientIDFromMCP()
+ SetClientID("client")
+ fail := errors.New("fail")
+ tokenParseURL = func(string) (*url.URL, error) { return nil, fail }
+ if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("logout parse error = %v", err)
+ }
+ tokenParseURL = oldParseURL
+ tokenLogoutURL = "https://logout.test"
+ tokenNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
+ if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("logout request error = %v", err)
+ }
+ tokenNewRequest = oldNewRequest
+ tokenLogoutHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, fail })}
+ if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("logout network error = %v", err)
+ }
+ response := func(status int) *http.Client {
+ return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
+ })}
+ }
+ for _, status := range []int{http.StatusOK, http.StatusFound} {
+ tokenLogoutHTTPClient = response(status)
+ if err := RevokeTokenRemote(context.Background()); err != nil {
+ t.Fatalf("logout status %d = %v", status, err)
+ }
+ }
+ tokenLogoutHTTPClient = response(http.StatusBadGateway)
+ if err := RevokeTokenRemote(context.Background()); err == nil {
+ t.Fatal("bad logout status succeeded")
+ }
+
+ SetClientIDFromMCP("mcp-client")
+ tokenRevokeURL = func() string { return "" }
+ if err := RevokeTokenRemote(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ tokenRevokeURL = func() string { return "https://revoke.test" }
+ tokenLoadData = func(string) (*TokenData, error) { return nil, fail }
+ if err := RevokeTokenRemote(context.Background()); err != nil {
+ t.Fatal("missing token revoke should be a no-op")
+ }
+ tokenLoadData = func(string) (*TokenData, error) { return &TokenData{AccessToken: "access"}, nil }
+ tokenJSONMarshal = func(any) ([]byte, error) { return nil, fail }
+ if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("revoke marshal error = %v", err)
+ }
+ tokenJSONMarshal = oldMarshal
+ tokenNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
+ if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("revoke request error = %v", err)
+ }
+ tokenNewRequest = oldNewRequest
+ tokenRevokeHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, fail })}
+ if err := RevokeTokenRemote(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("revoke network error = %v", err)
+ }
+ tokenRevokeHTTPClient = response(http.StatusBadGateway)
+ if err := RevokeTokenRemote(context.Background()); err == nil {
+ t.Fatal("bad revoke status succeeded")
+ }
+ tokenRevokeHTTPClient = response(http.StatusOK)
+ if err := RevokeTokenRemote(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
+
+func TestSmallRemainingAuthEdges(t *testing.T) {
+ t.Run("app token hooks", func(t *testing.T) {
+ oldIndent := appTokenMarshalIndent
+ oldMarshal := appTokenMarshal
+ oldRequest := appTokenNewRequest
+ oldSet := appTokenKeychainSet
+ oldGet := appTokenKeychainGet
+ oldClient := appTokenHTTPClient
+ t.Cleanup(func() {
+ appTokenMarshalIndent = oldIndent
+ appTokenMarshal = oldMarshal
+ appTokenNewRequest = oldRequest
+ appTokenKeychainSet = oldSet
+ appTokenKeychainGet = oldGet
+ appTokenHTTPClient = oldClient
+ })
+ fail := errors.New("fail")
+ appTokenMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := SaveAppTokenData(&AppTokenData{ClientID: "id"}); !errors.Is(err, fail) {
+ t.Fatalf("app token marshal error = %v", err)
+ }
+ appTokenMarshalIndent = oldIndent
+ appTokenKeychainSet = func(string, string, string) error { return fail }
+ if err := SaveAppTokenData(&AppTokenData{ClientID: "id"}); !errors.Is(err, fail) {
+ t.Fatalf("app token keychain error = %v", err)
+ }
+ appTokenKeychainSet = oldSet
+ appTokenKeychainGet = func(string, string) (string, error) { return "", nil }
+ if got, err := LoadAppTokenData("id"); err != nil || got != nil {
+ t.Fatalf("empty app token = %#v %v", got, err)
+ }
+ appTokenKeychainGet = func(string, string) (string, error) { return "", fail }
+ if got, err := LoadAppTokenData("id"); err != nil || got != nil {
+ t.Fatalf("missing app token = %#v %v", got, err)
+ }
+ appTokenKeychainGet = oldGet
+ appTokenMarshal = func(any) ([]byte, error) { return nil, fail }
+ if _, _, err := FetchAppToken(context.Background(), "id", "secret"); !errors.Is(err, fail) {
+ t.Fatalf("app token request marshal error = %v", err)
+ }
+ appTokenMarshal = oldMarshal
+ appTokenNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
+ if _, _, err := FetchAppToken(context.Background(), "id", "secret"); !errors.Is(err, fail) {
+ t.Fatalf("app token request creation error = %v", err)
+ }
+ appTokenNewRequest = oldRequest
+
+ appTokenKeychainGet = func(string, string) (string, error) { return "{", nil }
+ appTokenHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"accessToken":"fresh","expireIn":7200}`)), Header: make(http.Header)}, nil
+ })}
+ appTokenKeychainSet = func(string, string, string) error { return fail }
+ if got, err := (&AppTokenProvider{AppKey: "id", AppSecret: "secret"}).GetToken(context.Background()); err != nil || got != "fresh" {
+ t.Fatalf("uncached app token with save failure = %q %v", got, err)
+ }
+ })
+
+ t.Run("defaults browser and strict keychain", func(t *testing.T) {
+ oldID := defaultAuthClientID
+ oldSecret := defaultAuthSecret
+ oldStart := browserStartCommand
+ oldEdition := edition.Get()
+ t.Cleanup(func() {
+ defaultAuthClientID = oldID
+ defaultAuthSecret = oldSecret
+ browserStartCommand = oldStart
+ edition.Override(oldEdition)
+ SetClientID("")
+ SetClientSecret("")
+ resetAppConfigCache()
+ })
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+ SetClientID("")
+ SetClientSecret("")
+ resetAppConfigCache()
+ defaultAuthClientID = "built-in-id"
+ defaultAuthSecret = "built-in-secret"
+ if ClientID() != "built-in-id" || ClientSecret() != "built-in-secret" {
+ t.Fatal("built-in credential fallback failed")
+ }
+
+ if err := SaveAppConfig(dir, &AppConfig{ClientID: "saved-id", ClientSecret: PlainSecret("saved-secret")}); err != nil {
+ t.Fatal(err)
+ }
+ resetAppConfigCache()
+ if got := ClientSecret(); got != "saved-secret" {
+ t.Fatalf("persisted client secret = %q", got)
+ }
+
+ browserStartCommand = func(string, ...string) error { return nil }
+ if err := openBrowser("https://example.test"); err != nil {
+ t.Fatal(err)
+ }
+ if err := oldStart("true"); err != nil {
+ t.Fatalf("default browser command hook = %v", err)
+ }
+
+ account := secretAccountKey("strict-id")
+ if err := keychain.Set(keychain.Service, account, "strict-secret"); err != nil {
+ t.Fatal(err)
+ }
+ config := `{"clientId":"strict-id","clientSecret":{"source":"keychain","id":` + fmt.Sprintf("%q", account) + `}}`
+ if err := os.WriteFile(GetAppConfigPath(dir), []byte(config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, secret, _, source, err := ResolveAppCredentialsStrict(dir); err != nil || secret != "strict-secret" || source != CredentialSourceKeychain {
+ t.Fatalf("strict keychain credentials = %q %q %v", secret, source, err)
+ }
+ })
+
+ t.Run("secret hooks", func(t *testing.T) {
+ oldGet := secretKeychainGet
+ oldSet := secretKeychainSet
+ t.Cleanup(func() { secretKeychainGet = oldGet; secretKeychainSet = oldSet })
+ var input SecretInput
+ if err := input.UnmarshalJSON([]byte("true")); err == nil {
+ t.Fatal("invalid secret JSON succeeded")
+ }
+ fail := errors.New("fail")
+ secretKeychainGet = func(string, string) (string, error) { return "", fail }
+ if _, err := ResolveSecret(SecretInput{Ref: &SecretRef{Source: "keychain", ID: "id"}}); !errors.Is(err, fail) {
+ t.Fatalf("secret keychain get error = %v", err)
+ }
+ secretKeychainSet = func(string, string, string) error { return fail }
+ if _, err := StoreSecret("id", PlainSecret("secret")); !errors.Is(err, fail) {
+ t.Fatalf("secret keychain set error = %v", err)
+ }
+ })
+
+ t.Run("portable export failures", func(t *testing.T) {
+ oldGOOS := portableRuntimeGOOS
+ oldGlob := portableGlob
+ oldMarshal := portableJSONMarshal
+ oldWalk := portableWalkDir
+ oldOpen := portableOpen
+ oldInRoot := portablePathInRoot
+ t.Cleanup(func() {
+ portableRuntimeGOOS = oldGOOS
+ portableGlob = oldGlob
+ portableJSONMarshal = oldMarshal
+ portableWalkDir = oldWalk
+ portableOpen = oldOpen
+ portablePathInRoot = oldInRoot
+ })
+ portableRuntimeGOOS = func() string { return "linux" }
+ keyRoot := t.TempDir()
+ t.Setenv(keychain.StorageDirEnv, keyRoot)
+ keyDir := keychain.StorageDir(keychain.Service)
+ if err := os.MkdirAll(keyDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(keyDir, keychain.AccountToken+".enc"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ configDir := t.TempDir()
+ portableGlob = func(string) ([]string, error) { return nil, errors.New("glob") }
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("portable config scan failure succeeded")
+ }
+ portableGlob = oldGlob
+ portableJSONMarshal = func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") }
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("portable manifest failure succeeded")
+ }
+ portableJSONMarshal = oldMarshal
+ portableWalkDir = func(string, fs.WalkDirFunc) error { return errors.New("walk") }
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("portable keychain walk failure succeeded")
+ }
+ portableWalkDir = oldWalk
+ configFile := filepath.Join(configDir, "app.json")
+ if err := os.WriteFile(configFile, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ portableOpen = func(name string) (io.ReadCloser, error) {
+ if name == configFile {
+ return nil, errors.New("open")
+ }
+ return oldOpen(name)
+ }
+ if err := ExportPortableAuthBundle(configDir, io.Discard); err == nil {
+ t.Fatal("portable config add failure succeeded")
+ }
+ portableOpen = oldOpen
+
+ portablePathInRoot = func(string, string) bool { return false }
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gz)
+ _ = tw.WriteHeader(&tar.Header{Name: "config/value", Typeflag: tar.TypeReg, Size: 1})
+ _, _ = tw.Write([]byte("x"))
+ _ = tw.Close()
+ _ = gz.Close()
+ if _, err := ImportPortableAuthBundle(t.TempDir(), bytes.NewReader(buf.Bytes())); err == nil {
+ t.Fatal("portable safe-join failure succeeded")
+ }
+ })
+
+ if tokenLogoutHTTPClient.CheckRedirect == nil {
+ t.Fatal("logout redirect guard is missing")
+ }
+ if err := tokenLogoutHTTPClient.CheckRedirect(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
+ t.Fatalf("logout redirect guard = %v", err)
+ }
+}
+
+func TestProfilesCoverageEdges(t *testing.T) {
+ oldAcquire := profilesAcquireDualLock
+ oldRead := profilesReadFile
+ oldRename := profilesRename
+ oldMkdir := profilesMkdirAll
+ oldMarshal := profilesMarshalIndent
+ oldWrite := profilesWriteFile
+ oldRemove := profilesRemove
+ oldLoad := profilesLoad
+ oldSave := profilesSave
+ oldEnsure := profilesEnsureMigration
+ oldSync := profilesSyncLegacyMirror
+ oldExists := profilesTokenExists
+ oldLoadLegacy := profilesLoadLegacy
+ oldSaveCorp := profilesSaveCorp
+ oldExistsCorp := profilesTokenExistsCorp
+ oldLoadCorp := profilesLoadCorp
+ oldSaveLegacy := profilesSaveLegacy
+ oldMarker := profilesWriteMarker
+ oldDeleteLegacy := profilesDeleteLegacy
+ oldDeleteMarker := profilesDeleteMarker
+ t.Cleanup(func() {
+ profilesAcquireDualLock = oldAcquire
+ profilesReadFile = oldRead
+ profilesRename = oldRename
+ profilesMkdirAll = oldMkdir
+ profilesMarshalIndent = oldMarshal
+ profilesWriteFile = oldWrite
+ profilesRemove = oldRemove
+ profilesLoad = oldLoad
+ profilesSave = oldSave
+ profilesEnsureMigration = oldEnsure
+ profilesSyncLegacyMirror = oldSync
+ profilesTokenExists = oldExists
+ profilesLoadLegacy = oldLoadLegacy
+ profilesSaveCorp = oldSaveCorp
+ profilesTokenExistsCorp = oldExistsCorp
+ profilesLoadCorp = oldLoadCorp
+ profilesSaveLegacy = oldSaveLegacy
+ profilesWriteMarker = oldMarker
+ profilesDeleteLegacy = oldDeleteLegacy
+ profilesDeleteMarker = oldDeleteMarker
+ })
+ fail := errors.New("fail")
+
+ profilesAcquireDualLock = func(context.Context, string) (*DualLock, error) { return nil, fail }
+ if err := withProfilesLock(t.TempDir(), func() error { return nil }); !errors.Is(err, fail) {
+ t.Fatalf("profiles lock error = %v", err)
+ }
+ profilesAcquireDualLock = oldAcquire
+
+ profilesReadFile = func(string) ([]byte, error) { return nil, fail }
+ if _, err := LoadProfiles(t.TempDir()); !errors.Is(err, fail) {
+ t.Fatalf("profiles read error = %v", err)
+ }
+ profilesReadFile = oldRead
+
+ dir := t.TempDir()
+ profilesMkdirAll = func(string, os.FileMode) error { return fail }
+ if err := SaveProfiles(dir, &ProfilesConfig{}); !errors.Is(err, fail) {
+ t.Fatalf("profiles mkdir error = %v", err)
+ }
+ profilesMkdirAll = oldMkdir
+ profilesMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := SaveProfiles(dir, &ProfilesConfig{}); !errors.Is(err, fail) {
+ t.Fatalf("profiles marshal error = %v", err)
+ }
+ profilesMarshalIndent = oldMarshal
+ profilesWriteFile = func(string, []byte, os.FileMode) error { return fail }
+ if err := SaveProfiles(dir, &ProfilesConfig{}); !errors.Is(err, fail) {
+ t.Fatalf("profiles write error = %v", err)
+ }
+ profilesWriteFile = oldWrite
+ profilesRename = func(string, string) error { return fail }
+ profilesRemove = func(string) error { return fail }
+ if err := SaveProfiles(dir, &ProfilesConfig{}); !errors.Is(err, fail) {
+ t.Fatalf("profiles rename error = %v", err)
+ }
+ profilesRename = oldRename
+ profilesRemove = oldRemove
+
+ profilesLoad = func(string) (*ProfilesConfig, error) { return nil, fail }
+ if err := ensureProfilesMigrationLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("migration profile load error = %v", err)
+ }
+ if err := upsertProfileFromTokenWithCurrentLocked(dir, &TokenData{}, true); !errors.Is(err, fail) {
+ t.Fatalf("upsert profile load error = %v", err)
+ }
+ profilesLoad = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
+ profilesTokenExists = func() bool { return true }
+ for name, token := range map[string]*TokenData{"nil": nil, "empty corp": {}} {
+ t.Run("migration "+name, func(t *testing.T) {
+ profilesLoadLegacy = func() (*TokenData, error) { return token, nil }
+ if err := ensureProfilesMigrationLocked(dir); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ profilesLoadLegacy = func() (*TokenData, error) { return nil, fail }
+ if err := ensureProfilesMigrationLocked(dir); err != nil {
+ t.Fatal("legacy token read errors should be ignored")
+ }
+ legacy := &TokenData{CorpID: "corp"}
+ profilesLoadLegacy = func() (*TokenData, error) { return legacy, nil }
+ profilesSaveCorp = func(string, *TokenData) error { return fail }
+ if err := ensureProfilesMigrationLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("migration corp save error = %v", err)
+ }
+ profilesSaveCorp = oldSaveCorp
+ profilesSave = func(string, *ProfilesConfig) error { return fail }
+ if err := ensureProfilesMigrationLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("migration profile save error = %v", err)
+ }
+
+ cfg := &ProfilesConfig{
+ PrimaryProfile: "primary", CurrentProfile: "current", PreviousProfile: "previous",
+ Profiles: []Profile{{Name: "primary", CorpID: "primary"}, {Name: "current", CorpID: "current"}, {Name: "previous", CorpID: "previous"}},
+ }
+ profilesEnsureMigration = func(string) error { return fail }
+ if _, err := ResolveProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("resolve migration error = %v", err)
+ }
+ if _, err := resolveProfileForLoad(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("load resolution migration error = %v", err)
+ }
+ if _, err := setCurrentProfileLocked(dir, "current"); !errors.Is(err, fail) {
+ t.Fatalf("set-current migration error = %v", err)
+ }
+ if _, err := usePreviousProfileLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("previous migration error = %v", err)
+ }
+ profilesEnsureMigration = func(string) error { return nil }
+ profilesLoad = func(string) (*ProfilesConfig, error) { return nil, fail }
+ if _, err := ResolveProfile(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("resolve load error = %v", err)
+ }
+ if _, err := resolveProfileForLoad(dir, ""); !errors.Is(err, fail) {
+ t.Fatalf("profile-for-load read error = %v", err)
+ }
+ if _, err := setCurrentProfileLocked(dir, "current"); !errors.Is(err, fail) {
+ t.Fatalf("set-current read error = %v", err)
+ }
+ if _, err := usePreviousProfileLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("previous read error = %v", err)
+ }
+ if _, err := removeProfileLocked(dir, "current"); !errors.Is(err, fail) {
+ t.Fatalf("remove read error = %v", err)
+ }
+ if err := markProfileStatusLocked(dir, "current", "expired"); !errors.Is(err, fail) {
+ t.Fatalf("mark status read error = %v", err)
+ }
+ if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("mirror read error = %v", err)
+ }
+ profilesLoad = func(string) (*ProfilesConfig, error) {
+ return &ProfilesConfig{
+ CurrentProfile: "current",
+ PreviousProfile: "missing",
+ Profiles: []Profile{{CorpID: "current"}},
+ }, nil
+ }
+ if _, err := usePreviousProfileLocked(dir); err == nil || !strings.Contains(err.Error(), "not found") {
+ t.Fatalf("missing previous profile error = %v", err)
+ }
+
+ profilesLoad = func(string) (*ProfilesConfig, error) {
+ clone := *cfg
+ clone.Profiles = append([]Profile(nil), cfg.Profiles...)
+ return &clone, nil
+ }
+ profilesSave = func(string, *ProfilesConfig) error { return nil }
+ if got, err := ResolveProfile(dir, ""); err != nil || got == nil || got.CorpID != "current" {
+ t.Fatalf("current profile resolution = %#v %v", got, err)
+ }
+ primaryOnly := &ProfilesConfig{PrimaryProfile: "primary", Profiles: []Profile{{Name: "primary", CorpID: "primary"}}}
+ profilesLoad = func(string) (*ProfilesConfig, error) { return primaryOnly, nil }
+ if got, err := ResolveProfile(dir, ""); err != nil || got == nil || got.CorpID != "primary" {
+ t.Fatalf("primary profile resolution = %#v %v", got, err)
+ }
+ profilesLoad = func(string) (*ProfilesConfig, error) { return &ProfilesConfig{}, nil }
+ if got, err := ResolveProfile(dir, ""); err != nil || got != nil {
+ t.Fatalf("empty profile resolution = %#v %v", got, err)
+ }
+
+ profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
+ profilesTokenExistsCorp = func(string) bool { return false }
+ if got, err := resolveProfileForLoad(dir, ""); err != nil || got == nil || got.CorpID != "current" {
+ t.Fatalf("profile-for-load current fallback = %#v %v", got, err)
+ }
+ cfgNoCurrent := &ProfilesConfig{PrimaryProfile: "primary", Profiles: []Profile{{CorpID: "primary"}}}
+ profilesLoad = func(string) (*ProfilesConfig, error) { return cfgNoCurrent, nil }
+ if got, err := resolveProfileForLoad(dir, ""); err != nil || got == nil || got.CorpID != "primary" {
+ t.Fatalf("profile-for-load primary fallback = %#v %v", got, err)
+ }
+
+ profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
+ profilesSave = func(string, *ProfilesConfig) error { return fail }
+ if _, err := setCurrentProfileLocked(dir, "current"); !errors.Is(err, fail) {
+ t.Fatalf("set-current save error = %v", err)
+ }
+ if _, err := usePreviousProfileLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("previous save error = %v", err)
+ }
+ if _, err := removeProfileLocked(dir, "current"); !errors.Is(err, fail) {
+ t.Fatalf("remove save error = %v", err)
+ }
+ cfg = &ProfilesConfig{
+ PrimaryProfile: "primary", CurrentProfile: "current", PreviousProfile: "previous",
+ Profiles: []Profile{{Name: "primary", CorpID: "primary"}, {Name: "current", CorpID: "current"}, {Name: "previous", CorpID: "previous"}},
+ }
+ profilesSave = func(string, *ProfilesConfig) error { return nil }
+ profilesSyncLegacyMirror = func(string) error { return fail }
+ if _, err := setCurrentProfileLocked(dir, "current"); !errors.Is(err, fail) {
+ t.Fatalf("set-current mirror error = %v", err)
+ }
+ if _, err := usePreviousProfileLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("previous mirror error = %v", err)
+ }
+
+ profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil }
+ profilesLoadCorp = func(string) (*TokenData, error) { return &TokenData{AccessToken: "x"}, nil }
+ profilesSaveLegacy = func(*TokenData) error { return fail }
+ if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("mirror save error = %v", err)
+ }
+ profilesSaveLegacy = func(*TokenData) error { return nil }
+ profilesWriteMarker = func(string) error { return fail }
+ if err := syncLegacyTokenMirrorLocked(dir); !errors.Is(err, fail) {
+ t.Fatalf("mirror marker error = %v", err)
+ }
+ profilesLoadCorp = func(string) (*TokenData, error) { return nil, fail }
+ if err := syncLegacyTokenMirrorLocked(dir); err != nil {
+ t.Fatal("transient mirror read error should be ignored")
+ }
+ profilesLoadCorp = func(string) (*TokenData, error) { return nil, nil }
+ deletedLegacy, deletedMarker := false, false
+ profilesDeleteLegacy = func() error { deletedLegacy = true; return fail }
+ profilesDeleteMarker = func(string) error { deletedMarker = true; return fail }
+ if err := syncLegacyTokenMirrorLocked(dir); err != nil || !deletedLegacy || !deletedMarker {
+ t.Fatalf("empty mirror cleanup = %v %v %v", err, deletedLegacy, deletedMarker)
+ }
+
+ normalizeProfilesConfig(nil)
+}
+
+func TestSecureIdentityKeychainAndLockEdges(t *testing.T) {
+ t.Run("secure store", func(t *testing.T) {
+ oldGetMAC := secureGetMAC
+ oldMkdir := secureMkdirAll
+ oldStat := secureStat
+ oldChmod := secureChmod
+ oldMarshal := secureMarshalIndent
+ oldEncrypt := secureEncrypt
+ oldOpen := secureOpenFile
+ oldRemove := secureRemove
+ oldRename := secureRename
+ oldRead := secureReadFile
+ oldDecrypt := secureDecrypt
+ oldUnmarshal := secureUnmarshal
+ t.Cleanup(func() {
+ secureGetMAC = oldGetMAC
+ secureMkdirAll = oldMkdir
+ secureStat = oldStat
+ secureChmod = oldChmod
+ secureMarshalIndent = oldMarshal
+ secureEncrypt = oldEncrypt
+ secureOpenFile = oldOpen
+ secureRemove = oldRemove
+ secureRename = oldRename
+ secureReadFile = oldRead
+ secureDecrypt = oldDecrypt
+ secureUnmarshal = oldUnmarshal
+ cachedMACOnce = sync.Once{}
+ cachedMAC, cachedMACErr = "", nil
+ })
+ resetMAC := func() {
+ cachedMACOnce = sync.Once{}
+ cachedMAC, cachedMACErr = "", nil
+ }
+ fail := errors.New("fail")
+ secureGetMAC = func() (string, error) { return "", fail }
+ resetMAC()
+ if _, err := resolvePassword(); !errors.Is(err, fail) {
+ t.Fatalf("password MAC error = %v", err)
+ }
+ if err := SaveSecureTokenData(t.TempDir(), &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure save password error = %v", err)
+ }
+ resetMAC()
+ if _, err := LoadSecureTokenData(t.TempDir()); !errors.Is(err, fail) {
+ t.Fatalf("secure load password error = %v", err)
+ }
+ secureGetMAC = func() (string, error) { return "00:11:22:33:44:55", nil }
+ resetMAC()
+ dir := t.TempDir()
+ secureMkdirAll = func(string, os.FileMode) error { return fail }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure mkdir error = %v", err)
+ }
+ secureMkdirAll = oldMkdir
+ if err := os.Chmod(dir, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ secureChmod = func(string, os.FileMode) error { return fail }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure chmod error = %v", err)
+ }
+ secureChmod = oldChmod
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ secureMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure marshal error = %v", err)
+ }
+ secureMarshalIndent = oldMarshal
+ secureEncrypt = func([]byte, []byte) ([]byte, error) { return nil, fail }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure encrypt error = %v", err)
+ }
+ secureEncrypt = oldEncrypt
+ secureOpenFile = func(string, int, os.FileMode) (secureTempFile, error) { return nil, fail }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure temp open error = %v", err)
+ }
+ for name, configure := range map[string]func(*fakeSecureTemp){
+ "write": func(f *fakeSecureTemp) { f.writeErr = fail },
+ "sync": func(f *fakeSecureTemp) { f.syncErr = fail },
+ "close": func(f *fakeSecureTemp) { f.closeErr = fail },
+ } {
+ t.Run(name, func(t *testing.T) {
+ f := &fakeSecureTemp{}
+ configure(f)
+ secureOpenFile = func(string, int, os.FileMode) (secureTempFile, error) { return f, nil }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure %s error = %v", name, err)
+ }
+ })
+ }
+ secureOpenFile = func(string, int, os.FileMode) (secureTempFile, error) { return &fakeSecureTemp{}, nil }
+ secureRename = func(string, string) error { return fail }
+ secureRemove = func(string) error { return fail }
+ if err := SaveSecureTokenData(dir, &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("secure rename error = %v", err)
+ }
+ secureRename = oldRename
+ secureOpenFile = oldOpen
+ secureRemove = oldRemove
+
+ secureReadFile = func(string) ([]byte, error) { return nil, fail }
+ if _, err := LoadSecureTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("secure read error = %v", err)
+ }
+ secureReadFile = func(string) ([]byte, error) { return []byte("cipher"), nil }
+ secureDecrypt = func([]byte, []byte) ([]byte, error) { return nil, fail }
+ if _, err := LoadSecureTokenData(dir); !errors.Is(err, ErrTokenDecryption) {
+ t.Fatalf("secure decrypt error = %v", err)
+ }
+ secureDecrypt = func([]byte, []byte) ([]byte, error) { return []byte("{}"), nil }
+ secureUnmarshal = func([]byte, any) error { return fail }
+ if _, err := LoadSecureTokenData(dir); !errors.Is(err, fail) {
+ t.Fatalf("secure unmarshal error = %v", err)
+ }
+ secureRemove = func(string) error { return fail }
+ if err := DeleteSecureData(dir); !errors.Is(err, fail) {
+ t.Fatalf("secure delete error = %v", err)
+ }
+ })
+
+ t.Run("keychain store", func(t *testing.T) {
+ oldMarshal := authKeychainMarshal
+ oldUnmarshal := authKeychainUnmarshal
+ oldSet := authKeychainSet
+ oldGet := authKeychainGet
+ oldMigrate := authKeychainMigrate
+ t.Cleanup(func() {
+ authKeychainMarshal = oldMarshal
+ authKeychainUnmarshal = oldUnmarshal
+ authKeychainSet = oldSet
+ authKeychainGet = oldGet
+ authKeychainMigrate = oldMigrate
+ migrationOnce = sync.Once{}
+ })
+ fail := errors.New("fail")
+ authKeychainMarshal = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := saveTokenDataKeychainAccount("account", &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("keychain marshal error = %v", err)
+ }
+ authKeychainMarshal = oldMarshal
+ authKeychainSet = func(string, string, string) error { return fail }
+ if err := saveTokenDataKeychainAccount("account", &TokenData{}); !errors.Is(err, fail) {
+ t.Fatalf("keychain set error = %v", err)
+ }
+ if err := SaveClientSecret("id", "secret"); !errors.Is(err, fail) {
+ t.Fatalf("client-secret set error = %v", err)
+ }
+ authKeychainSet = oldSet
+ authKeychainGet = func(string, string) (string, error) { return "", fail }
+ if _, err := loadTokenDataKeychainAccount("account"); !errors.Is(err, fail) {
+ t.Fatalf("keychain get error = %v", err)
+ }
+ if LoadClientSecret("id") != "" {
+ t.Fatal("client-secret get error returned a value")
+ }
+ authKeychainGet = func(string, string) (string, error) { return "{}", nil }
+ authKeychainUnmarshal = func([]byte, any) error { return fail }
+ if _, err := loadTokenDataKeychainAccount("account"); !errors.Is(err, fail) {
+ t.Fatalf("keychain unmarshal error = %v", err)
+ }
+
+ for name, result := range map[string]*keychain.MigrationResult{
+ "migrated": {Migrated: true, FromPath: "from", BackupPath: "backup"},
+ "relogin": {NeedRelogin: true, Error: fail},
+ "error": {Error: fail},
+ } {
+ t.Run("migration "+name, func(t *testing.T) {
+ migrationOnce = sync.Once{}
+ authKeychainMigrate = func(string) *keychain.MigrationResult { return result }
+ EnsureMigration(t.TempDir(), slog.Default())
+ })
+ }
+ })
+
+ t.Run("identity", func(t *testing.T) {
+ oldRead := identityReadFile
+ oldUnmarshal := identityUnmarshal
+ oldMkdir := identityMkdirAll
+ oldMarshal := identityMarshalIndent
+ oldWrite := identityWriteFile
+ oldRand := identityRandRead
+ oldEdition := edition.Get()
+ t.Cleanup(func() {
+ identityReadFile = oldRead
+ identityUnmarshal = oldUnmarshal
+ identityMkdirAll = oldMkdir
+ identityMarshalIndent = oldMarshal
+ identityWriteFile = oldWrite
+ identityRandRead = oldRand
+ edition.Override(oldEdition)
+ })
+ id := &Identity{MachineID: "machine"}
+ id.migrate()
+ if id.AgentID != "machine" || id.Source != "dws" || id.Agents == nil {
+ t.Fatalf("identity migration = %#v", id)
+ }
+ id.Agents = nil
+ if got := id.ResolveAgentID(t.TempDir(), "agent", "test"); got == "" || id.Agents == nil {
+ t.Fatal("identity agent map was not initialized")
+ }
+ edition.Override(&edition.Hooks{Name: "coverage", ScenarioCode: "custom-scenario"})
+ if id.Headers()["x-dingtalk-scenario-code"] != "custom-scenario" {
+ t.Fatal("identity scenario override failed")
+ }
+ fail := errors.New("fail")
+ identityMkdirAll = func(string, os.FileMode) error { return fail }
+ if err := save(t.TempDir(), id); !errors.Is(err, fail) {
+ t.Fatalf("identity mkdir error = %v", err)
+ }
+ identityMkdirAll = oldMkdir
+ identityMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := save(t.TempDir(), id); !errors.Is(err, fail) {
+ t.Fatalf("identity marshal error = %v", err)
+ }
+ identityMarshalIndent = oldMarshal
+ identityWriteFile = func(string, []byte, os.FileMode) error { return fail }
+ if err := save(t.TempDir(), id); !errors.Is(err, fail) {
+ t.Fatalf("identity write error = %v", err)
+ }
+ if formatDerivedAgentID("x") != "dwsa_00000000000x" || base62Encode([]byte{0}) != "0" {
+ t.Fatal("identity short encoding failed")
+ }
+ identityRandRead = func([]byte) (int, error) { return 0, fail }
+ if generateUUID() != "00000000-0000-4000-8000-000000000000" {
+ t.Fatal("identity UUID fallback failed")
+ }
+ })
+
+ t.Run("file lock", func(t *testing.T) {
+ oldMkdir := fileLockMkdirAll
+ oldOpen := fileLockOpenFile
+ oldTry := fileLockTry
+ oldNow := fileLockNow
+ oldSleep := fileLockSleep
+ oldProcess := dualAcquireProcessLock
+ oldToken := dualAcquireTokenLock
+ t.Cleanup(func() {
+ fileLockMkdirAll = oldMkdir
+ fileLockOpenFile = oldOpen
+ fileLockTry = oldTry
+ fileLockNow = oldNow
+ fileLockSleep = oldSleep
+ dualAcquireProcessLock = oldProcess
+ dualAcquireTokenLock = oldToken
+ processLocks = sync.Map{}
+ })
+ fail := errors.New("fail")
+ key := processLockKey("bad-type")
+ processLocks.Store(key, "not-a-channel")
+ release, waited, err := acquireProcessLock(context.Background(), "bad-type")
+ if err != nil || waited {
+ t.Fatalf("bad process lock recovery = %v %v", waited, err)
+ }
+ release()
+ held, _, err := acquireProcessLock(context.Background(), "held")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, waited, err := acquireProcessLock(ctx, "held"); err == nil || !waited {
+ t.Fatalf("canceled process wait = %v %v", waited, err)
+ }
+ held()
+
+ fileLockMkdirAll = func(string, os.FileMode) error { return fail }
+ if _, err := acquireTokenLock(t.TempDir()); !errors.Is(err, fail) {
+ t.Fatalf("token lock mkdir error = %v", err)
+ }
+ fileLockMkdirAll = oldMkdir
+ fileLockOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
+ if _, err := acquireTokenLock(t.TempDir()); !errors.Is(err, fail) {
+ t.Fatalf("token lock open error = %v", err)
+ }
+ fileLockOpenFile = oldOpen
+ fileLockTry = func(*os.File) error { return fail }
+ base := time.Now()
+ calls := 0
+ fileLockNow = func() time.Time {
+ calls++
+ if calls < 3 {
+ return base
+ }
+ return base.Add(time.Hour)
+ }
+ slept := false
+ fileLockSleep = func(time.Duration) { slept = true }
+ if _, err := acquireTokenLock(t.TempDir()); err == nil || !slept {
+ t.Fatalf("token lock timeout = %v, slept=%v", err, slept)
+ }
+
+ dualAcquireProcessLock = func(context.Context, string) (func(), bool, error) { return nil, false, fail }
+ if _, err := AcquireDualLock(context.Background(), t.TempDir()); !errors.Is(err, fail) {
+ t.Fatalf("dual process lock error = %v", err)
+ }
+ released := false
+ dualAcquireProcessLock = func(context.Context, string) (func(), bool, error) {
+ return func() { released = true }, true, nil
+ }
+ dualAcquireTokenLock = func(string) (*tokenFileLock, error) { return nil, fail }
+ if _, err := AcquireDualLock(context.Background(), t.TempDir()); !errors.Is(err, fail) || !released {
+ t.Fatalf("dual file lock error = %v released=%v", err, released)
+ }
+ })
+}
+
+func TestAppConfigHookCoverageEdges(t *testing.T) {
+ oldStore := appConfigStoreSecret
+ oldMarshal := appConfigMarshalIndent
+ oldWrite := appConfigAtomicWrite
+ oldRead := appConfigReadFile
+ oldRemove := appConfigRemove
+ oldLoad := appConfigLoad
+ oldResolve := appConfigResolveSecret
+ oldBeforeLock := appConfigBeforeResolveLock
+ oldEdition := edition.Get()
+ t.Cleanup(func() {
+ appConfigStoreSecret = oldStore
+ appConfigMarshalIndent = oldMarshal
+ appConfigAtomicWrite = oldWrite
+ appConfigReadFile = oldRead
+ appConfigRemove = oldRemove
+ appConfigLoad = oldLoad
+ appConfigResolveSecret = oldResolve
+ appConfigBeforeResolveLock = oldBeforeLock
+ edition.Override(oldEdition)
+ resetAppConfigCache()
+ })
+ fail := errors.New("fail")
+ dir := t.TempDir()
+ appConfigStoreSecret = func(string, SecretInput) (SecretInput, error) { return SecretInput{}, fail }
+ if err := SaveAppConfig(dir, &AppConfig{ClientID: "id", ClientSecret: PlainSecret("secret")}); !errors.Is(err, fail) {
+ t.Fatalf("app config secret-store error = %v", err)
+ }
+ appConfigStoreSecret = oldStore
+ appConfigMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
+ if err := SaveAppConfig(dir, &AppConfig{ClientID: "id"}); !errors.Is(err, fail) {
+ t.Fatalf("app config marshal error = %v", err)
+ }
+ appConfigMarshalIndent = oldMarshal
+ appConfigAtomicWrite = func(string, []byte) error { return fail }
+ if err := SaveAppConfig(dir, &AppConfig{ClientID: "id"}); !errors.Is(err, fail) {
+ t.Fatalf("app config write error = %v", err)
+ }
+ appConfigAtomicWrite = oldWrite
+
+ cleanupLegacySiblingAppConfig(dir, nil)
+ edition.Override(&edition.Hooks{Name: "coverage"})
+ cleanupLegacySiblingAppConfig(dir, &AppConfig{ClientID: "id"})
+ legacyPath := filepath.Join(dir, appConfigFile)
+ if err := os.WriteFile(legacyPath, []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cleanupLegacySiblingAppConfig(dir, &AppConfig{ClientID: "id"})
+ if err := os.WriteFile(legacyPath, []byte(`{"clientId":"other"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cleanupLegacySiblingAppConfig(dir, &AppConfig{ClientID: "id"})
+ if err := os.WriteFile(legacyPath, []byte(`{"clientId":"id"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ appConfigRemove = func(string) error { return fail }
+ cleanupLegacySiblingAppConfig(dir, &AppConfig{ClientID: "id"})
+ appConfigReadFile = func(string) ([]byte, error) { return nil, fail }
+ cleanupLegacySiblingAppConfig(dir, &AppConfig{ClientID: "id"})
+ appConfigReadFile = oldRead
+
+ appConfigRemove = func(string) error { return fail }
+ if err := DeleteAppConfig(dir); !errors.Is(err, fail) {
+ t.Fatalf("app config delete error = %v", err)
+ }
+ appConfigRemove = oldRemove
+ appConfigLoad = func(string) (*AppConfig, error) { return nil, fail }
+ if _, err := ReloadAppConfig(dir); !errors.Is(err, fail) {
+ t.Fatalf("app config reload error = %v", err)
+ }
+ resetAppConfigCache()
+ appConfigLoad = oldLoad
+ if err := os.WriteFile(GetAppConfigPath(dir), []byte(`{"clientId":"id","clientSecret":"secret"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReloadAppConfig(dir); err != nil {
+ t.Fatal(err)
+ }
+ resetAppConfigCache()
+ appConfigResolveSecret = func(SecretInput) (string, error) { return "", fail }
+ if id, secret := ResolveAppCredentials(dir); id != "id" || secret != "" {
+ t.Fatalf("app config resolve failure = %q %q", id, secret)
+ }
+ resetAppConfigCache()
+ appConfigBeforeResolveLock = func() {
+ cachedResolvedMu.Lock()
+ cachedResolvedID = "raced-id"
+ cachedResolvedSecret = "raced-secret"
+ cachedResolvedValid = true
+ cachedResolvedMu.Unlock()
+ }
+ if id, secret := ResolveAppCredentials(dir); id != "raced-id" || secret != "raced-secret" {
+ t.Fatalf("app config double-check = %q %q", id, secret)
+ }
+}
+
+func TestDeviceFlowHighLevelCoverageEdges(t *testing.T) {
+ oldFetch := deviceFetchClientID
+ oldLogin := deviceLoginOnce
+ oldRequest := deviceRequestCode
+ oldWait := deviceWaitAuth
+ oldExchange := deviceExchangeCode
+ oldCheck := deviceCheckCLIAuth
+ oldAdmins := deviceGetAdmins
+ oldSaveToken := deviceSaveToken
+ oldHasApp := deviceHasAppConfig
+ oldSaveApp := deviceSaveAppConfig
+ oldPollStatus := devicePollStatus
+ oldPollToken := devicePollToken
+ oldAfter := deviceFlowAfter
+ oldBrowser := deviceOpenBrowser
+ t.Cleanup(func() {
+ deviceFetchClientID = oldFetch
+ deviceLoginOnce = oldLogin
+ deviceRequestCode = oldRequest
+ deviceWaitAuth = oldWait
+ deviceExchangeCode = oldExchange
+ deviceCheckCLIAuth = oldCheck
+ deviceGetAdmins = oldAdmins
+ deviceSaveToken = oldSaveToken
+ deviceHasAppConfig = oldHasApp
+ deviceSaveAppConfig = oldSaveApp
+ devicePollStatus = oldPollStatus
+ devicePollToken = oldPollToken
+ deviceFlowAfter = oldAfter
+ deviceOpenBrowser = oldBrowser
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ })
+ fail := errors.New("fail")
+ p := &DeviceFlowProvider{configDir: t.TempDir(), clientID: "client", Output: io.Discard, logger: slog.Default(), NoBrowser: true}
+
+ SetClientID("runtime-id")
+ SetClientSecret("runtime-secret")
+ deviceLoginOnce = func(p *DeviceFlowProvider, _ context.Context, _ int) (*TokenData, error) {
+ if p.clientID != "runtime-id" {
+ t.Fatalf("runtime device client ID = %q", p.clientID)
+ }
+ return &TokenData{AccessToken: "access"}, nil
+ }
+ if _, err := p.Login(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ SetClientID("")
+ SetClientSecret("")
+ deviceFetchClientID = func(context.Context) (string, error) { return "", fail }
+ if _, err := p.Login(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("device client-ID fetch error = %v", err)
+ }
+ deviceFetchClientID = func(context.Context) (string, error) { return "mcp-id", nil }
+ deviceLoginOnce = func(*DeviceFlowProvider, context.Context, int) (*TokenData, error) { return nil, fail }
+ if _, err := p.Login(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("device login error = %v", err)
+ }
+
+ deviceRequestCode = func(*DeviceFlowProvider, context.Context) (*DeviceAuthResponse, error) { return nil, fail }
+ if _, err := p.loginOnce(context.Background(), 1); !errors.Is(err, fail) {
+ t.Fatalf("device-code request error = %v", err)
+ }
+ authResp := &DeviceAuthResponse{DeviceCode: "device", UserCode: "user", VerificationURIComplete: "https://example.test", Interval: 1, ExpiresIn: 10}
+ deviceRequestCode = func(*DeviceFlowProvider, context.Context) (*DeviceAuthResponse, error) { return authResp, nil }
+ deviceOpenBrowser = func(string) error { return fail }
+ p.NoBrowser = false
+ deviceWaitAuth = func(*DeviceFlowProvider, context.Context, *DeviceAuthResponse) (*DeviceTokenResponse, error) {
+ return nil, fail
+ }
+ if _, err := p.loginOnce(context.Background(), 1); !errors.Is(err, fail) {
+ t.Fatalf("device wait error = %v", err)
+ }
+ p.NoBrowser = true
+ deviceWaitAuth = func(*DeviceFlowProvider, context.Context, *DeviceAuthResponse) (*DeviceTokenResponse, error) {
+ return &DeviceTokenResponse{AuthCode: "code"}, nil
+ }
+ deviceExchangeCode = func(*OAuthProvider, context.Context, string) (*TokenData, error) { return nil, fail }
+ if _, err := p.loginOnce(context.Background(), 1); !errors.Is(err, fail) {
+ t.Fatalf("device exchange error = %v", err)
+ }
+ token := &TokenData{AccessToken: "access"}
+ deviceExchangeCode = func(*OAuthProvider, context.Context, string) (*TokenData, error) { return token, nil }
+ deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { return nil, fail }
+ if _, err := p.loginOnce(context.Background(), 1); !errors.Is(err, fail) {
+ t.Fatalf("device CLI-auth check error = %v", err)
+ }
+
+ denials := []struct {
+ name string
+ channel string
+ status *CLIAuthStatus
+ }{
+ {"user forbidden", "", &CLIAuthStatus{Success: true, Result: &CLIAuthResult{UserScope: "forbidden"}}},
+ {"user excluded", "", &CLIAuthStatus{Success: true, Result: &CLIAuthResult{UserScope: "specified"}}},
+ {"channel excluded", "bad", &CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"good"}}}},
+ {"channel required", "", &CLIAuthStatus{Success: false, ErrorCode: "CHANNEL_REQUIRED"}},
+ {"enterprise default", "", &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED"}},
+ {"enterprise message", "", &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED", ErrorMsg: " custom "}},
+ {"no auth", "", &CLIAuthStatus{Success: false, ErrorCode: "NO_AUTH"}},
+ }
+ for _, tc := range denials {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("DWS_CHANNEL", tc.channel)
+ deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { return tc.status, nil }
+ if _, err := p.loginOnce(context.Background(), 1); err == nil {
+ t.Fatal("denied device login succeeded")
+ }
+ })
+ }
+
+ t.Setenv("DWS_CHANNEL", "")
+ deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
+ return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}, nil
+ }
+ deviceGetAdmins = func(context.Context, string) (*SuperAdminResponse, error) {
+ return &SuperAdminResponse{Success: true, Result: []SuperAdmin{{Name: "Admin"}}}, nil
+ }
+ if _, err := p.loginOnce(context.Background(), 1); err == nil {
+ t.Fatal("disabled CLI auth device login succeeded")
+ }
+ deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
+ return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
+ }
+ deviceSaveToken = func(string, *TokenData) error { return fail }
+ if _, err := p.loginOnce(context.Background(), 1); !errors.Is(err, fail) {
+ t.Fatalf("device token save error = %v", err)
+ }
+ deviceSaveToken = func(string, *TokenData) error { return nil }
+ deviceHasAppConfig = func(string) bool { return false }
+ appSaved := false
+ deviceSaveAppConfig = func(string, *AppConfig) error { appSaved = true; return fail }
+ if _, err := p.loginOnce(context.Background(), 1); err != nil || !appSaved {
+ t.Fatalf("successful device login = %v appSaved=%v", err, appSaved)
+ }
+
+ deviceFlowAfter = func(time.Duration) <-chan time.Time {
+ ch := make(chan time.Time, 1)
+ ch <- time.Now()
+ return ch
+ }
+ flowAuth := &DeviceAuthResponse{FlowID: "flow", Interval: 1, ExpiresIn: 30}
+ flowCases := []struct {
+ name string
+ responses []*DevicePollResponse
+ errors []error
+ wantErr bool
+ }{
+ {"network then approved", []*DevicePollResponse{nil, {Success: true, Data: DevicePollData{Status: StatusApproved, AuthCode: "code"}}}, []error{fail, nil}, false},
+ {"pending then approved", []*DevicePollResponse{{Success: true, Data: DevicePollData{Status: StatusPending}}, {Success: true, Data: DevicePollData{Status: StatusApproved}}}, nil, false},
+ {"rejected", []*DevicePollResponse{{Success: true, Data: DevicePollData{Status: StatusRejected}}}, nil, true},
+ {"expired", []*DevicePollResponse{{Success: true, Data: DevicePollData{Status: StatusExpired}}}, nil, true},
+ {"unknown then approved", []*DevicePollResponse{{Success: true, Data: DevicePollData{Status: "FUTURE"}}, {Success: true, Data: DevicePollData{Status: StatusApproved}}}, nil, false},
+ }
+ for _, tc := range flowCases {
+ t.Run("flow "+tc.name, func(t *testing.T) {
+ call := 0
+ devicePollStatus = func(*DeviceFlowProvider, context.Context, string) (*DevicePollResponse, error) {
+ i := call
+ call++
+ if i < len(tc.errors) && tc.errors[i] != nil {
+ return nil, tc.errors[i]
+ }
+ return tc.responses[i], nil
+ }
+ _, err := p.waitForAuthorizationByFlowID(context.Background(), flowAuth)
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("flow wait error = %v want %v", err, tc.wantErr)
+ }
+ })
+ }
+ deviceAuth := &DeviceAuthResponse{DeviceCode: "device", Interval: 30, ExpiresIn: 30}
+ deviceCases := []struct {
+ name string
+ responses []*DeviceTokenResponse
+ errors []error
+ wantErr bool
+ }{
+ {"network then success", []*DeviceTokenResponse{nil, {AuthCode: "code"}}, []error{fail, nil}, false},
+ {"pending then success", []*DeviceTokenResponse{{Error: "authorization_pending"}, {AuthCode: "code"}}, nil, false},
+ {"slow then success", []*DeviceTokenResponse{{Error: "slow_down"}, {AuthCode: "code"}}, nil, false},
+ {"denied", []*DeviceTokenResponse{{Error: "access_denied"}}, nil, true},
+ {"expired", []*DeviceTokenResponse{{Error: "expired_token"}}, nil, true},
+ {"unknown then success", []*DeviceTokenResponse{{Error: "future"}, {AuthCode: "code"}}, nil, false},
+ }
+ for _, tc := range deviceCases {
+ t.Run("device "+tc.name, func(t *testing.T) {
+ call := 0
+ devicePollToken = func(*DeviceFlowProvider, context.Context, string) (*DeviceTokenResponse, error) {
+ i := call
+ call++
+ if i < len(tc.errors) && tc.errors[i] != nil {
+ return nil, tc.errors[i]
+ }
+ return tc.responses[i], nil
+ }
+ _, err := p.waitForAuthorizationByDeviceCode(context.Background(), deviceAuth)
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("device wait error = %v want %v", err, tc.wantErr)
+ }
+ })
+ }
+
+ statusClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("down")), Header: make(http.Header)}, nil
+ })}
+ p.httpClient = statusClient
+ p.baseURL = "https://example.test"
+ p.terminalBaseURL = "https://example.test"
+ if _, err := p.requestDeviceCode(context.Background()); err == nil {
+ t.Fatal("device-code HTTP error succeeded")
+ }
+ if _, err := p.pollDeviceToken(context.Background(), "device"); err == nil {
+ t.Fatal("device-token HTTP error succeeded")
+ }
+ if _, err := p.pollDeviceStatus(context.Background(), "flow"); err == nil {
+ t.Fatal("device-status HTTP error succeeded")
+ }
+ if _, err := p.postForm(context.Background(), "https://example.test", url.Values{}); err == nil {
+ t.Fatal("form HTTP status error succeeded")
+ }
+ if _, err := p.doGet(context.Background(), "https://example.test"); err == nil {
+ t.Fatal("GET HTTP status error succeeded")
+ }
+ dfPrintBox(io.Discard, []string{"short"})
+}
diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go
index 8d25da53..02e96332 100644
--- a/internal/auth/device_flow.go
+++ b/internal/auth/device_flow.go
@@ -43,6 +43,37 @@ const (
maxPollTotalWait = 10 * time.Minute
)
+var (
+ deviceFlowAfter = time.After
+ deviceOpenBrowser = openBrowser
+ deviceFetchClientID = FetchClientIDFromMCP
+ deviceLoginOnce = func(p *DeviceFlowProvider, ctx context.Context, attempt int) (*TokenData, error) {
+ return p.loginOnce(ctx, attempt)
+ }
+ deviceRequestCode = func(p *DeviceFlowProvider, ctx context.Context) (*DeviceAuthResponse, error) {
+ return p.requestDeviceCode(ctx)
+ }
+ deviceWaitAuth = func(p *DeviceFlowProvider, ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
+ return p.waitForAuthorization(ctx, auth)
+ }
+ deviceExchangeCode = func(p *OAuthProvider, ctx context.Context, code string) (*TokenData, error) {
+ return p.exchangeCode(ctx, code)
+ }
+ deviceCheckCLIAuth = func(p *OAuthProvider, ctx context.Context, token string) (*CLIAuthStatus, error) {
+ return p.CheckCLIAuthEnabled(ctx, token)
+ }
+ deviceGetAdmins = GetSuperAdmins
+ deviceSaveToken = SaveTokenData
+ deviceHasAppConfig = HasAppConfig
+ deviceSaveAppConfig = SaveAppConfig
+ devicePollStatus = func(p *DeviceFlowProvider, ctx context.Context, flowID string) (*DevicePollResponse, error) {
+ return p.pollDeviceStatus(ctx, flowID)
+ }
+ devicePollToken = func(p *DeviceFlowProvider, ctx context.Context, code string) (*DeviceTokenResponse, error) {
+ return p.pollDeviceToken(ctx, code)
+ }
+)
+
type DeviceFlowProvider struct {
configDir string
clientID string
@@ -181,7 +212,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
}
- mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
+ mcpClientID, mcpErr := deviceFetchClientID(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
@@ -193,8 +224,8 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
}
const maxAttempts = 3
- for attempt := 1; attempt <= maxAttempts; attempt++ {
- tokenData, err := p.loginOnce(ctx, attempt)
+ for attempt := 1; ; attempt++ {
+ tokenData, err := deviceLoginOnce(p, ctx, attempt)
if err == nil {
return tokenData, nil
}
@@ -205,21 +236,20 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
}
return nil, err
}
- return nil, fmt.Errorf("%s", i18n.Tf("设备授权流程失败(已重试 %d 次)", maxAttempts))
}
func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*TokenData, error) {
dfPrintStep(p.output(), 1, i18n.T("请求设备授权码..."), attempt)
_, _ = fmt.Fprintln(p.output(), "")
- authResp, err := p.requestDeviceCode(ctx)
+ authResp, err := deviceRequestCode(p, ctx)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("请求设备授权码失败"), err)
}
dfPrintDeviceCodeBox(p.output(), authResp)
if authResp.VerificationURIComplete != "" && !p.NoBrowser {
- if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
+ if bErr := deviceOpenBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
p.logger.Debug("could not open browser", "error", bErr)
}
}
@@ -228,7 +258,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
dfPrintDim(p.output(), fmt.Sprintf(i18n.T(" (每 %d 秒轮询一次)"), authResp.Interval))
_, _ = fmt.Fprintln(p.output(), "")
- tokenResult, err := p.waitForAuthorization(ctx, authResp)
+ tokenResult, err := deviceWaitAuth(p, ctx, authResp)
if err != nil {
return nil, err
}
@@ -241,14 +271,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
clientID: p.clientID,
logger: p.logger,
}
- tokenData, err := oauthProvider.exchangeCode(ctx, tokenResult.AuthCode)
+ tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
// Check if CLI auth is enabled for this organization (fail-closed: block on error)
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
- authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
+ authStatus, authErr := deviceCheckCLIAuth(oauthProvider, ctx, tokenData.AccessToken)
if authErr != nil {
_, _ = fmt.Fprintln(p.output(), "")
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态")))
@@ -299,7 +329,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
_, _ = fmt.Fprintln(p.output(), "")
- admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
+ admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
maxAdmins := 3
if len(admins.Result) < maxAdmins {
@@ -322,7 +352,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
- if err := SaveTokenData(p.configDir, tokenData); err != nil {
+ if err := deviceSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
@@ -334,8 +364,8 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
if p.clientID != "" {
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
- if !HasAppConfig(p.configDir) {
- _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
+ if !deviceHasAppConfig(p.configDir) {
+ _ = deviceSaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
}
@@ -454,14 +484,14 @@ func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, a
select {
case <-ctx.Done():
return nil, ctx.Err()
- case <-time.After(interval):
+ case <-deviceFlowAfter(interval):
}
pollCount++
elapsedSec := int(time.Since(startTime).Seconds())
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
- pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID)
+ pollResp, err := devicePollStatus(p, ctx, auth.FlowID)
if err != nil {
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
if p.logger != nil {
@@ -505,14 +535,14 @@ func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Contex
select {
case <-ctx.Done():
return nil, ctx.Err()
- case <-time.After(interval):
+ case <-deviceFlowAfter(interval):
}
pollCount++
elapsedSec := int(time.Since(startTime).Seconds())
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
- resp, err := p.pollDeviceToken(ctx, auth.DeviceCode)
+ resp, err := devicePollToken(p, ctx, auth.DeviceCode)
if err != nil {
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
if p.logger != nil {
@@ -655,9 +685,6 @@ func dfPrintBox(w io.Writer, lines []string) {
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
for _, line := range lines {
pad := maxLen - tui.PlainRuneWidth(line)
- if pad < 0 {
- pad = 0
- }
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
}
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go
index b90743bf..098297f2 100644
--- a/internal/auth/endpoints.go
+++ b/internal/auth/endpoints.go
@@ -137,6 +137,9 @@ func GetMCPBaseURL() string {
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
+ authUserHomeDir = os.UserHomeDir
+ defaultAuthClientID = DefaultClientID
+ defaultAuthSecret = DefaultClientSecret
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
@@ -247,8 +250,8 @@ func ClientID() string {
return v
}
// Only return default if it's not a placeholder
- if !strings.HasPrefix(DefaultClientID, "<") {
- return DefaultClientID
+ if !strings.HasPrefix(defaultAuthClientID, "<") {
+ return defaultAuthClientID
}
return ""
}
@@ -272,7 +275,7 @@ func ClientSecret() string {
if v := os.Getenv("DWS_CLIENT_SECRET"); v != "" {
return v
}
- return DefaultClientSecret
+ return defaultAuthSecret
}
// HasValidClientSecret returns true if a valid client secret is available.
@@ -301,7 +304,7 @@ func getDefaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
- homeDir, err := os.UserHomeDir()
+ homeDir, err := authUserHomeDir()
if err != nil {
return ".dws"
}
diff --git a/internal/auth/filelock.go b/internal/auth/filelock.go
index f5ca8ce1..4bf81eff 100644
--- a/internal/auth/filelock.go
+++ b/internal/auth/filelock.go
@@ -35,6 +35,16 @@ const (
var processLocks sync.Map // map[string]chan struct{}
+var (
+ fileLockMkdirAll = os.MkdirAll
+ fileLockOpenFile = os.OpenFile
+ fileLockTry = lockFile
+ fileLockNow = time.Now
+ fileLockSleep = time.Sleep
+ dualAcquireProcessLock = acquireProcessLock
+ dualAcquireTokenLock = acquireTokenLock
+)
+
// processLockKey generates a unique key for process-level locking.
func processLockKey(configDir string) string {
return "refresh:" + configDir
@@ -93,28 +103,28 @@ type tokenFileLock struct {
// It blocks (with timeout) if another process holds the lock.
// The caller MUST call release() when done.
func acquireTokenLock(configDir string) (*tokenFileLock, error) {
- if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
+ if err := fileLockMkdirAll(configDir, config.DirPerm); err != nil {
return nil, fmt.Errorf("creating config dir for lock: %w", err)
}
lockPath := filepath.Join(configDir, lockFileName)
- f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, config.FilePerm)
+ f, err := fileLockOpenFile(lockPath, os.O_CREATE|os.O_RDWR, config.FilePerm)
if err != nil {
return nil, fmt.Errorf("opening lock file: %w", err)
}
- deadline := time.Now().Add(config.LockTimeout)
+ deadline := fileLockNow().Add(config.LockTimeout)
for {
- if err := lockFile(f); err == nil {
+ if err := fileLockTry(f); err == nil {
return &tokenFileLock{path: lockPath, file: f}, nil
}
- if time.Now().After(deadline) {
+ if fileLockNow().After(deadline) {
_ = f.Close()
return nil, fmt.Errorf("timeout acquiring token lock after %v (another dws process may be running)", config.LockTimeout)
}
- time.Sleep(lockRetryDelay)
+ fileLockSleep(lockRetryDelay)
}
}
@@ -145,13 +155,13 @@ type DualLock struct {
// The caller MUST call Release() when done.
func AcquireDualLock(ctx context.Context, configDir string) (*DualLock, error) {
// 1. Acquire process-level lock first (fast, in-memory)
- processRelease, waited, err := acquireProcessLock(ctx, configDir)
+ processRelease, waited, err := dualAcquireProcessLock(ctx, configDir)
if err != nil {
return nil, fmt.Errorf("acquiring process lock: %w", err)
}
// 2. Acquire file-level lock (cross-process)
- fileLock, err := acquireTokenLock(configDir)
+ fileLock, err := dualAcquireTokenLock(configDir)
if err != nil {
processRelease() // Release process lock on failure
return nil, fmt.Errorf("acquiring file lock: %w", err)
diff --git a/internal/auth/identity.go b/internal/auth/identity.go
index 6e6059d5..d1caec19 100644
--- a/internal/auth/identity.go
+++ b/internal/auth/identity.go
@@ -39,6 +39,15 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
+var (
+ identityReadFile = os.ReadFile
+ identityUnmarshal = json.Unmarshal
+ identityMkdirAll = os.MkdirAll
+ identityMarshalIndent = json.MarshalIndent
+ identityWriteFile = os.WriteFile
+ identityRandRead = rand.Read
+)
+
const identityFile = "identity.json"
// identityVersion is the current on-disk schema version. v1 files (no
@@ -71,12 +80,12 @@ type Identity struct {
// v1 files are migrated in-memory (machineId backfilled from agentId).
func Load(configDir string) *Identity {
path := filepath.Join(configDir, identityFile)
- data, err := os.ReadFile(path)
+ data, err := identityReadFile(path)
if err != nil {
return nil
}
var id Identity
- if err := json.Unmarshal(data, &id); err != nil {
+ if err := identityUnmarshal(data, &id); err != nil {
return nil
}
if id.AgentID == "" && id.MachineID == "" {
@@ -182,15 +191,15 @@ func (id *Identity) Headers() map[string]string {
}
func save(configDir string, id *Identity) error {
- if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
+ if err := identityMkdirAll(configDir, config.DirPerm); err != nil {
return err
}
- data, err := json.MarshalIndent(id, "", " ")
+ data, err := identityMarshalIndent(id, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
- return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
+ return identityWriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
}
// deriveAgentID computes a stable, client-side agentId for a (machine,
@@ -198,7 +207,10 @@ func save(configDir string, id *Identity) error {
// Deterministic and idempotent; no gateway allocation needed for statistics.
func deriveAgentID(seed, agentCode string) string {
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
- enc := base62Encode(sum[:])
+ return formatDerivedAgentID(base62Encode(sum[:]))
+}
+
+func formatDerivedAgentID(enc string) string {
for len(enc) < 12 {
enc = "0" + enc
}
@@ -228,7 +240,7 @@ func base62Encode(b []byte) string {
// generateUUID produces a UUID v4 string.
func generateUUID() string {
var u [16]byte
- if _, err := rand.Read(u[:]); err != nil {
+ if _, err := identityRandRead(u[:]); err != nil {
// Extremely unlikely; fallback to zero UUID rather than panic.
return "00000000-0000-4000-8000-000000000000"
}
diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go
index 716678b9..9b75de28 100644
--- a/internal/auth/keychain_store.go
+++ b/internal/auth/keychain_store.go
@@ -26,8 +26,15 @@ import (
)
var (
- migrationOnce sync.Once
- migrationDone bool
+ migrationOnce sync.Once
+ migrationDone bool
+ authKeychainMarshal = json.MarshalIndent
+ authKeychainUnmarshal = json.Unmarshal
+ authKeychainSet = keychain.Set
+ authKeychainGet = keychain.Get
+ authKeychainRemove = keychain.Remove
+ authKeychainExists = keychain.Exists
+ authKeychainMigrate = keychain.MigrateFromLegacy
)
// ErrTokenDataNotFound means the requested keychain slot does not exist.
@@ -54,7 +61,7 @@ func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
}
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
- jsonData, err := json.MarshalIndent(data, "", " ")
+ jsonData, err := authKeychainMarshal(data, "", " ")
if err != nil {
return fmt.Errorf("marshal token data: %w", err)
}
@@ -65,7 +72,7 @@ func saveTokenDataKeychainAccount(account string, data *TokenData) error {
}
}()
- if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
+ if err := authKeychainSet(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save to keychain: %w", err)
}
return nil
@@ -86,7 +93,7 @@ func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
}
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
- jsonStr, err := keychain.Get(keychain.Service, account)
+ jsonStr, err := authKeychainGet(keychain.Service, account)
if err != nil {
return nil, fmt.Errorf("load from keychain: %w", err)
}
@@ -95,7 +102,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
}
var data TokenData
- if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+ if err := authKeychainUnmarshal([]byte(jsonStr), &data); err != nil {
return nil, fmt.Errorf("parse token data: %w", err)
}
return &data, nil
@@ -169,7 +176,7 @@ func preflightTokenRefreshPersistence(data *TokenData) error {
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
func DeleteTokenDataKeychain() error {
- return keychain.Remove(keychain.Service, keychain.AccountToken)
+ return authKeychainRemove(keychain.Service, keychain.AccountToken)
}
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
@@ -178,12 +185,12 @@ func DeleteTokenDataKeychainForCorpID(corpID string) error {
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
- return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
+ return authKeychainRemove(keychain.Service, TokenAccountForCorpID(corpID))
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
- return keychain.Exists(keychain.Service, keychain.AccountToken)
+ return authKeychainExists(keychain.Service, keychain.AccountToken)
}
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
@@ -192,7 +199,7 @@ func TokenDataExistsKeychainForCorpID(corpID string) bool {
if corpID == "" {
return false
}
- return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
+ return authKeychainExists(keychain.Service, TokenAccountForCorpID(corpID))
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
@@ -200,7 +207,7 @@ func TokenDataExistsKeychainForCorpID(corpID string) bool {
// The migration is idempotent and thread-safe.
func EnsureMigration(configDir string, logger *slog.Logger) {
migrationOnce.Do(func() {
- result := keychain.MigrateFromLegacy(configDir)
+ result := authKeychainMigrate(configDir)
migrationDone = true
if result.Migrated {
@@ -240,7 +247,7 @@ func SaveClientSecret(clientID, clientSecret string) error {
return nil // Nothing to save
}
account := clientSecretPrefix + clientID
- if err := keychain.Set(keychain.Service, account, clientSecret); err != nil {
+ if err := authKeychainSet(keychain.Service, account, clientSecret); err != nil {
return fmt.Errorf("save client secret: %w", err)
}
return nil
@@ -253,7 +260,7 @@ func LoadClientSecret(clientID string) string {
return ""
}
account := clientSecretPrefix + clientID
- secret, err := keychain.Get(keychain.Service, account)
+ secret, err := authKeychainGet(keychain.Service, account)
if err != nil {
return ""
}
@@ -266,5 +273,5 @@ func DeleteClientSecret(clientID string) error {
return nil
}
account := clientSecretPrefix + clientID
- return keychain.Remove(keychain.Service, account)
+ return authKeychainRemove(keychain.Service, account)
}
diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go
index 1385dab6..be3658f3 100644
--- a/internal/auth/oauth_helpers.go
+++ b/internal/auth/oauth_helpers.go
@@ -31,6 +31,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
+var (
+ oauthSaveClientSecret = SaveClientSecret
+ oauthSaveTokenLocked = saveTokenDataLocked
+ oauthRetryAfter = time.After
+ oauthNewRequest = http.NewRequestWithContext
+)
+
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
if err := preflightTokenPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
@@ -61,7 +68,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
data.ClientID = clientID
data.Source = resolveCredentialSource()
// Save clientSecret for future refresh (even if env changes)
- if err := SaveClientSecret(clientID, clientSecret); err != nil {
+ if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
// Log warning but don't fail login
fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err)
}
@@ -125,7 +132,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
clientSecret = ClientSecret()
}
- if clientID == "" || clientSecret == "" {
+ if clientID == "" || clientSecret == "" || strings.HasPrefix(clientSecret, "<") {
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录")
}
@@ -156,7 +163,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
- if err := saveTokenDataLocked(p.configDir, updated); err != nil {
+ if err := oauthSaveTokenLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -202,7 +209,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
- if err := saveTokenDataLocked(p.configDir, updated); err != nil {
+ if err := oauthSaveTokenLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -213,7 +220,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
+ req, err := oauthNewRequest(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1362,7 +1369,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
select {
case <-ctx.Done():
return nil, ctx.Err()
- case <-time.After(time.Duration(attempt) * time.Second):
+ case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
@@ -1376,7 +1383,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
url := GetMCPBaseURL() + CLIAuthEnabledPath
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1417,7 +1424,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
select {
case <-ctx.Done():
return nil, ctx.Err()
- case <-time.After(time.Duration(attempt) * time.Second):
+ case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
result, err := doGetSuperAdmins(ctx, accessToken)
@@ -1431,7 +1438,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
url := GetMCPBaseURL() + SuperAdminPath
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1465,7 +1472,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
select {
case <-ctx.Done():
return nil, ctx.Err()
- case <-time.After(time.Duration(attempt) * time.Second):
+ case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
@@ -1479,7 +1486,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@@ -1522,7 +1529,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
- case <-time.After(time.Duration(attempt) * time.Second):
+ case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
}
}
id, err := doFetchClientIDFromMCP(ctx)
@@ -1536,7 +1543,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
url := GetMCPBaseURL() + ClientIDPath
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go
index f766096d..7b51d4fc 100644
--- a/internal/auth/oauth_provider.go
+++ b/internal/auth/oauth_provider.go
@@ -36,6 +36,34 @@ var oauthHTTPClient = &http.Client{
Timeout: 15 * time.Second,
}
+var (
+ oauthListen = net.Listen
+ oauthOpenBrowser = openBrowser
+ oauthLoginTimeout = 5 * time.Minute
+ oauthApprovalTimeout = 10 * time.Minute
+ oauthPollInterval = 5 * time.Second
+ oauthSuccessPause = 2 * time.Second
+ oauthLoadToken = LoadTokenData
+ oauthAcquireLock = AcquireDualLock
+ oauthMarkProfile = MarkProfileStatus
+ oauthFetchClientID = FetchClientIDFromMCP
+ oauthExchange = func(p *OAuthProvider, ctx context.Context, code string) (*TokenData, error) {
+ return p.exchangeCode(ctx, code)
+ }
+ oauthCheckStatus = func(p *OAuthProvider, ctx context.Context, token string) (*CLIAuthStatus, error) {
+ return p.CheckCLIAuthEnabled(ctx, token)
+ }
+ oauthGetAdmins = GetSuperAdmins
+ oauthSendApply = SendCliAuthApply
+ oauthSaveToken = SaveTokenData
+ oauthHasAppConfig = HasAppConfig
+ oauthSaveAppConfig = SaveAppConfig
+ oauthRefreshToken = func(p *OAuthProvider, ctx context.Context, data *TokenData) (*TokenData, error) {
+ return p.refreshWithRefreshToken(ctx, data)
+ }
+ oauthSleep = time.Sleep
+)
+
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct {
configDir string
@@ -81,7 +109,7 @@ func (p *OAuthProvider) output() io.Writer {
func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, error) {
// Smart degradation: try silent refresh before opening browser.
if !force {
- data, err := LoadTokenData(p.configDir)
+ data, err := oauthLoadToken(p.configDir)
if err == nil {
// Case 1: access_token still valid — no action needed.
if data.IsAccessTokenValid() {
@@ -129,7 +157,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
}
- mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
+ mcpClientID, mcpErr := oauthFetchClientID(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
@@ -141,7 +169,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Find a free port for the callback server.
- listener, err := net.Listen("tcp", "127.0.0.1:0")
+ listener, err := oauthListen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("starting callback listener: %w", err)
}
@@ -234,7 +262,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Exchange code for token
- tokenData, exchangeErr := p.exchangeCode(ctx, code)
+ tokenData, exchangeErr := oauthExchange(p, ctx, code)
if exchangeErr != nil {
// Clear in-progress state on error
callbackTokenMu.Lock()
@@ -266,7 +294,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
callbackTokenMu.Unlock()
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
- authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
+ authStatus, statusErr := oauthCheckStatus(p, ctx, tokenData.AccessToken)
var denialReason string
if statusErr != nil {
denialReason = "unknown"
@@ -322,7 +350,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
- result, err := GetSuperAdmins(ctx, token.AccessToken)
+ result, err := oauthGetAdmins(ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -346,7 +374,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
- result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID)
+ result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -382,7 +410,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
return
}
- result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken)
+ result, err := oauthCheckStatus(p, ctx, token.AccessToken)
if err != nil {
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
return
@@ -417,7 +445,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
p.logger.Debug("authorization URL", "url", authURL)
}
if !p.NoBrowser {
- if err := openBrowser(authURL); err != nil && p.logger != nil {
+ if err := oauthOpenBrowser(authURL); err != nil && p.logger != nil {
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
}
}
@@ -429,7 +457,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprintf(p.output(), i18n.T("如果浏览器未自动打开,请手动访问:\n %s\n\n"), authURL)
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 等待授权中..."))
- timeout := time.NewTimer(5 * time.Minute)
+ timeout := time.NewTimer(oauthLoginTimeout)
defer timeout.Stop()
var result callbackResult
@@ -469,9 +497,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
// Poll for CLI auth status while waiting
- applyTimeout := time.NewTimer(10 * time.Minute)
+ applyTimeout := time.NewTimer(oauthApprovalTimeout)
defer applyTimeout.Stop()
- pollTicker := time.NewTicker(5 * time.Second)
+ pollTicker := time.NewTicker(oauthPollInterval)
defer pollTicker.Stop()
elapsedSeconds := 0
@@ -494,7 +522,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Check if user switched to an org with CLI auth enabled
if currentToken != nil && !currentAuthDisabled {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
- time.Sleep(2 * time.Second)
+ oauthSleep(oauthSuccessPause)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
@@ -502,10 +530,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Check if CLI auth is now enabled (admin approved)
if currentToken != nil {
- authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
+ authStatus, err := oauthCheckStatus(p, ctx, currentToken.AccessToken)
if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" {
_, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
- time.Sleep(2 * time.Second)
+ oauthSleep(oauthSuccessPause)
result.token = currentToken
result.cliAuthDisabled = false
goto continueLogin
@@ -527,7 +555,7 @@ continueLogin:
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
- if err := SaveTokenData(p.configDir, tokenData); err != nil {
+ if err := oauthSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
@@ -539,8 +567,8 @@ continueLogin:
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
if p.clientID != "" {
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
- if !HasAppConfig(p.configDir) {
- _ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
+ if !oauthHasAppConfig(p.configDir) {
+ _ = oauthSaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
}
}
@@ -551,7 +579,7 @@ continueLogin:
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
- data, err := LoadTokenData(p.configDir)
+ data, err := oauthLoadToken(p.configDir)
if err != nil {
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
}
@@ -567,12 +595,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
if rErr == nil {
return refreshed.AccessToken, nil
}
- _ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
+ _ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
} else {
- _ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
+ _ = oauthMarkProfile(p.configDir, data.CorpID, ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
@@ -599,7 +627,7 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
// refresh API, invalidating each other's refresh_token.
func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// Acquire dual-layer lock (process-level + file-level)
- lock, err := AcquireDualLock(ctx, p.configDir)
+ lock, err := oauthAcquireLock(ctx, p.configDir)
if err != nil {
return nil, fmt.Errorf("acquiring dual lock: %w", err)
}
@@ -607,7 +635,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// Double-check: re-load from disk — another goroutine/process may have refreshed
// while we were waiting for the lock.
- data, err := LoadTokenData(p.configDir)
+ data, err := oauthLoadToken(p.configDir)
if err != nil {
return nil, err
}
@@ -633,20 +661,20 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
- return p.refreshWithRefreshToken(ctx, data)
+ return oauthRefreshToken(p, ctx, data)
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
// external host, exchanges it for tokens, and persists them.
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
- tokenData, err := p.exchangeCode(ctx, authCode)
+ tokenData, err := oauthExchange(p, ctx, authCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
if uid != "" {
tokenData.UserID = uid
}
- if err := SaveTokenData(p.configDir, tokenData); err != nil {
+ if err := oauthSaveToken(p.configDir, tokenData); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
}
return tokenData, nil
diff --git a/internal/auth/oauth_provider_coverage_test.go b/internal/auth/oauth_provider_coverage_test.go
new file mode 100644
index 00000000..e6876bee
--- /dev/null
+++ b/internal/auth/oauth_provider_coverage_test.go
@@ -0,0 +1,1104 @@
+package auth
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type oauthLoginFixture struct {
+ configDir string
+ callbackBase string
+ provider *OAuthProvider
+ server *httptest.Server
+ exchangeEntered chan struct{}
+ exchangeRelease chan struct{}
+ statusEntered chan struct{}
+ statusRelease chan struct{}
+ statusCalls atomic.Int32
+ exchangeError atomic.Bool
+}
+
+func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture {
+ t.Helper()
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ })
+
+ f := &oauthLoginFixture{
+ exchangeEntered: make(chan struct{}),
+ exchangeRelease: make(chan struct{}),
+ statusEntered: make(chan struct{}),
+ statusRelease: make(chan struct{}),
+ }
+ var exchangeOnce sync.Once
+ f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.HasSuffix(r.URL.Path, ClientIDPath):
+ _ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "test-client"})
+ case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
+ exchangeOnce.Do(func() {
+ close(f.exchangeEntered)
+ <-f.exchangeRelease
+ })
+ if f.exchangeError.Load() {
+ http.Error(w, "exchange failed", http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "accessToken": "access",
+ "refreshToken": "refresh",
+ "expiresIn": 7200,
+ "corpId": "corp-1",
+ "userId": "user-1",
+ })
+ case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
+ call := f.statusCalls.Add(1)
+ if call == 1 {
+ close(f.statusEntered)
+ <-f.statusRelease
+ }
+ _ = json.NewEncoder(w).Encode(status(call))
+ case strings.HasSuffix(r.URL.Path, SuperAdminPath):
+ _ = json.NewEncoder(w).Encode(SuperAdminResponse{
+ Success: true,
+ Result: []SuperAdmin{{StaffID: "admin-1", Name: "Admin"}},
+ })
+ case strings.HasSuffix(r.URL.Path, SendCliAuthApplyPath):
+ _ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(f.server.Close)
+ f.configDir = setupMCPConfigDir(t, f.server.URL)
+
+ oldClient := oauthHTTPClient
+ oauthHTTPClient = f.server.Client()
+ t.Cleanup(func() { oauthHTTPClient = oldClient })
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.callbackBase = "http://" + listener.Addr().String()
+ oldListen := oauthListen
+ oauthListen = func(string, string) (net.Listener, error) { return listener, nil }
+ t.Cleanup(func() { oauthListen = oldListen })
+
+ f.provider = &OAuthProvider{
+ configDir: f.configDir,
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ Output: io.Discard,
+ httpClient: f.server.Client(),
+ NoBrowser: true,
+ }
+ return f
+}
+
+func httpGetBody(t *testing.T, rawURL string) (int, string) {
+ t.Helper()
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Get(rawURL)
+ if err != nil {
+ t.Fatalf("GET %s: %v", rawURL, err)
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp.StatusCode, string(data)
+}
+
+func TestOAuthLoginCallbackAndAPIs(t *testing.T) {
+ f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
+ return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
+ })
+
+ loginDone := make(chan struct {
+ token *TokenData
+ err error
+ }, 1)
+ go func() {
+ token, err := f.provider.Login(context.Background(), true)
+ loginDone <- struct {
+ token *TokenData
+ err error
+ }{token, err}
+ }()
+
+ for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
+ _, body := httpGetBody(t, f.callbackBase+path)
+ if !strings.Contains(body, "授权尚未完成") {
+ t.Fatalf("pre-auth %s = %q", path, body)
+ }
+ }
+ _, body := httpGetBody(t, f.callbackBase+"/api/sendApply")
+ if !strings.Contains(body, "adminStaffId") {
+ t.Fatalf("missing admin response = %q", body)
+ }
+ _, body = httpGetBody(t, f.callbackBase+"/api/status")
+ if !strings.Contains(body, "test-client") {
+ t.Fatalf("initial status = %q", body)
+ }
+ if _, body = httpGetBody(t, f.callbackBase+"/success"); !strings.Contains(body, " 1}}
+ })
+ close(f.exchangeRelease)
+ close(f.statusRelease)
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.provider.Login(context.Background(), true)
+ done <- err
+ }()
+ _, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
+ if err := <-done; err != nil {
+ t.Fatalf("poll-enabled login failed: %v", err)
+ }
+ })
+
+ t.Run("context canceled while pending", func(t *testing.T) {
+ f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
+ return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
+ })
+ close(f.exchangeRelease)
+ close(f.statusRelease)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.provider.Login(ctx, true)
+ done <- err
+ }()
+ _, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
+ cancel()
+ if err := <-done; err == nil {
+ t.Fatal("canceled pending login succeeded")
+ }
+ })
+}
+
+func TestOAuthLoginExchangeFailure(t *testing.T) {
+ f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
+ return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
+ })
+ f.exchangeError.Store(true)
+ close(f.exchangeRelease)
+ close(f.statusRelease)
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.provider.Login(context.Background(), true)
+ done <- err
+ }()
+ _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
+ if !strings.Contains(body, "failed") {
+ t.Fatalf("exchange failure page = %q", body)
+ }
+ if err := <-done; err == nil {
+ t.Fatal("exchange failure login succeeded")
+ }
+}
+
+func TestOAuthRefreshAndParsingEdges(t *testing.T) {
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+ dir := t.TempDir()
+ p := &OAuthProvider{configDir: dir, Output: io.Discard}
+ oldOAuthClient := oauthHTTPClient
+ responseBody := "{\"accessToken\":\"new-access\",\"refreshToken\":\"new-refresh\",\"expiresIn\":7200,\"corpId\":\"new-corp\"}"
+ p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(responseBody)), Header: make(http.Header)}, nil
+ })}
+ SetClientID("direct")
+ SetClientSecret("secret")
+ t.Cleanup(func() {
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ oauthHTTPClient = oldOAuthClient
+ })
+
+ for name, body := range map[string][]byte{
+ "malformed": []byte("{"),
+ "missing": []byte("{}"),
+ } {
+ t.Run("direct "+name, func(t *testing.T) {
+ if _, err := p.parseTokenResponse(body); err == nil {
+ t.Fatal("invalid direct token parsed")
+ }
+ })
+ t.Run("mcp "+name, func(t *testing.T) {
+ if _, err := p.parseMCPTokenResponse(body); err == nil {
+ t.Fatal("invalid MCP token parsed")
+ }
+ })
+ }
+ if parsed, err := p.parseTokenResponse([]byte("{\"accessToken\":\"a\",\"expiresIn\":0}")); err != nil || parsed.ExpiresAt.IsZero() {
+ t.Fatalf("default direct expiry = %#v, %v", parsed, err)
+ }
+ if parsed, err := p.parseMCPTokenResponse([]byte("{\"accessToken\":\"a\",\"expiresIn\":0,\"corpName\":\"Corp\"}")); err != nil || parsed.CorpName != "Corp" {
+ t.Fatalf("default MCP expiry = %#v, %v", parsed, err)
+ }
+
+ if _, err := p.postJSON(context.Background(), "https://example.test", map[string]any{"bad": func() {}}); err == nil {
+ t.Fatal("unmarshalable request succeeded")
+ }
+ if _, err := p.postJSON(context.Background(), "://bad", map[string]string{}); err == nil {
+ t.Fatal("invalid request URL succeeded")
+ }
+ p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("network")
+ })}
+ if _, err := p.postJSON(context.Background(), "https://example.test", map[string]string{}); err == nil {
+ t.Fatal("network request succeeded")
+ }
+ p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(errorReader{}), Header: make(http.Header)}, nil
+ })}
+ if _, err := p.postJSON(context.Background(), "https://example.test", map[string]string{}); err == nil {
+ t.Fatal("response read failure succeeded")
+ }
+ p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 250))), Header: make(http.Header)}, nil
+ })}
+ if _, err := p.postJSON(context.Background(), "https://example.test", map[string]string{}); err == nil {
+ t.Fatal("HTTP failure succeeded")
+ }
+
+ p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(responseBody)), Header: make(http.Header)}, nil
+ })}
+ original := &TokenData{
+ ClientID: "direct", Source: "direct", RefreshToken: "refresh", PersistentCode: "pc",
+ CorpID: "corp", CorpName: "Original", UserID: "user", UserName: "Name",
+ }
+ if err := SaveClientSecret("direct", "secret"); err != nil {
+ t.Fatal(err)
+ }
+ updated, err := p.refreshWithRefreshToken(context.Background(), original)
+ if err != nil || updated.CorpID != "corp" || updated.PersistentCode != "pc" || updated.CorpName != "Original" {
+ t.Fatalf("direct refresh = %#v, %v", updated, err)
+ }
+ missing := &TokenData{ClientID: "missing-client", RefreshToken: "refresh"}
+ SetClientID("")
+ SetClientSecret("")
+ if _, err := p.refreshWithRefreshToken(context.Background(), missing); err == nil {
+ t.Fatal("refresh without credentials succeeded")
+ }
+
+ mcpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.WriteString(w, responseBody)
+ }))
+ defer mcpSrv.Close()
+ configDir := setupMCPConfigDir(t, mcpSrv.URL)
+ resetAppConfigCache()
+ oauthHTTPClient = mcpSrv.Client()
+ mcpProvider := &OAuthProvider{configDir: configDir, httpClient: mcpSrv.Client()}
+ mcpOriginal := &TokenData{ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh", CorpID: "corp"}
+ if updated, err := mcpProvider.refreshViaMCP(context.Background(), mcpOriginal); err != nil || updated.Source != "mcp" {
+ t.Fatalf("MCP refresh = %#v, %v", updated, err)
+ }
+ if _, err := mcpProvider.refreshViaMCP(context.Background(), &TokenData{Source: "mcp"}); err == nil {
+ t.Fatalf("MCP refresh without client ID succeeded (fallback client ID %q)", ClientID())
+ }
+}
+
+func TestOAuthProviderHighLevelEdges(t *testing.T) {
+ oldLoad := oauthLoadToken
+ oldAcquire := oauthAcquireLock
+ oldMark := oauthMarkProfile
+ oldFetch := oauthFetchClientID
+ oldExchange := oauthExchange
+ oldSave := oauthSaveToken
+ oldRefresh := oauthRefreshToken
+ oldSaveSecret := oauthSaveClientSecret
+ oldSaveLocked := oauthSaveTokenLocked
+ t.Cleanup(func() {
+ oauthLoadToken = oldLoad
+ oauthAcquireLock = oldAcquire
+ oauthMarkProfile = oldMark
+ oauthFetchClientID = oldFetch
+ oauthExchange = oldExchange
+ oauthSaveToken = oldSave
+ oauthRefreshToken = oldRefresh
+ oauthSaveClientSecret = oldSaveSecret
+ oauthSaveTokenLocked = oldSaveLocked
+ SetClientID("")
+ SetClientSecret("")
+ resetClientIDFromMCP()
+ })
+ fail := errors.New("fail")
+ p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default(), Output: io.Discard}
+
+ valid := &TokenData{AccessToken: "valid", ExpiresAt: time.Now().Add(time.Hour)}
+ oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
+ if got, err := p.Login(context.Background(), false); err != nil || got != valid {
+ t.Fatalf("silent valid login = %#v %v", got, err)
+ }
+ expired := &TokenData{AccessToken: "old", ExpiresAt: time.Now().Add(-time.Hour), RefreshToken: "refresh", RefreshExpAt: time.Now().Add(time.Hour)}
+ oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
+ refreshed := &TokenData{AccessToken: "new", ExpiresAt: time.Now().Add(time.Hour)}
+ oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return refreshed, nil }
+ if got, err := p.Login(context.Background(), false); err != nil || got != refreshed {
+ t.Fatalf("silent refresh login = %#v %v", got, err)
+ }
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return nil, fail }
+ oauthFetchClientID = func(context.Context) (string, error) { return "", fail }
+ if _, err := p.Login(context.Background(), false); !errors.Is(err, fail) {
+ t.Fatalf("refresh fallback fetch error = %v", err)
+ }
+
+ oauthLoadToken = func(string) (*TokenData, error) { return nil, fail }
+ if _, err := p.GetAccessToken(context.Background()); err == nil {
+ t.Fatal("missing access token succeeded")
+ }
+ oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
+ if got, err := p.GetAccessToken(context.Background()); err != nil || got != "valid" {
+ t.Fatalf("valid access token = %q %v", got, err)
+ }
+ oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
+ oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return refreshed, nil }
+ if got, err := p.GetAccessToken(context.Background()); err != nil || got != "new" {
+ t.Fatalf("refreshed access token = %q %v", got, err)
+ }
+ marked := false
+ oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, fail }
+ oauthMarkProfile = func(string, string, string) error { marked = true; return fail }
+ if _, err := p.GetAccessToken(context.Background()); err == nil || !marked {
+ t.Fatalf("failed refresh = %v marked=%v", err, marked)
+ }
+ marked = false
+ noRefresh := &TokenData{AccessToken: "old", ExpiresAt: time.Now().Add(-time.Hour), CorpID: "corp"}
+ oauthLoadToken = func(string) (*TokenData, error) { return noRefresh, nil }
+ if _, err := p.GetAccessToken(context.Background()); err == nil || !marked {
+ t.Fatalf("expired access token = %v marked=%v", err, marked)
+ }
+
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return nil, fail }
+ if _, err := p.lockedRefresh(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("locked refresh acquire error = %v", err)
+ }
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
+ oauthLoadToken = func(string) (*TokenData, error) { return nil, fail }
+ if _, err := p.lockedRefresh(context.Background()); !errors.Is(err, fail) {
+ t.Fatalf("locked refresh load error = %v", err)
+ }
+ for _, waited := range []bool{false, true} {
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{Waited: waited}, nil }
+ oauthLoadToken = func(string) (*TokenData, error) { return valid, nil }
+ if got, err := p.lockedRefresh(context.Background()); err != nil || got != valid {
+ t.Fatalf("double-check valid token waited=%v: %#v %v", waited, got, err)
+ }
+ }
+ oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
+ oauthLoadToken = func(string) (*TokenData, error) { return noRefresh, nil }
+ if _, err := p.lockedRefresh(context.Background()); err == nil {
+ t.Fatal("expired refresh token succeeded")
+ }
+ oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
+ oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return refreshed, nil }
+ if got, err := p.lockedRefresh(context.Background()); err != nil || got != refreshed {
+ t.Fatalf("locked token refresh = %#v %v", got, err)
+ }
+
+ oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { return nil, fail }
+ if _, err := p.ExchangeAuthCode(context.Background(), "code", "user"); !errors.Is(err, fail) {
+ t.Fatalf("external exchange error = %v", err)
+ }
+ oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { return &TokenData{}, nil }
+ oauthSaveToken = func(string, *TokenData) error { return fail }
+ if _, err := p.ExchangeAuthCode(context.Background(), "code", "user"); !errors.Is(err, fail) {
+ t.Fatalf("external exchange save error = %v", err)
+ }
+
+ oauthSaveClientSecret = func(string, string) error { return fail }
+ SetClientID("direct")
+ SetClientSecret("secret")
+ p.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"accessToken":"access","refreshToken":"refresh"}`)), Header: make(http.Header)}, nil
+ })}
+ var warnings bytes.Buffer
+ p.Output = &warnings
+ if _, err := p.exchangeCode(context.Background(), "code"); err != nil || !strings.Contains(warnings.String(), "Warning") {
+ t.Fatalf("direct exchange secret warning = %v %q", err, warnings.String())
+ }
+ oauthSaveTokenLocked = func(string, *TokenData) error { return fail }
+ if _, err := p.refreshWithRefreshToken(context.Background(), &TokenData{ClientID: "direct", RefreshToken: "refresh"}); !errors.Is(err, fail) {
+ t.Fatalf("direct refresh save error = %v", err)
+ }
+ if _, err := oldRefresh(p, context.Background(), &TokenData{ClientID: "direct", RefreshToken: "refresh"}); !errors.Is(err, fail) {
+ t.Fatalf("default refresh hook = %v", err)
+ }
+}
+
+func TestOAuthCallbackRemainingEdges(t *testing.T) {
+ oldCheck := oauthCheckStatus
+ oldAdmins := oauthGetAdmins
+ oldApply := oauthSendApply
+ oldSave := oauthSaveToken
+ oldPoll := oauthPollInterval
+ oldApproval := oauthApprovalTimeout
+ oldLogin := oauthLoginTimeout
+ oldPause := oauthSuccessPause
+ oldSleep := oauthSleep
+ t.Cleanup(func() {
+ oauthCheckStatus = oldCheck
+ oauthGetAdmins = oldAdmins
+ oauthSendApply = oldApply
+ oauthSaveToken = oldSave
+ oauthPollInterval = oldPoll
+ oauthApprovalTimeout = oldApproval
+ oauthLoginTimeout = oldLogin
+ oauthSuccessPause = oldPause
+ oauthSleep = oldSleep
+ })
+ oauthApprovalTimeout = time.Second
+ oauthLoginTimeout = time.Second
+ oauthSuccessPause = 0
+ oauthSleep = func(time.Duration) {}
+
+ type loginResult struct {
+ token *TokenData
+ err error
+ }
+ startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult {
+ t.Helper()
+ done := make(chan loginResult, 1)
+ go func() {
+ token, err := f.provider.Login(ctx, true)
+ done <- loginResult{token: token, err: err}
+ }()
+ return done
+ }
+ finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string {
+ t.Helper()
+ bodyCh := make(chan string, 1)
+ go func() {
+ _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code))
+ bodyCh <- body
+ }()
+ <-f.exchangeEntered
+ close(f.exchangeRelease)
+ return <-bodyCh
+ }
+
+ t.Run("switch organization and cached disabled pages", func(t *testing.T) {
+ oauthPollInterval = 200 * time.Millisecond
+ var checks atomic.Int32
+ oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) {
+ if checks.Add(1) == 1 {
+ return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}, nil
+ }
+ return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
+ }
+ f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
+ done := startLogin(context.Background(), f)
+ if body := finishExchange(t, f, "first"); !strings.Contains(body, " 0 {
return nil
}
- if !TokenDataExistsKeychain() {
+ if !profilesTokenExists() {
return nil
}
- data, err := LoadTokenDataKeychain()
+ data, err := profilesLoadLegacy()
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
- if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
+ if err := profilesSaveCorp(data.CorpID, data); err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, false)
@@ -200,7 +223,7 @@ func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCu
}
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return err
}
@@ -270,15 +293,15 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = corpID
}
- return SaveProfiles(configDir, cfg)
+ return profilesSave(configDir, cfg)
}
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
func ResolveProfile(configDir, selector string) (*Profile, error) {
- if err := ensureProfilesMigrationLocked(configDir); err != nil {
+ if err := profilesEnsureMigration(configDir); err != nil {
return nil, err
}
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
@@ -300,10 +323,10 @@ func ResolveProfile(configDir, selector string) (*Profile, error) {
}
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
- if err := ensureProfilesMigrationLocked(configDir); err != nil {
+ if err := profilesEnsureMigration(configDir); err != nil {
return nil, err
}
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
@@ -316,7 +339,7 @@ func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
return p, nil
}
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
- if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
+ if p := findProfile(cfg, candidate); p != nil && profilesTokenExistsCorp(p.CorpID) {
return p, nil
}
}
@@ -341,10 +364,10 @@ func SetCurrentProfile(configDir, selector string) (*Profile, error) {
}
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
- if err := ensureProfilesMigrationLocked(configDir); err != nil {
+ if err := profilesEnsureMigration(configDir); err != nil {
return nil, err
}
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
@@ -359,10 +382,10 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
cfg.CurrentProfile = p.CorpID
}
touchProfile(cfg, p.CorpID)
- if err := SaveProfiles(configDir, cfg); err != nil {
+ if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
- if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
+ if err := profilesSyncLegacyMirror(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
@@ -380,10 +403,10 @@ func UsePreviousProfile(configDir string) (*Profile, error) {
}
func usePreviousProfileLocked(configDir string) (*Profile, error) {
- if err := ensureProfilesMigrationLocked(configDir); err != nil {
+ if err := profilesEnsureMigration(configDir); err != nil {
return nil, err
}
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
@@ -397,10 +420,10 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
}
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
touchProfile(cfg, p.CorpID)
- if err := SaveProfiles(configDir, cfg); err != nil {
+ if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
- if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
+ if err := profilesSyncLegacyMirror(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
@@ -418,7 +441,7 @@ func RemoveProfile(configDir, selector string) (*Profile, error) {
}
func removeProfileLocked(configDir, selector string) (*Profile, error) {
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
@@ -451,7 +474,7 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
cfg.CurrentProfile = ""
cfg.PreviousProfile = ""
}
- if err := SaveProfiles(configDir, cfg); err != nil {
+ if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
return &removed, nil
@@ -468,7 +491,7 @@ func MarkProfileStatus(configDir, corpID, status string) error {
}
func markProfileStatusLocked(configDir, corpID, status string) error {
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return err
}
@@ -478,7 +501,7 @@ func markProfileStatusLocked(configDir, corpID, status string) error {
}
p.Status = strings.TrimSpace(status)
p.UpdatedAt = time.Now().Format(time.RFC3339)
- return SaveProfiles(configDir, cfg)
+ return profilesSave(configDir, cfg)
}
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
@@ -489,7 +512,7 @@ func SyncLegacyTokenMirror(configDir string) error {
}
func syncLegacyTokenMirrorLocked(configDir string) error {
- cfg, err := LoadProfiles(configDir)
+ cfg, err := profilesLoad(configDir)
if err != nil {
return err
}
@@ -499,17 +522,17 @@ func syncLegacyTokenMirrorLocked(configDir string) error {
if p == nil {
continue
}
- data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
+ data, loadErr := profilesLoadCorp(p.CorpID)
if loadErr != nil {
// Transient keychain read failure: do NOT touch the existing mirror.
hadReadError = true
continue
}
if data != nil {
- if err := SaveTokenDataKeychain(data); err != nil {
+ if err := profilesSaveLegacy(data); err != nil {
return err
}
- return WriteTokenMarker(configDir)
+ return profilesWriteMarker(configDir)
}
}
if hadReadError {
@@ -518,8 +541,8 @@ func syncLegacyTokenMirrorLocked(configDir string) error {
return nil
}
// All candidate profiles confirmed absent (no token): clear the mirror.
- _ = DeleteTokenDataKeychain()
- _ = DeleteTokenMarker(configDir)
+ _ = profilesDeleteLegacy()
+ _ = profilesDeleteMarker(configDir)
return nil
}
diff --git a/internal/auth/secret.go b/internal/auth/secret.go
index 8537921e..4cc171bd 100644
--- a/internal/auth/secret.go
+++ b/internal/auth/secret.go
@@ -27,6 +27,11 @@ const (
secretKeyPrefix = "appsecret:"
)
+var (
+ secretKeychainGet = keychain.Get
+ secretKeychainSet = keychain.Set
+)
+
// SecretRef references a secret stored externally.
type SecretRef struct {
Source string `json:"source"` // "keychain" | "file"
@@ -114,7 +119,7 @@ func ResolveSecret(input SecretInput) (string, error) {
}
return strings.TrimSpace(string(data)), nil
case "keychain":
- val, err := keychain.Get(keychain.Service, input.Ref.ID)
+ val, err := secretKeychainGet(keychain.Service, input.Ref.ID)
if err != nil {
return "", fmt.Errorf("failed to get secret from keychain: %w", err)
}
@@ -132,7 +137,7 @@ func StoreSecret(clientID string, input SecretInput) (SecretInput, error) {
return input, nil // SecretRef → keep as-is
}
key := secretAccountKey(clientID)
- if err := keychain.Set(keychain.Service, key, input.Plain); err != nil {
+ if err := secretKeychainSet(keychain.Service, key, input.Plain); err != nil {
return SecretInput{}, fmt.Errorf("keychain unavailable: %w\nhint: use file reference in config to bypass keychain", err)
}
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
diff --git a/internal/auth/secure_store.go b/internal/auth/secure_store.go
index 89af75ab..c89c006f 100644
--- a/internal/auth/secure_store.go
+++ b/internal/auth/secure_store.go
@@ -25,6 +25,29 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
+type secureTempFile interface {
+ Write([]byte) (int, error)
+ Sync() error
+ Close() error
+}
+
+var (
+ secureGetMAC = security.GetMACAddress
+ secureMkdirAll = os.MkdirAll
+ secureStat = os.Stat
+ secureChmod = os.Chmod
+ secureMarshalIndent = json.MarshalIndent
+ secureEncrypt = security.Encrypt
+ secureOpenFile = func(name string, flag int, perm os.FileMode) (secureTempFile, error) {
+ return os.OpenFile(name, flag, perm)
+ }
+ secureRemove = os.Remove
+ secureRename = os.Rename
+ secureReadFile = os.ReadFile
+ secureDecrypt = security.Decrypt
+ secureUnmarshal = json.Unmarshal
+)
+
const secureDataFile = ".data"
// ErrTokenDecryption indicates that token decryption failed, typically
@@ -43,7 +66,7 @@ var (
// getCachedMAC returns the cached MAC address, fetching it once if needed.
func getCachedMAC() (string, error) {
cachedMACOnce.Do(func() {
- cachedMAC, cachedMACErr = security.GetMACAddress()
+ cachedMAC, cachedMACErr = secureGetMAC()
})
return cachedMAC, cachedMACErr
}
@@ -71,19 +94,19 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
return err
}
- if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
+ if err := secureMkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("creating config dir %s: %w", configDir, err)
}
// Verify the directory permissions are strict even if it already existed.
- if info, statErr := os.Stat(configDir); statErr == nil {
+ if info, statErr := secureStat(configDir); statErr == nil {
if perm := info.Mode().Perm(); perm&0o077 != 0 {
- if chErr := os.Chmod(configDir, config.DirPerm); chErr != nil {
+ if chErr := secureChmod(configDir, config.DirPerm); chErr != nil {
return fmt.Errorf("config dir %s has unsafe permissions %o and chmod failed: %w", configDir, perm, chErr)
}
}
}
- plaintext, err := json.MarshalIndent(data, "", " ")
+ plaintext, err := secureMarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data: %w", err)
}
@@ -93,7 +116,7 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
}
}()
- ciphertext, err := security.Encrypt(plaintext, password)
+ ciphertext, err := secureEncrypt(plaintext, password)
if err != nil {
return fmt.Errorf("encrypting token data: %w", err)
}
@@ -102,7 +125,7 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
tmpPath := finalPath + ".tmp"
// Atomic write with fsync to ensure data durability
- tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
+ tmpFile, err := secureOpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
if err != nil {
return fmt.Errorf("creating tmp file: %w", err)
}
@@ -111,7 +134,7 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
defer func() {
if !writeSuccess {
tmpFile.Close()
- _ = os.Remove(tmpPath)
+ _ = secureRemove(tmpPath)
}
}()
@@ -124,8 +147,8 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("closing tmp file: %w", err)
}
- if err := os.Rename(tmpPath, finalPath); err != nil {
- _ = os.Remove(tmpPath)
+ if err := secureRename(tmpPath, finalPath); err != nil {
+ _ = secureRemove(tmpPath)
return fmt.Errorf("renaming tmp to final: %w", err)
}
writeSuccess = true
@@ -142,12 +165,12 @@ func LoadSecureTokenData(configDir string) (*TokenData, error) {
}
path := filepath.Join(configDir, secureDataFile)
- ciphertext, err := os.ReadFile(path)
+ ciphertext, err := secureReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading secure data file: %w", err)
}
- plaintext, err := security.Decrypt(ciphertext, password)
+ plaintext, err := secureDecrypt(ciphertext, password)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTokenDecryption, err)
}
@@ -159,7 +182,7 @@ func LoadSecureTokenData(configDir string) (*TokenData, error) {
}()
var data TokenData
- if err := json.Unmarshal(plaintext, &data); err != nil {
+ if err := secureUnmarshal(plaintext, &data); err != nil {
return nil, fmt.Errorf("parsing decrypted token data: %w", err)
}
@@ -169,16 +192,16 @@ func LoadSecureTokenData(configDir string) (*TokenData, error) {
// DeleteSecureData removes .data file from configDir.
func DeleteSecureData(configDir string) error {
path := filepath.Join(configDir, secureDataFile)
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ if err := secureRemove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("deleting secure data file: %w", err)
}
- _ = os.Remove(path + ".tmp")
+ _ = secureRemove(path + ".tmp")
return nil
}
// SecureDataExists checks if the secure .data file exists in the given directory.
func SecureDataExists(configDir string) bool {
path := filepath.Join(configDir, secureDataFile)
- _, err := os.Stat(path)
+ _, err := secureStat(path)
return err == nil
}
diff --git a/internal/auth/token.go b/internal/auth/token.go
index 3cf93904..3e41e816 100644
--- a/internal/auth/token.go
+++ b/internal/auth/token.go
@@ -31,6 +31,41 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
+var (
+ tokenJSONMarshalIndent = json.MarshalIndent
+ tokenJSONMarshal = json.Marshal
+ tokenMkdirAll = os.MkdirAll
+ tokenWriteFile = os.WriteFile
+ tokenRename = os.Rename
+ tokenRemove = os.Remove
+ tokenGlob = filepath.Glob
+ tokenSaveKeychainForCorpID = SaveTokenDataKeychainForCorpID
+ tokenSaveKeychain = SaveTokenDataKeychain
+ tokenLoadKeychainForCorpID = LoadTokenDataKeychainForCorpID
+ tokenLoadKeychain = LoadTokenDataKeychain
+ tokenKeychainExists = TokenDataExistsKeychain
+ tokenDeleteKeychainForCorpID = DeleteTokenDataKeychainForCorpID
+ tokenDeleteKeychain = DeleteTokenDataKeychain
+ tokenLoadSecure = LoadSecureTokenData
+ tokenDeleteSecure = DeleteSecureData
+ tokenResolveProfile = resolveProfileForLoad
+ tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
+ tokenRemoveProfile = removeProfileLocked
+ tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
+ tokenLoadProfiles = LoadProfiles
+ tokenWriteMarker = WriteTokenMarker
+ tokenDeleteMarker = DeleteTokenMarker
+ tokenParseURL = url.Parse
+ tokenNewRequest = http.NewRequestWithContext
+ tokenDefaultConfigDir = getDefaultConfigDir
+ tokenLoadData = LoadTokenData
+ tokenRevokeURL = GetRevokeTokenURL
+ tokenLogoutURL = LogoutURL
+ tokenLogoutContinueURL = LogoutContinueURL
+ tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
+)
+
// TokenData holds the OAuth token set persisted to disk.
type TokenData struct {
AccessToken string `json:"access_token"`
@@ -82,20 +117,20 @@ type TokenMarker struct {
// decide whether it needs to trigger a new auth exchange.
func WriteTokenMarker(configDir string) error {
marker := TokenMarker{UpdatedAt: time.Now().Format(time.RFC3339)}
- data, _ := json.MarshalIndent(marker, "", " ")
- if err := os.MkdirAll(configDir, 0o700); err != nil {
+ data, _ := tokenJSONMarshalIndent(marker, "", " ")
+ if err := tokenMkdirAll(configDir, 0o700); err != nil {
return err
}
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
- if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ if err := tokenWriteFile(tmp, data, 0o600); err != nil {
return err
}
- return os.Rename(tmp, filepath.Join(configDir, tokenJSONFile))
+ return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
}
// DeleteTokenMarker removes the token.json marker file.
func DeleteTokenMarker(configDir string) error {
- if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
+ if err := tokenRemove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
@@ -123,30 +158,30 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
return saveTokenViaHook(h, configDir, data)
}
if data != nil && strings.TrimSpace(data.CorpID) != "" {
- if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
+ if err := tokenSaveKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
- if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
+ if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil {
return err
}
if makeCurrent {
- if err := SaveTokenDataKeychain(data); err != nil {
+ if err := tokenSaveKeychain(data); err != nil {
return err
}
- } else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
+ } else if err := tokenSyncLegacyMirror(configDir); err != nil {
return err
}
- return WriteTokenMarker(configDir)
+ return tokenWriteMarker(configDir)
}
- if err := SaveTokenDataKeychain(data); err != nil {
+ if err := tokenSaveKeychain(data); err != nil {
return err
}
- return WriteTokenMarker(configDir)
+ return tokenWriteMarker(configDir)
}
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
- jsonData, err := json.MarshalIndent(data, "", " ")
+ jsonData, err := tokenJSONMarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
@@ -179,12 +214,12 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
}
// Default: keychain with legacy .data migration
- selected, err := resolveProfileForLoad(configDir, profile)
+ selected, err := tokenResolveProfile(configDir, profile)
if err != nil {
return nil, err
}
if selected != nil {
- data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
+ data, err := tokenLoadKeychainForCorpID(selected.CorpID)
if err == nil {
return data, nil
}
@@ -195,7 +230,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
// profile. Only fall back to the legacy single slot when it belongs to
// the SAME org; otherwise surface the error instead of silently acting
// as a different organization (the legacy mirror may have drifted).
- if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
+ if legacy, lerr := tokenLoadKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
return legacy, nil
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
@@ -203,17 +238,17 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
}
return nil, err
}
- if TokenDataExistsKeychain() {
- return LoadTokenDataKeychain()
+ if tokenKeychainExists() {
+ return tokenLoadKeychain()
}
- data, err := LoadSecureTokenData(configDir)
+ data, err := tokenLoadSecure(configDir)
if err != nil {
return nil, err
}
// One-time legacy secure-store -> keychain migration. This read path may run
// while the refresh lock is already held, so use the lock-free saver.
if err := saveTokenDataLocked(configDir, data); err == nil {
- _ = DeleteSecureData(configDir)
+ _ = tokenDeleteSecure(configDir)
}
return data, nil
}
@@ -240,15 +275,15 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
}
func deleteTokenDataForProfileLocked(configDir, profile string) error {
- selected, err := resolveProfileForLoad(configDir, profile)
+ selected, err := tokenResolveProfile(configDir, profile)
if err != nil {
return err
}
if selected != nil {
- keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
- _, removeErr := removeProfileLocked(configDir, selected.CorpID)
- legacyErr := syncLegacyTokenMirrorLocked(configDir)
- secureErr := DeleteSecureData(configDir)
+ keychainErr := tokenDeleteKeychainForCorpID(selected.CorpID)
+ _, removeErr := tokenRemoveProfile(configDir, selected.CorpID)
+ legacyErr := tokenSyncLegacyMirror(configDir)
+ secureErr := tokenDeleteSecure(configDir)
if keychainErr != nil {
return keychainErr
}
@@ -261,9 +296,9 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
return secureErr
}
- keychainErr := DeleteTokenDataKeychain()
- legacyErr := DeleteSecureData(configDir)
- markerErr := DeleteTokenMarker(configDir)
+ keychainErr := tokenDeleteKeychain()
+ legacyErr := tokenDeleteSecure(configDir)
+ markerErr := tokenDeleteMarker(configDir)
if keychainErr != nil {
return keychainErr
}
@@ -282,31 +317,31 @@ func DeleteAllTokenData(configDir string) error {
var firstErr error
// Best-effort: even if profiles.json is unreadable, still clear every
// other slot so the user can always self-heal via auth reset / logout.
- if cfg, err := LoadProfiles(configDir); err == nil {
+ if cfg, err := tokenLoadProfiles(configDir); err == nil {
for _, profile := range cfg.Profiles {
- if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
+ if e := tokenDeleteKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
firstErr = e
}
}
}
- if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
+ if e := tokenRemove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
- if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
+ if matches, _ := tokenGlob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
for _, m := range matches {
- if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
+ if e := tokenRemove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
}
}
- if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
+ if e := tokenDeleteKeychain(); e != nil && firstErr == nil {
firstErr = e
}
- if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
+ if e := tokenDeleteSecure(configDir); e != nil && firstErr == nil {
firstErr = e
}
- if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
+ if e := tokenDeleteMarker(configDir); e != nil && firstErr == nil {
firstErr = e
}
return firstErr
@@ -323,30 +358,22 @@ func RevokeTokenRemote(ctx context.Context) error {
return revokeTokenViaMCP(ctx)
}
// Direct mode: use DingTalk logout endpoint
- logoutURL, err := url.Parse(LogoutURL)
+ logoutURL, err := tokenParseURL(tokenLogoutURL)
if err != nil {
return fmt.Errorf("parsing logout URL: %w", err)
}
q := logoutURL.Query()
q.Set("client_id", ClientID())
- q.Set("continue", LogoutContinueURL)
+ q.Set("continue", tokenLogoutContinueURL)
logoutURL.RawQuery = q.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoutURL.String(), nil)
+ req, err := tokenNewRequest(ctx, http.MethodGet, logoutURL.String(), nil)
if err != nil {
return fmt.Errorf("creating logout request: %w", err)
}
- client := &http.Client{
- Timeout: 10 * time.Second,
- // Do not follow redirects — we just need to hit the logout endpoint.
- CheckRedirect: func(req *http.Request, via []*http.Request) error {
- return http.ErrUseLastResponse
- },
- }
-
- resp, err := client.Do(req)
+ resp, err := tokenLogoutHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("calling logout endpoint: %w", err)
}
@@ -362,13 +389,13 @@ func RevokeTokenRemote(ctx context.Context) error {
// revokeTokenViaMCP revokes token via MCP endpoint.
func revokeTokenViaMCP(ctx context.Context) error {
- revokeURL := GetRevokeTokenURL()
+ revokeURL := tokenRevokeURL()
if revokeURL == "" {
return nil // No revoke endpoint available
}
// Load current token to get accessToken
- tokenData, err := LoadTokenData(getDefaultConfigDir())
+ tokenData, err := tokenLoadData(tokenDefaultConfigDir())
if err != nil || tokenData == nil {
return nil // No token to revoke
}
@@ -377,19 +404,18 @@ func revokeTokenViaMCP(ctx context.Context) error {
"clientId": ClientID(),
"accessToken": tokenData.AccessToken,
}
- bodyBytes, err := json.Marshal(body)
+ bodyBytes, err := tokenJSONMarshal(body)
if err != nil {
return fmt.Errorf("marshaling revoke request: %w", err)
}
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes))
+ req, err := tokenNewRequest(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("creating revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
- client := &http.Client{Timeout: 10 * time.Second}
- resp, err := client.Do(req)
+ resp, err := tokenRevokeHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("calling revoke endpoint: %w", err)
}
diff --git a/internal/cli/coverage_edges_test.go b/internal/cli/coverage_edges_test.go
new file mode 100644
index 00000000..557749f9
--- /dev/null
+++ b/internal/cli/coverage_edges_test.go
@@ -0,0 +1,445 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+func TestCanonicalCommandsAndFlags(t *testing.T) {
+ mcp := NewMCPCommand(t.Context(), nil, nil, nil)
+ mcp.SetOut(&bytes.Buffer{})
+ if err := mcp.Execute(); err != nil {
+ t.Fatal(err)
+ }
+
+ schema := map[string]any{"properties": map[string]any{
+ "str": map[string]any{"type": "string", "description": " text "},
+ "enum": map[string]any{"enum": []any{"a"}},
+ "int": map[string]any{"type": "integer"},
+ "num": map[string]any{"type": "number"},
+ "bool": map[string]any{"type": "boolean"},
+ "object": map[string]any{"type": "object"},
+ "strings": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
+ "enums": map[string]any{"type": "array", "items": map[string]any{"enum": []any{"x"}}},
+ "ints": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
+ "nums": map[string]any{"type": "array", "items": map[string]any{"type": "number"}},
+ "bools": map[string]any{"type": "array", "items": map[string]any{"type": "boolean"}},
+ "objects": map[string]any{"type": "array", "items": map[string]any{"type": "object"}},
+ "unknown": map[string]any{"type": "future"},
+ "array": map[string]any{"type": "array"},
+ "invalid": "not schema",
+ }}
+ specs := BuildFlagSpecs(schema, map[string]CLIFlagHint{
+ "str": {Alias: "string-alias", Shorthand: "s"},
+ "int": {Alias: "int-alias", Shorthand: "i"},
+ "num": {Alias: "num-alias", Shorthand: "n"},
+ "bool": {Alias: "bool-alias", Shorthand: "b"},
+ })
+ if len(specs) != 13 {
+ t.Fatalf("flag specs = %d, want 13", len(specs))
+ }
+ if BuildFlagSpecs(nil, nil) != nil || BuildFlagSpecs(map[string]any{}, nil) != nil {
+ t.Fatal("missing properties produced flag specs")
+ }
+ cmd := &cobra.Command{Use: "flags"}
+ cmd.Flags().String("occupied", "", "")
+ cmd.Flags().StringP("short", "s", "", "")
+ specs = append(specs,
+ FlagSpec{PropertyName: "skip", FlagName: "json", Kind: flagString},
+ FlagSpec{PropertyName: "same", FlagName: "same", Alias: "same", Kind: flagString},
+ FlagSpec{PropertyName: "occupied", FlagName: "occupied", Kind: flagString},
+ FlagSpec{PropertyName: "default", FlagName: "default", Kind: flagString},
+ )
+ applyFlagSpecs(cmd, specs)
+ applyFlagSpecs(cmd, []FlagSpec{{PropertyName: "array-alias", FlagName: "array-alias", Alias: "array-hidden", Kind: flagStringArray}})
+ for _, name := range []string{"str", "string-alias", "int", "int-alias", "num", "num-alias", "bool", "bool-alias", "strings", "ints", "nums", "bools", "objects", "array", "default"} {
+ if cmd.Flags().Lookup(name) == nil {
+ t.Fatalf("flag %q not registered", name)
+ }
+ }
+ if nested, ok := nestedMap(map[string]any{"x": map[string]any{"y": true}}, "x"); !ok || !nested["y"].(bool) {
+ t.Fatal("nested map lookup failed")
+ }
+ if _, ok := nestedMap(nil, "x"); ok {
+ t.Fatal("nil nested map matched")
+ }
+ if _, ok := nestedMap(map[string]any{}, "x"); ok {
+ t.Fatal("missing nested map matched")
+ }
+ if _, ok := nestedMap(map[string]any{"x": 1}, "x"); ok {
+ t.Fatal("non-map nested value matched")
+ }
+ if schemaDescription(map[string]any{"description": 1}) != "" {
+ t.Fatal("non-string description rendered")
+ }
+}
+
+func TestSchemaCommandModes(t *testing.T) {
+ root := &cobra.Command{Use: "dws"}
+ dev := &cobra.Command{Use: "dev"}
+ group := &cobra.Command{Use: "app"}
+ leaf := &cobra.Command{
+ Use: "create",
+ Short: "create app",
+ Run: func(*cobra.Command, []string) {},
+ Annotations: map[string]string{
+ "mcp-tool": "app.create",
+ "mcp-source": "source",
+ },
+ }
+ group.AddCommand(leaf)
+ dev.AddCommand(group)
+ root.AddCommand(dev)
+ fetch := func(context.Context, string) (map[string]HelperToolSchema, error) {
+ return map[string]HelperToolSchema{
+ "app.create": {
+ Description: "create",
+ Properties: map[string]any{"appName": map[string]any{"type": "string", "default": "demo"}},
+ Required: []string{"appName"},
+ },
+ }, nil
+ }
+ schemaCmd := NewSchemaCommand(nil, fetch)
+ var out bytes.Buffer
+ schemaCmd.SetOut(&out)
+ root.AddCommand(schemaCmd)
+ root.SetArgs([]string{"schema", "dev.app.create"})
+ if err := root.Execute(); err != nil || !strings.Contains(out.String(), "app-name") {
+ t.Fatalf("helper schema output = %q, %v", out.String(), err)
+ }
+
+ root = &cobra.Command{Use: "dws"}
+ schemaCmd = NewSchemaCommand(nil, nil)
+ out.Reset()
+ schemaCmd.SetOut(&out)
+ root.AddCommand(schemaCmd)
+ root.SetArgs([]string{"schema"})
+ if err := root.Execute(); err != nil || !strings.Contains(out.String(), "static endpoint mode") {
+ t.Fatalf("static schema output = %q, %v", out.String(), err)
+ }
+ root = &cobra.Command{Use: "dws"}
+ schemaCmd = NewSchemaCommand(nil, nil)
+ root.AddCommand(schemaCmd)
+ root.SetArgs([]string{"schema", "one", "--cli-path", "two"})
+ if err := root.Execute(); err == nil {
+ t.Fatal("conflicting schema paths accepted")
+ }
+ root = &cobra.Command{Use: "dws"}
+ schemaCmd = NewSchemaCommand(nil, nil)
+ out.Reset()
+ schemaCmd.SetOut(&out)
+ root.AddCommand(schemaCmd)
+ root.SetArgs([]string{"schema", "--cli-path", "other"})
+ if err := root.Execute(); err != nil || !strings.Contains(out.String(), "static endpoint mode") {
+ t.Fatalf("cli-path schema = %q, %v", out.String(), err)
+ }
+ root = &cobra.Command{Use: "dws"}
+ dev = &cobra.Command{Use: "dev"}
+ leaf = &cobra.Command{Use: "leaf", Run: func(*cobra.Command, []string) {}, Annotations: map[string]string{"mcp-tool": "tool"}}
+ dev.AddCommand(leaf)
+ root.AddCommand(dev)
+ schemaCmd = NewSchemaCommand(nil, func(context.Context, string) (map[string]HelperToolSchema, error) {
+ return nil, errors.New("fetch failed")
+ })
+ root.AddCommand(schemaCmd)
+ root.SetArgs([]string{"schema", "dev.leaf"})
+ if err := root.Execute(); err == nil {
+ t.Fatal("schema fetch error did not propagate")
+ }
+}
+
+func TestHelperSchemaEdgeCases(t *testing.T) {
+ if payload, ok, err := renderHelperSchema(t.Context(), nil, "dev", nil); err != nil || ok || payload != nil {
+ t.Fatalf("nil helper root = %#v, %v, %v", payload, ok, err)
+ }
+ root := &cobra.Command{Use: "dws"}
+ dev := &cobra.Command{Use: "dev", Short: "dev"}
+ leafNoTool := &cobra.Command{Use: "connect", Run: func(*cobra.Command, []string) {}}
+ leaf := &cobra.Command{Use: "leaf", Run: func(*cobra.Command, []string) {}, Annotations: map[string]string{"mcp-tool": "tool"}}
+ hidden := &cobra.Command{Use: "hidden", Hidden: true, Run: func(*cobra.Command, []string) {}}
+ dev.AddCommand(leafNoTool, leaf, hidden)
+ root.AddCommand(dev)
+ for _, path := range []string{"", "other", "dev.unknown", "dev", "dev --flag"} {
+ if _, _, err := renderHelperSchema(t.Context(), root, path, nil); err != nil {
+ t.Fatalf("render %q: %v", path, err)
+ }
+ }
+ if payload, err := helperLeafSchema(t.Context(), leafNoTool, nil); err != nil || payload["error"] == nil {
+ t.Fatalf("unbound leaf = %#v, %v", payload, err)
+ }
+ if payload, err := helperLeafSchema(t.Context(), leaf, nil); err != nil || payload["error"] == nil {
+ t.Fatalf("nil fetcher leaf = %#v, %v", payload, err)
+ }
+ failure := errors.New("fetch failed")
+ if _, err := helperLeafSchema(t.Context(), leaf, func(context.Context, string) (map[string]HelperToolSchema, error) {
+ return nil, failure
+ }); !errors.Is(err, failure) {
+ t.Fatalf("fetch error = %v", err)
+ }
+ if payload, err := helperLeafSchema(t.Context(), leaf, func(context.Context, string) (map[string]HelperToolSchema, error) {
+ return map[string]HelperToolSchema{}, nil
+ }); err != nil || payload["error"] == nil {
+ t.Fatalf("missing tool = %#v, %v", payload, err)
+ }
+ leaf.Annotations["mcp-source"] = "custom"
+ payload, err := helperLeafSchema(t.Context(), leaf, func(_ context.Context, source string) (map[string]HelperToolSchema, error) {
+ if source != "custom" {
+ t.Fatalf("source = %q", source)
+ }
+ return map[string]HelperToolSchema{"tool": {Description: "desc"}}, nil
+ })
+ if err != nil || payload["source"] != "mcp:custom" {
+ t.Fatalf("leaf payload = %#v, %v", payload, err)
+ }
+}
+
+func TestHelperSchemaPrimitiveEdges(t *testing.T) {
+ tool := HelperToolSchema{
+ Properties: map[string]any{
+ "nil": nil,
+ "string": map[string]any{"type": "string", "default": "x"},
+ "integer": map[string]any{"type": "integer", "default": float64(2)},
+ "fraction": map[string]any{"type": "number", "default": 2.5},
+ "bool": map[string]any{"type": "boolean", "default": true},
+ "array": map[string]any{"type": "array"},
+ "object": map[string]any{"type": "object"},
+ "unknown": map[string]any{"type": "unknown"},
+ "missing": map[string]any{},
+ },
+ Required: []string{"string"},
+ }
+ if params := helperFlatParameters(tool); len(params) != len(tool.Properties) {
+ t.Fatalf("parameters = %#v", params)
+ }
+ for _, typ := range []string{"string", "integer", "number", "boolean", "array", "object", "unknown"} {
+ _ = mcpJSONType(map[string]any{"type": typ})
+ }
+ if mcpString(nil, "x") != "" || mcpString(map[string]any{"x": 1}, "x") != "" {
+ t.Fatal("invalid MCP string rendered")
+ }
+ for _, prop := range []map[string]any{nil, {}, {"default": nil}, {"default": "x"}, {"default": float64(1)}, {"default": 1.25}, {"default": true}} {
+ _, _ = mcpDefault(prop)
+ }
+ for input, want := range map[string]string{"SSLVerify": "ssl-verify", "__a--b__": "a-b", "aB2C": "a-b2-c"} {
+ if got := kebabCase(input); got != want {
+ t.Fatalf("kebabCase(%q) = %q, want %q", input, got, want)
+ }
+ }
+ if got := helperSubcommands(&cobra.Command{Use: "empty"}); len(got) != 0 {
+ t.Fatalf("empty subcommands = %#v", got)
+ }
+}
+
+func TestLoaderAndPriorityEdges(t *testing.T) {
+ for _, reason := range []CatalogDegradedReason{DegradedUnauthenticated, DegradedMarketUnreachable, DegradedRuntimeAllFailed, "other"} {
+ degraded := newCatalogDegraded(reason, 2)
+ if degraded.Error() == "" || degradedHint(reason, 2) == "" || degraded.Hint == "" {
+ t.Fatalf("degraded %q = %#v", reason, degraded)
+ }
+ }
+ catalog := Catalog{Products: []CanonicalProduct{{ID: "product", Tools: []ToolDescriptor{{RPCName: "tool"}}}}}
+ product, ok := catalog.FindProduct("product")
+ if !ok {
+ t.Fatal("product not found")
+ }
+ if _, ok := catalog.FindProduct("missing"); ok {
+ t.Fatal("missing product found")
+ }
+ if _, ok := product.FindTool("tool"); !ok {
+ t.Fatal("tool not found")
+ }
+ if _, ok := product.FindTool("missing"); ok {
+ t.Fatal("missing tool found")
+ }
+ if got, err := (StaticLoader{Catalog: catalog}).Load(t.Context()); err != nil || len(got.Products) != 1 {
+ t.Fatalf("static load = %#v, %v", got, err)
+ }
+ failure := errors.New("load failed")
+ if got, err := CatalogLoaderFrom(catalog, failure).Load(t.Context()); !errors.Is(err, failure) || len(got.Products) != 1 {
+ t.Fatalf("preloaded load = %#v, %v", got, err)
+ }
+ loader := NewEnvironmentLoader()
+ if got, err := loader.Load(t.Context()); err != nil || len(got.Products) != 0 {
+ t.Fatalf("environment load = %#v, %v", got, err)
+ }
+ cmd := &cobra.Command{Use: "priority"}
+ SetOverridePriority(cmd, 42)
+ if got := OverridePriority(cmd); got != 42 {
+ t.Fatalf("priority = %d", got)
+ }
+ original := edition.Get()
+ edition.Override(&edition.Hooks{IsEmbedded: true})
+ t.Cleanup(func() { edition.Override(original) })
+ for _, reason := range []CatalogDegradedReason{DegradedUnauthenticated, DegradedMarketUnreachable, DegradedRuntimeAllFailed} {
+ if degradedHint(reason, 2) == "" {
+ t.Fatalf("embedded degraded hint %q is empty", reason)
+ }
+ }
+}
+
+func TestSchemaValidationCompleteMatrix(t *testing.T) {
+ if err := ValidateInputSchema(nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateInputSchema(nil, map[string]any{"type": "object", "required": []string{"x"}}); err == nil {
+ t.Fatal("missing required property accepted")
+ }
+ schema := map[string]any{
+ "type": "object",
+ "required": []any{"known", 1, ""},
+ "properties": map[string]any{
+ "known": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
+ "skip": "invalid",
+ },
+ "additionalProperties": false,
+ }
+ if err := ValidateInputSchema(map[string]any{"known": []any{1, 2.5}}, schema); err == nil {
+ t.Fatal("invalid array item accepted")
+ }
+ if err := ValidateInputSchema(map[string]any{"known": []any{1}, "extra": true}, schema); err == nil {
+ t.Fatal("unknown property accepted")
+ }
+ additional := map[string]any{
+ "type": "object",
+ "properties": map[string]any{"known": map[string]any{"type": "string"}},
+ "additionalProperties": map[string]any{"type": "number"},
+ }
+ if err := ValidateInputSchema(map[string]any{"known": "x", "extra": "bad"}, additional); err == nil {
+ t.Fatal("invalid additional property accepted")
+ }
+ if err := validateSchemaValue("$", "x", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateSchemaValue("$", "x", map[string]any{"enum": []string{"y"}}); err == nil {
+ t.Fatal("invalid enum accepted")
+ }
+
+ typeCases := []struct {
+ value any
+ typ string
+ want bool
+ }{
+ {map[string]any{}, "object", true}, {[]any{}, "array", true}, {"x", "string", true},
+ {true, "boolean", true}, {1.5, "number", true}, {"x", "number", false},
+ {2, "integer", true}, {2.5, "integer", false}, {"x", "integer", false},
+ {nil, "null", true}, {"x", "future", true},
+ }
+ for _, tc := range typeCases {
+ if got := matchesType(tc.value, tc.typ); got != tc.want {
+ t.Fatalf("matchesType(%#v,%q) = %v, want %v", tc.value, tc.typ, got, tc.want)
+ }
+ }
+ for _, typed := range []any{"", "string", []string{"", "number"}, []any{"", 1, "boolean"}, 1} {
+ _ = schemaTypes(map[string]any{"type": typed})
+ }
+ if hasType([]string{"a", "b"}, "c") || !hasType([]string{"a", "b"}, "b") {
+ t.Fatal("hasType failed")
+ }
+ if matchesAnyType("x", []string{"number", "boolean"}) || !matchesAnyType("x", []string{"number", "string"}) {
+ t.Fatal("matchesAnyType failed")
+ }
+ for _, enum := range []any{[]any{"x"}, []string{"x"}, "invalid"} {
+ _ = schemaEnum(map[string]any{"enum": enum})
+ }
+ if valuesEqual("1", 1) || !valuesEqual(json.Number("1"), float64(1)) {
+ t.Fatal("enum value equality failed")
+ }
+ for _, number := range []any{float64(1), float32(1), int(1), int8(1), int16(1), int32(1), int64(1), uint(1), uint8(1), uint16(1), uint32(1), uint64(1), json.Number("1"), json.Number("x"), "x"} {
+ _, _ = numberValue(number)
+ }
+ if got := schemaProperties(map[string]any{}); len(got) != 0 {
+ t.Fatal("missing properties were nonempty")
+ }
+ for _, required := range []any{[]string{"x"}, []any{"x", 1, ""}, "invalid"} {
+ _ = schemaRequired(map[string]any{"required": required})
+ }
+ for _, additional := range []map[string]any{{}, {"additionalProperties": true}, {"additionalProperties": false}, {"additionalProperties": map[string]any{"type": "string"}}, {"additionalProperties": "invalid"}} {
+ _, _, _ = additionalProperties(additional)
+ }
+}
+
+func TestStdinCompleteMatrix(t *testing.T) {
+ original := os.Stdin
+ t.Cleanup(func() { os.Stdin = original })
+ closed, err := os.CreateTemp(t.TempDir(), "closed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = closed.Close()
+ os.Stdin = closed
+ if StdinIsPipe() {
+ t.Fatal("closed stdin reported as pipe")
+ }
+ if _, err := ReadStdin(); err == nil {
+ t.Fatal("closed stdin read succeeded")
+ }
+
+ stdin, err := os.CreateTemp(t.TempDir(), "stdin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := stdin.WriteString("piped"); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = stdin.Seek(0, 0)
+ os.Stdin = stdin
+ if !StdinIsPipe() {
+ t.Fatal("regular file stdin did not report pipe")
+ }
+ if got, err := ReadStdinIfPiped(); err != nil || got != "piped" {
+ t.Fatalf("piped stdin = %q, %v", got, err)
+ }
+ _, _ = stdin.Seek(0, 0)
+ guard := NewStdinGuard()
+ if got, err := ResolveInputSource("@-", "text", guard); err != nil || got != "piped" {
+ t.Fatalf("resolved stdin = %q, %v", got, err)
+ }
+ if _, err := ResolveInputSource("@-", "other", guard); err == nil {
+ t.Fatal("stdin reused")
+ }
+ if _, err := ResolveInputSource("@-", "text", nil); err == nil {
+ t.Fatal("nil stdin guard accepted")
+ }
+
+ dir := t.TempDir()
+ if _, err := readFileBounded(dir); err == nil {
+ t.Fatal("directory read as file")
+ }
+ large := filepath.Join(t.TempDir(), "large")
+ if err := os.WriteFile(large, bytes.Repeat([]byte("x"), maxStdinSize+1), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readFileBounded(large); err == nil {
+ t.Fatal("oversized file accepted")
+ }
+ largeStdin, err := os.Open(large)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer largeStdin.Close()
+ os.Stdin = largeStdin
+ if _, err := readStdinBounded(); err == nil {
+ t.Fatal("oversized stdin accepted")
+ }
+ for _, value := range []string{"@A", "@a", "@1", "@.", "@/", "@~", "@_", "@-", "@!", "@所有人", "plain"} {
+ _ = looksLikeFilePath(value)
+ }
+ if _, _, err := ReadFileArg("@"); err == nil {
+ t.Fatal("bare file argument accepted")
+ }
+ if _, err := ResolveInputSource("@", "text", guard); err == nil {
+ t.Fatal("bare input source accepted")
+ }
+}
diff --git a/internal/errors/coverage_edges_test.go b/internal/errors/coverage_edges_test.go
new file mode 100644
index 00000000..1db9d40f
--- /dev/null
+++ b/internal/errors/coverage_edges_test.go
@@ -0,0 +1,228 @@
+package errors
+
+import (
+ "bytes"
+ "encoding/json"
+ stderrors "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDiagnosticsAndErrorRenderingEdges(t *testing.T) {
+ retryable := false
+ err := NewAPI("message",
+ nil,
+ WithAvailableFlags(),
+ WithActions("", "retry"),
+ WithServerDiag(ServerDiagnostics{}),
+ WithTraceID(" "),
+ WithServerDiag(ServerDiagnostics{
+ TraceID: "trace", ServerErrorCode: "TOKEN_VERIFIED_FAILED",
+ TechnicalDetail: "detail", ServerRetryable: &retryable,
+ }),
+ )
+ typed := err.(*Error)
+ if typed.Retryable || typed.ServerDiag.TraceID != "trace" {
+ t.Fatalf("diagnostics options = %#v", typed)
+ }
+ if (ServerDiagnostics{TraceID: "trace"}).IsEmpty() {
+ t.Fatal("populated diagnostics should not be empty")
+ }
+ traceOnly := NewAPI("trace", WithTraceID(" trace-only ")).(*Error)
+ if traceOnly.ServerDiag.TraceID != "trace-only" {
+ t.Fatalf("trimmed trace ID = %q", traceOnly.ServerDiag.TraceID)
+ }
+
+ var out bytes.Buffer
+ if err := PrintJSON(&out, err); err != nil || !strings.Contains(out.String(), "friendly_hint") {
+ t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err)
+ }
+ out.Reset()
+ if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") {
+ t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err)
+ }
+ out.Reset()
+ flagsErr := NewAPI("flags", WithActions(" ", "act"), WithAvailableFlags("one", "two"))
+ if err := PrintHuman(&out, flagsErr); err != nil || !strings.Contains(out.String(), "Flags: one, two") {
+ t.Fatalf("PrintHuman flags/actions = %q, %v", out.String(), err)
+ }
+
+ oldMarshal := marshalErrorJSON
+ t.Cleanup(func() { marshalErrorJSON = oldMarshal })
+ marshalErrorJSON = func(any, string, string) ([]byte, error) { return nil, stderrors.New("encode") }
+ out.Reset()
+ if err := PrintJSON(&out, err); err != nil || !strings.Contains(out.String(), "failed to encode") {
+ t.Fatalf("PrintJSON fallback = %q, %v", out.String(), err)
+ }
+
+ if got := formatAvailableFlagsHumanLine(nil); got != "" {
+ t.Fatalf("empty flags line = %q", got)
+ }
+ if got := formatAvailableFlagsHumanLine([]string{strings.Repeat("x", 201)}); !strings.HasSuffix(got, "...") {
+ t.Fatalf("long first flag = %q", got)
+ }
+ if got := formatAvailableFlagsHumanLine([]string{strings.Repeat("x", 200), "y"}); !strings.HasSuffix(got, "...") {
+ t.Fatalf("long separator flags = %q", got)
+ }
+ if got := formatAvailableFlagsHumanLine([]string{"one", "two"}); got != "Flags: one, two" {
+ t.Fatalf("normal flags line = %q", got)
+ }
+}
+
+func TestValidationCoverageEdges(t *testing.T) {
+ for _, name := range []string{"", strings.Repeat("a", 129), "1startsWithDigit", "bad name"} {
+ if ResourceName(name) == nil {
+ t.Errorf("ResourceName(%q) should fail", name)
+ }
+ }
+ for _, path := range []string{"safe\u200bname", "safe\u202ename", "safe\u2066name"} {
+ if SafePath(path) == nil {
+ t.Errorf("SafePath(%q) should fail", path)
+ }
+ }
+ if _, err := SafeOutputPath("\x00"); err == nil {
+ t.Fatal("control character output path should fail")
+ }
+ if _, err := SafeInputPath(filepath.Join(string(filepath.Separator), "tmp", "absolute")); err == nil {
+ t.Fatal("absolute input path should fail")
+ }
+
+ oldCwd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("get cwd: %v", err)
+ }
+ dir := t.TempDir()
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(oldCwd) })
+ outside := t.TempDir()
+ if err := os.Symlink(outside, "outside"); err != nil {
+ t.Fatalf("outside symlink: %v", err)
+ }
+ if _, err := SafeOutputPath("outside/file"); err == nil {
+ t.Fatal("outside symlink should fail containment")
+ }
+ if err := os.Symlink("loop", "loop"); err != nil {
+ t.Fatalf("loop symlink: %v", err)
+ }
+ if _, err := SafeInputPath("loop/child"); err == nil {
+ t.Fatal("symlink loop should fail resolution")
+ }
+ if _, err := SafeLocalFlagPath("--file", "outside/file"); err == nil {
+ t.Fatal("unsafe local flag path should fail")
+ }
+ for _, value := range []string{"", "http://example.test/file", "https://example.test/file"} {
+ if got, err := SafeLocalFlagPath("--file", value); err != nil || got != value {
+ t.Errorf("SafeLocalFlagPath(%q) = %q, %v", value, got, err)
+ }
+ }
+
+ oldGetwd, oldLstat, oldEval, oldRel := getWorkingDir, lstatPath, evalSymlinks, relPath
+ t.Cleanup(func() { getWorkingDir, lstatPath, evalSymlinks, relPath = oldGetwd, oldLstat, oldEval, oldRel })
+ wantErr := stderrors.New("filesystem failure")
+ getWorkingDir = func() (string, error) { return "", wantErr }
+ if _, err := SafeOutputPath("file"); !stderrors.Is(err, wantErr) {
+ t.Fatalf("getwd error = %v", err)
+ }
+ getWorkingDir = oldGetwd
+ lstatPath = func(string) (os.FileInfo, error) { return nil, nil }
+ evalSymlinks = func(string) (string, error) { return "", wantErr }
+ if _, err := SafeOutputPath("file"); !stderrors.Is(err, wantErr) {
+ t.Fatalf("existing symlink error = %v", err)
+ }
+ if _, err := resolveNearestAncestor("file"); !stderrors.Is(err, wantErr) {
+ t.Fatalf("ancestor symlink error = %v", err)
+ }
+ lstatPath = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
+ if got, err := resolveNearestAncestor(string(filepath.Separator)); err != nil || got != string(filepath.Separator) {
+ t.Fatalf("root ancestor = %q, %v", got, err)
+ }
+ relPath = func(string, string) (string, error) { return "", wantErr }
+ if isUnderDir("child", "parent") {
+ t.Fatal("relative-path failure should not be under parent")
+ }
+}
+
+func TestPATURLAndMutationCoverageEdges(t *testing.T) {
+ oldHost := hostControlProvider
+ oldBrowser := patBrowserProvider
+ t.Cleanup(func() {
+ SetHostControlProvider(oldHost)
+ SetPATOpenBrowserProvider(oldBrowser)
+ })
+ SetHostControlProvider(nil)
+ SetPATOpenBrowserProvider(nil)
+ for _, initial := range []any{nil, "wrong-type"} {
+ out := map[string]any{"data": initial}
+ ApplyHostMutations(out)
+ if _, ok := out["data"].(map[string]any); !ok {
+ t.Fatalf("ApplyHostMutations(%#v) = %#v", initial, out)
+ }
+ }
+
+ for _, data := range []map[string]any{
+ {"authUrl": " https://example.test/auth "},
+ {"authorizationUrl": "https://example.test/auth"},
+ {"uri": 1},
+ } {
+ out := map[string]any{"data": data}
+ ApplyHostMutations(out)
+ }
+
+ for _, raw := range []string{
+ "",
+ "not a url",
+ "https://example.test/other",
+ "https://example.test/fe/old#personalAuthorization?flowId=only",
+ "https://example.test/fe/old?hash=done#personalAuthorization?flowId=f&userCode=u",
+ } {
+ if got := PATAuthorizationURL(raw); raw != "" && strings.TrimSpace(raw) != got {
+ t.Errorf("PATAuthorizationURL(%q) = %q", raw, got)
+ }
+ }
+ parsed := &url.URL{Scheme: "https", Host: "example.test", Path: "/fe/old", Fragment: "%2FpersonalAuthorization%3FflowId%3Df%26userCode%3Du"}
+ if values := patAuthorizationRouteQuery(parsed); values.Get("flowId") != "f" {
+ t.Fatalf("decoded route values = %v", values)
+ }
+ if values := patAuthorizationRouteQuery(mustParseURLForTest(t, "https://example.test/fe/old")); values != nil {
+ t.Fatalf("missing route values = %v", values)
+ }
+ if parsePersonalAuthorizationRouteQuery("no-route") != nil || parsePersonalAuthorizationRouteQuery("personalAuthorization?bad=%zz") != nil {
+ t.Fatal("invalid routes should not parse")
+ }
+ values := parsePersonalAuthorizationRouteQuery("#personalAuthorization?flowId=f&userCode=u#ignored")
+ if values.Get("userCode") != "u" {
+ t.Fatalf("cut route values = %v", values)
+ }
+
+ if _, err := marshalSingleLineJSONNoHTMLEscape(make(chan int)); err == nil {
+ t.Fatal("unsupported PAT JSON value should fail")
+ }
+ if got := cleanPATJSON(map[string]any{"data": map[string]any{"unsupported": make(chan int)}}, "PAT_NO_PERMISSION"); !strings.Contains(got, "PAT_NO_PERMISSION") {
+ t.Fatalf("PAT JSON fallback = %q", got)
+ }
+}
+
+func mustParseURLForTest(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse URL: %v", err)
+ }
+ return parsed
+}
+
+func TestInvalidRPCDataIsOmitted(t *testing.T) {
+ err := NewAPI("bad rpc", WithRPCData(json.RawMessage(`{`)))
+ var out bytes.Buffer
+ if printErr := PrintJSON(&out, err); printErr != nil {
+ t.Fatalf("PrintJSON: %v", printErr)
+ }
+ if strings.Contains(out.String(), "rpc_data") {
+ t.Fatalf("invalid RPC data should be omitted: %q", out.String())
+ }
+}
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
index 8f6bb210..137293f7 100644
--- a/internal/errors/errors.go
+++ b/internal/errors/errors.go
@@ -26,6 +26,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
+var marshalErrorJSON = jsonutil.MarshalIndent
+
// Category represents a stable error class with a documented exit code.
type Category string
@@ -312,7 +314,7 @@ func PrintJSON(w io.Writer, err error) error {
}
payload := map[string]any{"error": errorPayload}
- data, marshalErr := jsonutil.MarshalIndent(payload, "", " ")
+ data, marshalErr := marshalErrorJSON(payload, "", " ")
if marshalErr != nil {
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
return writeErr
diff --git a/internal/errors/validate.go b/internal/errors/validate.go
index 87c21d1d..3ed8c794 100644
--- a/internal/errors/validate.go
+++ b/internal/errors/validate.go
@@ -25,6 +25,10 @@ import (
var (
ErrInvalidResourceName = stderrors.New("invalid resource name")
ErrUnsafePath = stderrors.New("unsafe path detected")
+ getWorkingDir = os.Getwd
+ lstatPath = os.Lstat
+ evalSymlinks = filepath.EvalSymlinks
+ relPath = filepath.Rel
)
func ResourceName(name string) error {
@@ -172,7 +176,7 @@ func safePath(raw, flagName string) (string, error) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}
- cwd, err := os.Getwd()
+ cwd, err := getWorkingDir()
if err != nil {
return "", fmt.Errorf("cannot determine working directory: %w", err)
}
@@ -183,8 +187,8 @@ func safePath(raw, flagName string) (string, error) {
// resolve its symlinks, and re-attach the remaining tail segments.
// This prevents TOCTOU attacks where a non-existent intermediate
// directory is replaced with a symlink between check and use.
- if _, err := os.Lstat(resolved); err == nil {
- resolved, err = filepath.EvalSymlinks(resolved)
+ if _, err := lstatPath(resolved); err == nil {
+ resolved, err = evalSymlinks(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
@@ -195,7 +199,7 @@ func safePath(raw, flagName string) (string, error) {
}
}
- canonicalCwd, _ := filepath.EvalSymlinks(cwd)
+ canonicalCwd, _ := evalSymlinks(cwd)
if !isUnderDir(resolved, canonicalCwd) {
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
}
@@ -211,8 +215,8 @@ func resolveNearestAncestor(path string) (string, error) {
var tail []string
cur := path
for {
- if _, err := os.Lstat(cur); err == nil {
- real, err := filepath.EvalSymlinks(cur)
+ if _, err := lstatPath(cur); err == nil {
+ real, err := evalSymlinks(cur)
if err != nil {
return "", err
}
@@ -233,7 +237,7 @@ func resolveNearestAncestor(path string) (string, error) {
// isUnderDir checks whether child is under parent directory.
func isUnderDir(child, parent string) bool {
- rel, err := filepath.Rel(parent, child)
+ rel, err := relPath(parent, child)
if err != nil {
return false
}
diff --git a/internal/event/bus/coverage_edges_test.go b/internal/event/bus/coverage_edges_test.go
new file mode 100644
index 00000000..91cfd080
--- /dev/null
+++ b/internal/event/bus/coverage_edges_test.go
@@ -0,0 +1,507 @@
+package bus
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
+ eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
+)
+
+var errBusInjected = errors.New("injected bus failure")
+
+func TestMatcherConsumerAndCounterEdges(t *testing.T) {
+ m, err := compileMatcher([]string{"", "*", "im.*", "prefix*", "exact"}, "^im", " sub ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.matches(nil) {
+ t.Fatal("nil event matched")
+ }
+ for _, tc := range []struct {
+ eventType, subscribe string
+ want bool
+ }{
+ {"exact", "sub", false},
+ {"im.message", "other", false},
+ {"im.message", "sub", true},
+ {"prefix-value", "sub", false},
+ } {
+ if got := m.matches(&dwsevent.RawEvent{EventType: tc.eventType, SubscribeID: tc.subscribe}); got != tc.want {
+ t.Fatalf("matches(%q,%q) = %v", tc.eventType, tc.subscribe, got)
+ }
+ }
+ m, _ = compileMatcher([]string{"exact", "pre*"}, "", "")
+ if !m.matches(&dwsevent.RawEvent{EventType: "exact"}) || !m.matches(&dwsevent.RawEvent{EventType: "prefix"}) {
+ t.Fatal("exact/prefix matcher failed")
+ }
+ if m.matches(&dwsevent.RawEvent{EventType: "other"}) {
+ t.Fatal("unexpected matcher hit")
+ }
+
+ re := &RegisterError{Err: errBusInjected}
+ if !errors.Is(re, errBusInjected) || re.Error() == "" {
+ t.Fatal("register error contract")
+ }
+ h := NewHub(0)
+ h.Deliver(nil)
+ c := &Consumer{SendCh: make(chan any), matcher: consumerMatcher{catchAll: true}}
+ c.deliver(mkEvent("unbuffered", "1"), h.Counters())
+ if c.dropped.Load() != 1 {
+ t.Fatalf("unbuffered dropped = %d", c.dropped.Load())
+ }
+ if ok, evicted := c.tryPushOrDropOldestLocked("x"); ok || evicted {
+ t.Fatalf("unbuffered = %v,%v", ok, evicted)
+ }
+ c.closed = true
+ c.deliver(mkEvent("closed", "2"), h.Counters())
+ if c.enqueue("x") {
+ t.Fatal("closed enqueue succeeded")
+ }
+ c.closeSend()
+
+ buffered := &Consumer{SendCh: make(chan any, 1)}
+ buffered.SendCh <- "old"
+ if ok, evicted := buffered.tryPushOrDropOldestLocked("new"); !ok || !evicted {
+ t.Fatalf("drop oldest = %v,%v", ok, evicted)
+ }
+ buffered.closeSend()
+ buffered.closeSend()
+
+ counters := NewPerTypeCounters()
+ counters.row("zero")
+ if got := counters.DropRatePercent("zero"); got != -1 {
+ t.Fatalf("zero rate = %d", got)
+ }
+ origHook := counterRowMissHook
+ t.Cleanup(func() { counterRowMissHook = origHook })
+ inserted := false
+ counterRowMissHook = func() {
+ if inserted {
+ return
+ }
+ inserted = true
+ counters.mu.Lock()
+ counters.m["raced"] = &typeRow{}
+ counters.mu.Unlock()
+ }
+ if counters.row("raced") == nil {
+ t.Fatal("raced row missing")
+ }
+}
+
+func TestDropWarningScanEdges(t *testing.T) {
+ origInterval := dropWarnTickInterval
+ t.Cleanup(func() { dropWarnTickInterval = origInterval })
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ dropWarnWatcher(ctx, NewPerTypeCounters(), slog.Default(), 0)
+ dropWarnWatcher(ctx, NewPerTypeCounters(), slog.Default(), 50)
+
+ c := NewPerTypeCounters()
+ c.AddReceived("healthy")
+ c.AddReceived("warn")
+ c.AddDropped("warn")
+ state := map[string]int{"healthy": 90}
+ var logs bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&logs, nil))
+ scanDropWarnings(c, logger, 25, state)
+ if _, ok := state["healthy"]; ok {
+ t.Fatal("healthy warning state retained")
+ }
+ if state["warn"] == 0 || logs.Len() == 0 {
+ t.Fatal("warning not emitted")
+ }
+ before := logs.Len()
+ scanDropWarnings(c, logger, 25, state)
+ if logs.Len() != before {
+ t.Fatal("warning hysteresis failed")
+ }
+ for i := 0; i < 10; i++ {
+ c.AddDropped("warn")
+ }
+ scanDropWarnings(c, logger, 25, state)
+ if logs.Len() == before {
+ t.Fatal("worsened warning not emitted")
+ }
+ dropWarnTickInterval = time.Millisecond
+ ctx, cancel = context.WithTimeout(context.Background(), 4*time.Millisecond)
+ defer cancel()
+ dropWarnWatcher(ctx, c, logger, 25)
+}
+
+func TestLockFailureInjection(t *testing.T) {
+ origTry := busTryAcquire
+ origSeek := busSeek
+ origRead := busReadAll
+ origAlive := busProcessAlive
+ origTruncate := busTruncate
+ origFprintf := busFprintf
+ origSync := busSync
+ t.Cleanup(func() {
+ busTryAcquire = origTry
+ busSeek = origSeek
+ busReadAll = origRead
+ busProcessAlive = origAlive
+ busTruncate = origTruncate
+ busFprintf = origFprintf
+ busSync = origSync
+ })
+ busTryAcquire = func(string) (*eventlock.File, error) { return nil, errBusInjected }
+ if _, err := Acquire("x"); err == nil {
+ t.Fatal("acquire error expected")
+ }
+ busTryAcquire = origTry
+
+ newPath := func(name string) string { return filepath.Join(t.TempDir(), name) }
+ busSeek = func(*os.File, int64, int) (int64, error) { return 0, errBusInjected }
+ if _, err := Acquire(newPath("seek")); err == nil {
+ t.Fatal("seek error expected")
+ }
+ busSeek = origSeek
+ busReadAll = func(io.Reader) ([]byte, error) { return nil, errBusInjected }
+ if _, err := Acquire(newPath("read")); err == nil {
+ t.Fatal("read error expected")
+ }
+ busReadAll = origRead
+ path := newPath("alive")
+ if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ busProcessAlive = func(int) bool { return true }
+ if _, err := Acquire(path); !errors.Is(err, ErrStaleOwnerAlive) {
+ t.Fatalf("alive error = %v", err)
+ }
+ busProcessAlive = origAlive
+ busTruncate = func(*os.File, int64) error { return errBusInjected }
+ if _, err := Acquire(newPath("write")); err == nil {
+ t.Fatal("write PID error expected")
+ }
+ busTruncate = origTruncate
+
+ lock, err := Acquire(newPath("held"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lock.HoldsPath() == "" {
+ t.Fatal("held path empty")
+ }
+ _ = lock.Close()
+ if lock.HoldsPath() != "" {
+ t.Fatal("closed path retained")
+ }
+ var nilLock *Lock
+ if nilLock.HoldsPath() != "" || nilLock.Close() != nil {
+ t.Fatal("nil lock contract")
+ }
+
+ f, err := os.CreateTemp(t.TempDir(), "pid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ busTruncate = func(*os.File, int64) error { return errBusInjected }
+ if truncateAndWritePID(f, 1) == nil {
+ t.Fatal("truncate failure expected")
+ }
+ busTruncate = func(*os.File, int64) error { return nil }
+ busSeek = func(*os.File, int64, int) (int64, error) { return 0, errBusInjected }
+ if truncateAndWritePID(f, 1) == nil {
+ t.Fatal("PID seek failure expected")
+ }
+ busSeek = origSeek
+ busFprintf = func(io.Writer, string, ...any) (int, error) { return 0, errBusInjected }
+ if truncateAndWritePID(f, 1) == nil {
+ t.Fatal("PID write failure expected")
+ }
+ busFprintf = origFprintf
+ busSync = func(*os.File) error { return errBusInjected }
+ if truncateAndWritePID(f, 0) == nil {
+ t.Fatal("sync failure expected")
+ }
+}
+
+func TestMetaFailureInjection(t *testing.T) {
+ origMarshal := metaMarshalIndent
+ origWrite := metaWriteFile
+ origRename := metaRename
+ origRemove := metaRemove
+ t.Cleanup(func() {
+ metaMarshalIndent = origMarshal
+ metaWriteFile = origWrite
+ metaRename = origRename
+ metaRemove = origRemove
+ })
+ metaMarshalIndent = func(any, string, string) ([]byte, error) { return nil, errBusInjected }
+ if WriteMeta(t.TempDir(), Meta{}) == nil {
+ t.Fatal("marshal error expected")
+ }
+ metaMarshalIndent = origMarshal
+ metaWriteFile = func(string, []byte, os.FileMode) error { return errBusInjected }
+ if WriteMeta(t.TempDir(), Meta{}) == nil {
+ t.Fatal("write error expected")
+ }
+ metaWriteFile = origWrite
+ removed := false
+ metaRename = func(string, string) error { return errBusInjected }
+ metaRemove = func(string) error { removed = true; return nil }
+ if WriteMeta(t.TempDir(), Meta{}) == nil || !removed {
+ t.Fatal("rename cleanup error expected")
+ }
+}
+
+type edgeSource struct {
+ start func(context.Context, dwsevent.EmitFn) error
+}
+
+func (s edgeSource) Start(ctx context.Context, emit dwsevent.EmitFn) error { return s.start(ctx, emit) }
+
+func TestRunStartupAndSourceEdges(t *testing.T) {
+ if err := Run(context.Background(), Config{}); err == nil {
+ t.Fatal("source validation expected")
+ }
+ origMkdir := daemonMkdirAll
+ origAcquire := daemonAcquire
+ origMeta := daemonWriteMeta
+ origListen := daemonListen
+ t.Cleanup(func() {
+ daemonMkdirAll = origMkdir
+ daemonAcquire = origAcquire
+ daemonWriteMeta = origMeta
+ daemonListen = origListen
+ })
+ source := edgeSource{start: func(context.Context, dwsevent.EmitFn) error { return errBusInjected }}
+ base := Config{WorkDir: t.TempDir(), IPCEndpoint: filepath.Join(t.TempDir(), "bus.sock"), Source: source}
+ daemonMkdirAll = func(string, os.FileMode) error { return errBusInjected }
+ if err := Run(context.Background(), base); err == nil {
+ t.Fatal("mkdir error expected")
+ }
+ daemonMkdirAll = origMkdir
+ daemonAcquire = func(string) (*Lock, error) { return nil, errBusInjected }
+ if err := Run(context.Background(), base); err == nil {
+ t.Fatal("lock error expected")
+ }
+ daemonAcquire = origAcquire
+ daemonWriteMeta = func(string, Meta) error { return errBusInjected }
+ if err := Run(context.Background(), base); err == nil {
+ t.Fatal("meta error expected")
+ }
+ daemonWriteMeta = origMeta
+ daemonListen = func(string) (transport.Listener, error) { return nil, errBusInjected }
+ if err := Run(context.Background(), base); err == nil {
+ t.Fatal("listen error expected")
+ }
+ daemonListen = origListen
+
+ workDir := shortTempDir(t)
+ base.WorkDir = workDir
+ base.IPCEndpoint = filepath.Join(workDir, "bus.sock")
+ base.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
+ base.Source = edgeSource{start: func(_ context.Context, emit dwsevent.EmitFn) error {
+ emit(nil)
+ e := &dwsevent.RawEvent{EventID: "same", EventType: "type", ReceivedAt: time.Now()}
+ emit(e)
+ emit(e)
+ return errBusInjected
+ }}
+ if err := Run(context.Background(), base); !errors.Is(err, errBusInjected) {
+ t.Fatalf("source error = %v", err)
+ }
+}
+
+type scriptedListener struct {
+ accept func() (net.Conn, error)
+ closed bool
+}
+
+func (l *scriptedListener) Accept() (net.Conn, error) { return l.accept() }
+func (l *scriptedListener) Close() error { l.closed = true; return nil }
+func (*scriptedListener) Endpoint() string { return "edge" }
+
+func TestDaemonMethodEdges(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ calls := 0
+ l := &scriptedListener{accept: func() (net.Conn, error) {
+ calls++
+ if calls == 1 {
+ return nil, errBusInjected
+ }
+ return nil, net.ErrClosed
+ }}
+ d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
+ d.acceptLoop(context.Background())
+ d.shuttingDown.Store(true)
+ d.acceptLoop(context.Background())
+ d.triggerShutdown("test")
+ if !l.closed {
+ t.Fatal("trigger did not close listener")
+ }
+
+ d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
+ d.idleWatch(context.Background())
+ ctx, cancel := context.WithCancel(context.Background())
+ d.cfg.IdleTimeout = time.Millisecond
+ cancel()
+ d.idleWatch(ctx)
+ d = &daemon{cfg: Config{IdleTimeout: 4 * time.Millisecond}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
+ consumer, _ := d.hub.Register(transport.Hello{})
+ idleDone := make(chan struct{})
+ go func() { d.idleWatch(context.Background()); close(idleDone) }()
+ time.Sleep(3 * time.Millisecond)
+ d.hub.Unregister(consumer.ID)
+ select {
+ case <-idleDone:
+ case <-time.After(50 * time.Millisecond):
+ t.Fatal("idle watcher did not stop")
+ }
+
+ origTimeout := daemonShutdownTimeout
+ t.Cleanup(func() { daemonShutdownTimeout = origTimeout })
+ daemonShutdownTimeout = time.Millisecond
+ d.listener = &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}
+ d.conns.Store("not-a-conn", struct{}{})
+ d.conns.Store(&queryConn{}, struct{}{})
+ d.consumerWG.Add(1)
+ d.shutdown()
+ d.consumerWG.Done()
+ d.shutdown()
+
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := errBusInjected
+ if got := failReady(pw, want); !errors.Is(got, want) {
+ t.Fatal(got)
+ }
+ b := make([]byte, 1)
+ _, _ = pr.Read(b)
+ _ = pr.Close()
+ if b[0] != 'E' {
+ t.Fatalf("fail ready byte = %q", b)
+ }
+ if failReady(nil, want) != want {
+ t.Fatal("nil failReady changed error")
+ }
+}
+
+func TestHandleConnectionProtocolEdges(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ newDaemon := func() *daemon {
+ return &daemon{
+ cfg: Config{ClientID: "client", Edition: "open", IdleTimeout: time.Second},
+ log: logger, hub: NewHub(2), started: time.Now(), idleStop: make(chan struct{}),
+ listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }},
+ }
+ }
+ run := func(d *daemon, action func(net.Conn)) {
+ server, client := net.Pipe()
+ done := make(chan struct{})
+ go func() { d.handleConnection(context.Background(), server); close(done) }()
+ action(client)
+ _ = client.Close()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("connection handler did not return")
+ }
+ }
+
+ run(newDaemon(), func(c net.Conn) {})
+ run(newDaemon(), func(c net.Conn) {
+ _ = transport.NewWriter(c).WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq})
+ })
+ run(newDaemon(), func(c net.Conn) {
+ _ = transport.NewWriter(c).WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleStatus})
+ })
+ run(newDaemon(), func(c net.Conn) {
+ w, r := transport.NewWriter(c), transport.NewReader(c)
+ _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleStatus})
+ _ = w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq})
+ var resp transport.StatusResp
+ if err := r.ReadJSON(&resp); err != nil {
+ t.Fatal(err)
+ }
+ })
+ run(newDaemon(), func(c net.Conn) {
+ w, r := transport.NewWriter(c), transport.NewReader(c)
+ _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleStop})
+ var bye transport.Bye
+ if err := r.ReadJSON(&bye); err != nil {
+ t.Fatal(err)
+ }
+ })
+ run(newDaemon(), func(c net.Conn) {
+ w, r := transport.NewWriter(c), transport.NewReader(c)
+ _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Filter: "["})
+ var bye transport.Bye
+ if err := r.ReadJSON(&bye); err != nil {
+ t.Fatal(err)
+ }
+ })
+ run(newDaemon(), func(c net.Conn) {
+ _ = transport.NewWriter(c).WriteJSON(transport.Hello{Type: transport.FrameTypeHello})
+ _ = c.Close()
+ })
+ run(newDaemon(), func(c net.Conn) {
+ w, r := transport.NewWriter(c), transport.NewReader(c)
+ _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello})
+ var ack transport.HelloAck
+ if err := r.ReadJSON(&ack); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = c.Write([]byte("{\n"))
+ _ = w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq})
+ _ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye})
+ })
+ run(newDaemon(), func(c net.Conn) {
+ w, r := transport.NewWriter(c), transport.NewReader(c)
+ _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello})
+ var ack transport.HelloAck
+ if err := r.ReadJSON(&ack); err != nil {
+ t.Fatal(err)
+ }
+ payload := bytes.Repeat([]byte("x"), transport.MaxFrameBytes+1)
+ _ = c.SetWriteDeadline(time.Now().Add(100 * time.Millisecond))
+ _, _ = c.Write(payload)
+ })
+ d := newDaemon()
+ run(d, func(c net.Conn) {
+ w, r := transport.NewWriter(c), transport.NewReader(c)
+ _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello})
+ var ack transport.HelloAck
+ if err := r.ReadJSON(&ack); err != nil {
+ t.Fatal(err)
+ }
+ d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye})
+ time.Sleep(time.Millisecond)
+ })
+}
+
+type queryConn struct{}
+
+func (*queryConn) Read([]byte) (int, error) { return 0, io.EOF }
+func (*queryConn) Write(p []byte) (int, error) { return len(p), nil }
+func (*queryConn) Close() error { return nil }
+func (*queryConn) LocalAddr() net.Addr { return edgeAddr("local") }
+func (*queryConn) RemoteAddr() net.Addr { return edgeAddr("remote") }
+func (*queryConn) SetDeadline(time.Time) error { return nil }
+func (*queryConn) SetReadDeadline(time.Time) error { return nil }
+func (*queryConn) SetWriteDeadline(time.Time) error { return nil }
+
+type edgeAddr string
+
+func (a edgeAddr) Network() string { return string(a) }
+func (a edgeAddr) String() string { return string(a) }
+
+var _ = fmt.Sprintf
diff --git a/internal/event/bus/daemon.go b/internal/event/bus/daemon.go
index 0e42aa51..d85bd46d 100644
--- a/internal/event/bus/daemon.go
+++ b/internal/event/bus/daemon.go
@@ -103,6 +103,14 @@ type Config struct {
Logger *slog.Logger
}
+var (
+ daemonMkdirAll = os.MkdirAll
+ daemonAcquire = Acquire
+ daemonWriteMeta = WriteMeta
+ daemonListen = transport.Listen
+ daemonShutdownTimeout = 2 * time.Second
+)
+
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
// 1. Acquire bus.lock (single-instance enforcement)
// 2. Write bus.meta
@@ -124,11 +132,11 @@ func Run(ctx context.Context, cfg Config) error {
log := cfg.Logger.With("component", "bus", "client_id", cfg.ClientID, "edition", cfg.Edition)
// 1. Acquire bus.lock
- if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
+ if err := daemonMkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: mkdir workdir: %w", err))
}
lockPath := filepath.Join(cfg.WorkDir, LockFileName)
- lock, err := Acquire(lockPath)
+ lock, err := daemonAcquire(lockPath)
if err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: acquire lock: %w", err))
}
@@ -145,12 +153,12 @@ func Run(ctx context.Context, cfg Config) error {
SDKVersion: cfg.SDKVersion,
BusPID: os.Getpid(),
}
- if err := WriteMeta(cfg.WorkDir, meta); err != nil {
+ if err := daemonWriteMeta(cfg.WorkDir, meta); err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: write meta: %w", err))
}
// 3. IPC listen
- listener, err := transport.Listen(cfg.IPCEndpoint)
+ listener, err := daemonListen(cfg.IPCEndpoint)
if err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: ipc listen: %w", err))
}
@@ -498,7 +506,7 @@ func (d *daemon) shutdown() {
}()
select {
case <-doneCh:
- case <-time.After(2 * time.Second):
+ case <-time.After(daemonShutdownTimeout):
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
}
}
diff --git a/internal/event/bus/dropwarn.go b/internal/event/bus/dropwarn.go
index 3e0e3062..8cf00328 100644
--- a/internal/event/bus/dropwarn.go
+++ b/internal/event/bus/dropwarn.go
@@ -30,7 +30,7 @@ const DefaultDropWarnPercent = 5
// dropWarnTickInterval is how often the watcher samples counters. 30s
// balances responsiveness ("see the warning while the burst is still
// happening") with log noise (one warning per scan, not one per drop).
-const dropWarnTickInterval = 30 * time.Second
+var dropWarnTickInterval = 30 * time.Second
// dropWarnState memoises the last warned drop rate per event type so we
// only emit a fresh WARN when the situation worsens by at least
@@ -57,25 +57,29 @@ func dropWarnWatcher(ctx context.Context, counters *PerTypeCounters, log *slog.L
case <-ctx.Done():
return
case <-tick.C:
- for _, et := range counters.SortedTypes() {
- pct := counters.DropRatePercent(et)
- if pct < threshold {
- // drop rate is healthy → forget any prior warning so
- // a future spike re-triggers a fresh WARN
- delete(lastWarnedPct, et)
- continue
- }
- prev, warned := lastWarnedPct[et]
- if warned && pct < prev+dropWarnHysteresis {
- continue // not significantly worse; suppress
- }
- log.Warn("bus: event type backpressure",
- "event_type", et,
- "drop_pct", pct,
- "threshold_pct", threshold,
- )
- lastWarnedPct[et] = pct
- }
+ scanDropWarnings(counters, log, threshold, lastWarnedPct)
}
}
}
+
+func scanDropWarnings(counters *PerTypeCounters, log *slog.Logger, threshold int, lastWarnedPct map[string]int) {
+ for _, et := range counters.SortedTypes() {
+ pct := counters.DropRatePercent(et)
+ if pct < threshold {
+ // drop rate is healthy → forget any prior warning so
+ // a future spike re-triggers a fresh WARN
+ delete(lastWarnedPct, et)
+ continue
+ }
+ prev, warned := lastWarnedPct[et]
+ if warned && pct < prev+dropWarnHysteresis {
+ continue // not significantly worse; suppress
+ }
+ log.Warn("bus: event type backpressure",
+ "event_type", et,
+ "drop_pct", pct,
+ "threshold_pct", threshold,
+ )
+ lastWarnedPct[et] = pct
+ }
+}
diff --git a/internal/event/bus/lockfile.go b/internal/event/bus/lockfile.go
index e84a9360..0349a8c9 100644
--- a/internal/event/bus/lockfile.go
+++ b/internal/event/bus/lockfile.go
@@ -33,6 +33,16 @@ const LockFileName = "bus.lock"
// ErrBusy is re-exported from lock for callers that only depend on bus.
var ErrBusy = lock.ErrBusy
+var (
+ busTryAcquire = lock.TryAcquire
+ busSeek = func(f *os.File, offset int64, whence int) (int64, error) { return f.Seek(offset, whence) }
+ busReadAll = io.ReadAll
+ busProcessAlive = process.Alive
+ busTruncate = func(f *os.File, size int64) error { return f.Truncate(size) }
+ busFprintf = fmt.Fprintf
+ busSync = func(f *os.File) error { return f.Sync() }
+)
+
// ErrStaleOwnerAlive indicates the PID stored in bus.lock points at a
// live process — there is already a bus running for this ClientID and we
// must not start another one. (This case is hit when the holder is still
@@ -59,24 +69,24 @@ type Lock struct {
// On success the returned Lock owns an exclusive flock and a file body
// containing our PID. Concurrent competing processes will get ErrBusy.
func Acquire(path string) (*Lock, error) {
- l, err := lock.TryAcquire(path)
+ l, err := busTryAcquire(path)
if err != nil {
return nil, err // already wraps ErrBusy when busy
}
// flock acquired. Read existing PID (if any).
f := l.File()
- if _, err := f.Seek(0, io.SeekStart); err != nil {
+ if _, err := busSeek(f, 0, io.SeekStart); err != nil {
_ = l.Close()
return nil, fmt.Errorf("bus: seek lock: %w", err)
}
- old, err := io.ReadAll(f)
+ old, err := busReadAll(f)
if err != nil {
_ = l.Close()
return nil, fmt.Errorf("bus: read lock: %w", err)
}
- if pid := parsePID(old); pid > 0 && process.Alive(pid) {
+ if pid := parsePID(old); pid > 0 && busProcessAlive(pid) {
// Defensive: flock returned us the lock, but the stored PID is
// alive. This shouldn't normally happen (the live process holds
// the flock), but it's possible across odd kernel/FS edge cases
@@ -130,18 +140,18 @@ func (l *Lock) HoldsPath() string {
}
func truncateAndWritePID(f *os.File, pid int) error {
- if err := f.Truncate(0); err != nil {
+ if err := busTruncate(f, 0); err != nil {
return err
}
- if _, err := f.Seek(0, io.SeekStart); err != nil {
+ if _, err := busSeek(f, 0, io.SeekStart); err != nil {
return err
}
if pid > 0 {
- if _, err := fmt.Fprintf(f, "%d\n", pid); err != nil {
+ if _, err := busFprintf(f, "%d\n", pid); err != nil {
return err
}
}
- return f.Sync()
+ return busSync(f)
}
func parsePID(b []byte) int {
diff --git a/internal/event/bus/meta.go b/internal/event/bus/meta.go
index a43850f2..1e2412b0 100644
--- a/internal/event/bus/meta.go
+++ b/internal/event/bus/meta.go
@@ -57,6 +57,13 @@ type Meta struct {
// readers (older readers tolerate unknown fields via encoding/json).
const CurrentBusVersion = "v1"
+var (
+ metaMarshalIndent = json.MarshalIndent
+ metaWriteFile = os.WriteFile
+ metaRename = os.Rename
+ metaRemove = os.Remove
+)
+
// WriteMeta atomically writes m to
/bus.meta. Atomic via tmp-file +
// rename. Directory permissions are not changed; caller must mkdir the
// containing directory beforehand with pkg/config.DirPerm.
@@ -70,17 +77,17 @@ func WriteMeta(dir string, m Meta) error {
if m.StartedAt.IsZero() {
m.StartedAt = time.Now().UTC()
}
- b, err := json.MarshalIndent(m, "", " ")
+ b, err := metaMarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("bus: marshal meta: %w", err)
}
final := filepath.Join(dir, MetaFileName)
tmp := final + ".tmp"
- if err := os.WriteFile(tmp, b, config.FilePerm); err != nil {
+ if err := metaWriteFile(tmp, b, config.FilePerm); err != nil {
return fmt.Errorf("bus: write tmp meta: %w", err)
}
- if err := os.Rename(tmp, final); err != nil {
- _ = os.Remove(tmp)
+ if err := metaRename(tmp, final); err != nil {
+ _ = metaRemove(tmp)
return fmt.Errorf("bus: rename meta: %w", err)
}
return nil
diff --git a/internal/event/bus/metrics.go b/internal/event/bus/metrics.go
index 8279a8d5..c31cc40d 100644
--- a/internal/event/bus/metrics.go
+++ b/internal/event/bus/metrics.go
@@ -21,6 +21,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
+var counterRowMissHook = func() {}
+
// PerTypeCounters is bus-wide and tracks how many events of each event_type
// the bus delivered (or dropped due to backpressure) since startup. Surfaced
// via `dws event status`. Concurrent-safe.
@@ -108,6 +110,7 @@ func (c *PerTypeCounters) row(eventType string) *typeRow {
if ok {
return row
}
+ counterRowMissHook()
c.mu.Lock()
defer c.mu.Unlock()
// double-check after acquiring write lock
diff --git a/internal/event/busctl/coverage_edges_test.go b/internal/event/busctl/coverage_edges_test.go
new file mode 100644
index 00000000..f181707f
--- /dev/null
+++ b/internal/event/busctl/coverage_edges_test.go
@@ -0,0 +1,310 @@
+package busctl
+
+import (
+ "errors"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
+)
+
+var errBusctlInjected = errors.New("injected busctl failure")
+
+func TestDiscoverValidationAndFailures(t *testing.T) {
+ for name, cfg := range map[string]DiscoverConfig{
+ "workdir": {IPCEndpoint: "endpoint", ClientID: "id"},
+ "endpoint": {WorkDir: t.TempDir(), ClientID: "id"},
+ "client": {WorkDir: t.TempDir(), IPCEndpoint: "endpoint"},
+ } {
+ if _, err := Discover(cfg); err == nil {
+ t.Fatalf("%s validation error expected", name)
+ }
+ }
+
+ origDial := discoverDial
+ origMkdir := discoverMkdirAll
+ origSpawn := discoverSpawn
+ t.Cleanup(func() {
+ discoverDial = origDial
+ discoverMkdirAll = origMkdir
+ discoverSpawn = origSpawn
+ })
+ discoverDial = func(string) (net.Conn, error) { return nil, errBusctlInjected }
+ discoverMkdirAll = func(string, os.FileMode) error { return errBusctlInjected }
+ if _, err := Discover(DiscoverConfig{WorkDir: t.TempDir(), IPCEndpoint: "x", ClientID: "id"}); err == nil {
+ t.Fatal("mkdir error expected")
+ }
+ discoverMkdirAll = func(string, os.FileMode) error { return nil }
+ discoverSpawn = func(SpawnConfig) (int, error) { return 0, ErrSpawnFailed }
+ if _, err := Discover(DiscoverConfig{
+ WorkDir: t.TempDir(), IPCEndpoint: "x", ClientID: "id",
+ DialDeadline: time.Millisecond, DialBackoff: time.Millisecond, DialMaxBackoff: time.Nanosecond,
+ }); err == nil {
+ t.Fatal("deadline error expected")
+ }
+ if got := MetaPath("dir"); got != filepath.Join("dir", "bus.meta") {
+ t.Fatalf("MetaPath = %q", got)
+ }
+}
+
+func TestSpawnAndReadyFailureEdges(t *testing.T) {
+ origExecutable := spawnExecutable
+ origPipe := spawnPipe
+ origTimeout := ReadyTimeout
+ t.Cleanup(func() {
+ spawnExecutable = origExecutable
+ spawnPipe = origPipe
+ ReadyTimeout = origTimeout
+ })
+ if _, err := Spawn(SpawnConfig{}); err == nil {
+ t.Fatal("client ID validation expected")
+ }
+ spawnExecutable = func() (string, error) { return "", errBusctlInjected }
+ if _, err := Spawn(SpawnConfig{ClientID: "id"}); err == nil {
+ t.Fatal("executable error expected")
+ }
+ spawnExecutable = origExecutable
+ spawnPipe = func() (*os.File, *os.File, error) { return nil, nil, errBusctlInjected }
+ if _, err := Spawn(SpawnConfig{ClientID: "id"}); err == nil {
+ t.Fatal("pipe error expected")
+ }
+ spawnPipe = origPipe
+ if _, err := Spawn(SpawnConfig{ClientID: "id", ExecPath: filepath.Join(t.TempDir(), "missing")}); err == nil {
+ t.Fatal("start error expected")
+ }
+
+ for _, tc := range []struct {
+ name string
+ data []byte
+ want error
+ }{
+ {"ready", []byte{'R'}, nil},
+ {"failed", []byte{'E'}, ErrSpawnFailed},
+ {"unexpected", []byte{'X'}, errBusctlInjected},
+ {"eof", nil, ErrSpawnFailed},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tc.data) > 0 {
+ _, _ = pw.Write(tc.data)
+ }
+ _ = pw.Close()
+ err = waitReady(pr)
+ _ = pr.Close()
+ if tc.want == nil && err != nil {
+ t.Fatal(err)
+ }
+ if tc.want != nil && tc.want == ErrSpawnFailed && !errors.Is(err, tc.want) {
+ t.Fatalf("error = %v", err)
+ }
+ if tc.name == "unexpected" && err == nil {
+ t.Fatal("unexpected byte error expected")
+ }
+ })
+ }
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = pr.Close()
+ if err := waitReady(pr); err == nil || errors.Is(err, ErrSpawnFailed) {
+ t.Fatalf("closed read error = %v", err)
+ }
+ _ = pw.Close()
+ pr, pw, err = os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ReadyTimeout = time.Millisecond
+ if err := waitReady(pr); !errors.Is(err, ErrSpawnTimeout) {
+ t.Fatalf("timeout error = %v", err)
+ }
+ _ = pr.Close()
+ _ = pw.Close()
+}
+
+func TestStatusFilesystemEdges(t *testing.T) {
+ origReadDir := statusReadDir
+ origStat := statusStat
+ t.Cleanup(func() { statusReadDir = origReadDir; statusStat = origStat })
+ configDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(configDir, "events"), []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := EnumerateBuses(configDir, ""); err == nil {
+ t.Fatal("events scan error expected")
+ }
+
+ configDir = t.TempDir()
+ legacy := makeBusDir(t, configDir, "open", "legacy", true, 0)
+ _ = legacy
+ sourceDir := filepath.Join(configDir, "events", "open", string(dwsevent.SourceKindPersonalStream), "identity")
+ if err := os.MkdirAll(sourceDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := bus.WriteMeta(sourceDir, bus.Meta{
+ ClientID: "client", Edition: "open", SourceKind: dwsevent.SourceKindPersonalStream,
+ IdentityHash: "meta-identity",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := EnumerateBuses(configDir, "")
+ if err != nil || len(entries) != 2 {
+ t.Fatalf("entries = %#v, %v", entries, err)
+ }
+ var personal BusEntry
+ for _, entry := range entries {
+ _ = entry.IPCEndpoint()
+ if entry.SourceKind == dwsevent.SourceKindPersonalStream {
+ personal = entry
+ }
+ }
+ if personal.IdentityHash != "meta-identity" || personal.ClientIDHash != "meta-identity" {
+ t.Fatalf("personal meta = %#v", personal)
+ }
+ if got := FindBusByIdentity(configDir, "open", dwsevent.SourceKindPersonalStream, "identity"); got == nil {
+ t.Fatal("personal identity not found")
+ }
+ if got := FindBusByIdentity(configDir, "open", "", "missing"); got != nil {
+ t.Fatalf("missing identity = %#v", got)
+ }
+
+ root := filepath.Join(configDir, "events")
+ statusReadDir = func(path string) ([]os.DirEntry, error) {
+ if path != root {
+ return nil, errBusctlInjected
+ }
+ return origReadDir(path)
+ }
+ if got, err := EnumerateBuses(configDir, ""); err != nil || len(got) != 0 {
+ t.Fatalf("edition scan skip = %#v, %v", got, err)
+ }
+ statusReadDir = func(path string) ([]os.DirEntry, error) {
+ if filepath.Base(path) == string(dwsevent.SourceKindPersonalStream) {
+ return nil, errBusctlInjected
+ }
+ return origReadDir(path)
+ }
+ if _, err := EnumerateBuses(configDir, ""); err != nil {
+ t.Fatalf("identity scan should be skipped: %v", err)
+ }
+
+ statusStat = func(string) (os.FileInfo, error) { return nil, nil }
+ if got := FindBusByClientID(configDir, "open", "virtual"); got == nil {
+ t.Fatal("virtual primary path should be inspected")
+ }
+}
+
+type queryErrorConn struct {
+ writes int
+ failAt int
+}
+
+func (c *queryErrorConn) Read([]byte) (int, error) { return 0, errBusctlInjected }
+func (c *queryErrorConn) Write(p []byte) (int, error) {
+ c.writes++
+ if c.failAt > 0 && c.writes >= c.failAt {
+ return 0, errBusctlInjected
+ }
+ return len(p), nil
+}
+func (*queryErrorConn) Close() error { return nil }
+func (*queryErrorConn) LocalAddr() net.Addr { return dummyAddr("local") }
+func (*queryErrorConn) RemoteAddr() net.Addr { return dummyAddr("remote") }
+func (*queryErrorConn) SetDeadline(time.Time) error { return errBusctlInjected }
+func (*queryErrorConn) SetReadDeadline(time.Time) error { return nil }
+func (*queryErrorConn) SetWriteDeadline(time.Time) error { return nil }
+
+type dummyAddr string
+
+func (a dummyAddr) Network() string { return string(a) }
+func (a dummyAddr) String() string { return string(a) }
+
+func TestQueryStatusAndEntryEdges(t *testing.T) {
+ origDial := statusDial
+ origQuery := queryStatus
+ t.Cleanup(func() { statusDial = origDial; queryStatus = origQuery })
+ statusDial = func(string) (net.Conn, error) { return nil, errBusctlInjected }
+ if _, err := QueryStatus("x"); err == nil {
+ t.Fatal("dial error expected")
+ }
+ for failAt := 1; failAt <= 4; failAt++ {
+ statusDial = func(string) (net.Conn, error) { return &queryErrorConn{failAt: failAt}, nil }
+ _, _ = QueryStatus("x")
+ }
+ statusDial = func(string) (net.Conn, error) { return &queryErrorConn{}, nil }
+ if _, err := QueryStatus("x"); err == nil {
+ t.Fatal("read error expected")
+ }
+
+ entry := BusEntry{State: BusStateNotRunning}
+ if got := QueryEntry(entry); got.Live != nil {
+ t.Fatalf("not-running live = %#v", got.Live)
+ }
+ entry.State = BusStateRunning
+ queryStatus = func(string) (*transport.StatusResp, error) { return nil, errBusctlInjected }
+ if got := QueryEntry(entry); got.Live != nil {
+ t.Fatalf("failed live = %#v", got.Live)
+ }
+ want := &transport.StatusResp{Type: transport.FrameTypeStatusResp}
+ queryStatus = func(string) (*transport.StatusResp, error) { return want, nil }
+ if got := QueryEntry(entry); got.Live != want {
+ t.Fatalf("live = %#v", got.Live)
+ }
+}
+
+func TestStopInjectedEdges(t *testing.T) {
+ origRead := stopReadHolderPID
+ origAlive := stopAlive
+ origFind := stopFindProcess
+ origSignal := stopSignalProcess
+ t.Cleanup(func() {
+ stopReadHolderPID = origRead
+ stopAlive = origAlive
+ stopFindProcess = origFind
+ stopSignalProcess = origSignal
+ })
+ if err := Stop(StopConfig{}); err == nil {
+ t.Fatal("workdir validation expected")
+ }
+ stopReadHolderPID = func(string) int { return 123 }
+ stopAlive = func(int) bool { return true }
+ stopFindProcess = func(int) (*os.Process, error) { return nil, errBusctlInjected }
+ if err := Stop(StopConfig{WorkDir: "x"}); err == nil {
+ t.Fatal("find error expected")
+ }
+ proc, err := os.FindProcess(os.Getpid())
+ if err != nil {
+ t.Fatal(err)
+ }
+ stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
+ stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
+ if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
+ t.Fatalf("done signal = %v", err)
+ }
+ stopSignalProcess = func(*os.Process, os.Signal) error { return errBusctlInjected }
+ if err := Stop(StopConfig{WorkDir: "x"}); err == nil {
+ t.Fatal("signal error expected")
+ }
+ stopSignalProcess = func(*os.Process, os.Signal) error { return nil }
+ calls := 0
+ stopAlive = func(int) bool { calls++; return calls < 3 }
+ if err := Stop(StopConfig{WorkDir: "x", Timeout: time.Second}); err != nil {
+ t.Fatalf("poll exit = %v", err)
+ }
+ stopAlive = func(int) bool { return true }
+ if err := Stop(StopConfig{WorkDir: "x", Timeout: time.Millisecond}); err == nil {
+ t.Fatal("timeout expected")
+ }
+}
+
+var _ io.Reader = (*queryErrorConn)(nil)
diff --git a/internal/event/busctl/discover.go b/internal/event/busctl/discover.go
index b15b1342..d29cd75d 100644
--- a/internal/event/busctl/discover.go
+++ b/internal/event/busctl/discover.go
@@ -57,6 +57,12 @@ const (
defaultDialDeadline = 5 * time.Second
)
+var (
+ discoverDial = transport.Dial
+ discoverMkdirAll = os.MkdirAll
+ discoverSpawn = Spawn
+)
+
// Discover returns a connected net.Conn to the bus for cfg.ClientID. If the
// bus is not running, Discover forks a new one and waits for it to come up.
//
@@ -84,7 +90,7 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
return nil, errors.New("busctl: ClientID is required")
}
if cfg.Spawn == nil {
- cfg.Spawn = Spawn
+ cfg.Spawn = discoverSpawn
}
if cfg.DialBackoff == 0 {
cfg.DialBackoff = defaultDialBackoff
@@ -97,12 +103,12 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
}
// Step 1: try dial.
- if conn, err := transport.Dial(cfg.IPCEndpoint); err == nil {
+ if conn, err := discoverDial(cfg.IPCEndpoint); err == nil {
return conn, nil
}
// Step 2: ensure WorkDir, then spawn _bus.
- if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
+ if err := discoverMkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
}
_, spawnErr := cfg.Spawn(SpawnConfig{
@@ -124,7 +130,7 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
backoff := cfg.DialBackoff
var lastDialErr error
for time.Now().Before(deadline) {
- conn, err := transport.Dial(cfg.IPCEndpoint)
+ conn, err := discoverDial(cfg.IPCEndpoint)
if err == nil {
return conn, nil
}
diff --git a/internal/event/busctl/spawn.go b/internal/event/busctl/spawn.go
index 53d663b9..789935df 100644
--- a/internal/event/busctl/spawn.go
+++ b/internal/event/busctl/spawn.go
@@ -34,7 +34,12 @@ const ReadyFDEnv = "DWS_EVENT_BUS_READY_FD"
// 10s is generous — bus startup is local-only work (file I/O + socket bind),
// so 1s would normally suffice; the extra headroom covers cold-start
// keychain prompts and slow CI machines.
-const ReadyTimeout = 10 * time.Second
+var ReadyTimeout = 10 * time.Second
+
+var (
+ spawnExecutable = os.Executable
+ spawnPipe = os.Pipe
+)
// ErrSpawnFailed is returned when the child reports startup failure via
// the ready pipe ('E' byte). The child's exit error / log file holds the
@@ -87,7 +92,7 @@ func Spawn(cfg SpawnConfig) (pid int, err error) {
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
}
if cfg.ExecPath == "" {
- execPath, err := os.Executable()
+ execPath, err := spawnExecutable()
if err != nil {
return 0, fmt.Errorf("busctl: locate executable: %w", err)
}
@@ -97,7 +102,7 @@ func Spawn(cfg SpawnConfig) (pid int, err error) {
cfg.Env = os.Environ()
}
- pr, pw, err := os.Pipe()
+ pr, pw, err := spawnPipe()
if err != nil {
return 0, fmt.Errorf("busctl: pipe: %w", err)
}
diff --git a/internal/event/busctl/status.go b/internal/event/busctl/status.go
index 87c00175..6a6ec28c 100644
--- a/internal/event/busctl/status.go
+++ b/internal/event/busctl/status.go
@@ -15,6 +15,7 @@ package busctl
import (
"fmt"
+ "net"
"os"
"path/filepath"
"sort"
@@ -26,6 +27,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
+var (
+ statusReadDir = os.ReadDir
+ statusStat = os.Stat
+ statusDial = func(endpoint string) (net.Conn, error) { return transport.Dial(endpoint) }
+ queryStatus = QueryStatus
+)
+
// BusEntryState classifies a discovered bus directory's runtime state.
// Used by `dws event status/list` to render the table and by
// --fail-on-orphan to drive exit code.
@@ -136,9 +144,9 @@ func EnumerateBuses(configDir string, editionFilter string) ([]BusEntry, error)
// using event.ClientIDHash.
func FindBusByClientID(configDir, editionName, clientIDHash string) *BusEntry {
workDir := filepath.Join(configDir, "events", editionName, string(dwsevent.SourceKindAppStream), clientIDHash)
- if _, err := os.Stat(workDir); err != nil {
+ if _, err := statusStat(workDir); err != nil {
legacy := filepath.Join(configDir, "events", editionName, clientIDHash)
- if _, legacyErr := os.Stat(legacy); legacyErr != nil {
+ if _, legacyErr := statusStat(legacy); legacyErr != nil {
return nil
}
workDir = legacy
@@ -153,7 +161,7 @@ func FindBusByIdentity(configDir, editionName string, sourceKind dwsevent.Source
sourceKind = dwsevent.SourceKindAppStream
}
workDir := filepath.Join(configDir, "events", editionName, string(sourceKind), identityHash)
- if _, err := os.Stat(workDir); err != nil {
+ if _, err := statusStat(workDir); err != nil {
return nil
}
e := inspectEntry(workDir, editionName, string(sourceKind), identityHash)
@@ -164,7 +172,7 @@ func FindBusByIdentity(configDir, editionName string, sourceKind dwsevent.Source
// regular files. Returns the err from ReadDir unchanged (callers handle
// os.IsNotExist).
func listSubdirs(path string) ([]string, error) {
- ents, err := os.ReadDir(path)
+ ents, err := statusReadDir(path)
if err != nil {
return nil, err
}
@@ -238,7 +246,7 @@ const DefaultStatusRPCTimeout = 2 * time.Second
// without registering with the Hub — ad-hoc tooling does not count as
// a consumer in `status.active_consumers`.
func QueryStatus(endpoint string) (*transport.StatusResp, error) {
- conn, err := transport.Dial(endpoint)
+ conn, err := statusDial(endpoint)
if err != nil {
return nil, fmt.Errorf("busctl: dial bus for status: %w", err)
}
@@ -282,7 +290,7 @@ func QueryEntry(entry BusEntry) EntryStatus {
if entry.State != BusStateRunning {
return out
}
- live, err := QueryStatus(entry.IPCEndpoint())
+ live, err := queryStatus(entry.IPCEndpoint())
if err == nil {
out.Live = live
}
diff --git a/internal/event/busctl/stop.go b/internal/event/busctl/stop.go
index d2dd8608..8b9bbdb1 100644
--- a/internal/event/busctl/stop.go
+++ b/internal/event/busctl/stop.go
@@ -33,6 +33,13 @@ const DefaultStopTimeout = 5 * time.Second
// distinguish "nothing to stop" from "failed to stop".
var ErrNotRunning = errors.New("busctl: bus is not running")
+var (
+ stopReadHolderPID = bus.ReadHolderPID
+ stopAlive = process.Alive
+ stopFindProcess = os.FindProcess
+ stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
+)
+
// StopConfig identifies the target bus and tunes timing.
type StopConfig struct {
// WorkDir holds bus.lock; Stop reads the PID from there.
@@ -62,19 +69,19 @@ func Stop(cfg StopConfig) error {
cfg.Timeout = DefaultStopTimeout
}
- pid := bus.ReadHolderPID(LockPath(cfg.WorkDir))
+ pid := stopReadHolderPID(LockPath(cfg.WorkDir))
if pid <= 0 {
return ErrNotRunning
}
- if !process.Alive(pid) {
+ if !stopAlive(pid) {
return ErrNotRunning
}
- proc, err := os.FindProcess(pid)
+ proc, err := stopFindProcess(pid)
if err != nil {
return fmt.Errorf("busctl: find process %d: %w", pid, err)
}
- if err := proc.Signal(stopSignal()); err != nil {
+ if err := stopSignalProcess(proc, stopSignal()); err != nil {
// On many Unix platforms Signal returns "process already finished"
// when the bus has just exited on its own — treat that as success.
if errors.Is(err, os.ErrProcessDone) {
@@ -86,7 +93,7 @@ func Stop(cfg StopConfig) error {
// Poll for actual exit.
deadline := time.Now().Add(cfg.Timeout)
for time.Now().Before(deadline) {
- if !process.Alive(pid) {
+ if !stopAlive(pid) {
return nil
}
time.Sleep(50 * time.Millisecond)
diff --git a/internal/event/consume/coverage_edges_test.go b/internal/event/consume/coverage_edges_test.go
new file mode 100644
index 00000000..8ce25dcb
--- /dev/null
+++ b/internal/event/consume/coverage_edges_test.go
@@ -0,0 +1,273 @@
+package consume
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "os"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
+)
+
+type errorFormatter struct{ err error }
+
+func (f errorFormatter) Render(transport.Event) ([]byte, error) { return nil, f.err }
+
+type errorSink struct{ err error }
+
+func (s errorSink) Write(transport.Event, []byte) error { return s.err }
+func (s errorSink) Close() error { return s.err }
+
+func TestFormatterPipelineRouterAndSinkEdges(t *testing.T) {
+ wantErr := errors.New("synthetic failure")
+ oldMarshal, oldIndent, oldCompact := marshalEvent, marshalEventIndent, marshalCompact
+ oldMkdir, oldWrite, oldRename, oldRemove := mkdirSinkDir, writeSinkFile, renameSinkFile, removeSinkFile
+ t.Cleanup(func() {
+ marshalEvent, marshalEventIndent, marshalCompact = oldMarshal, oldIndent, oldCompact
+ mkdirSinkDir, writeSinkFile, renameSinkFile, removeSinkFile = oldMkdir, oldWrite, oldRename, oldRemove
+ })
+ marshalEvent = func(any) ([]byte, error) { return nil, wantErr }
+ if _, err := (ndjsonFormatter{}).Render(transport.Event{}); !errors.Is(err, wantErr) {
+ t.Fatalf("ndjson marshal error = %v", err)
+ }
+ marshalEventIndent = func(any, string, string) ([]byte, error) { return nil, wantErr }
+ if _, err := (prettyFormatter{}).Render(transport.Event{}); !errors.Is(err, wantErr) {
+ t.Fatalf("pretty marshal error = %v", err)
+ }
+ marshalCompact = func(any) ([]byte, error) { return nil, wantErr }
+ if _, err := (compactFormatter{}).Render(transport.Event{}); !errors.Is(err, wantErr) {
+ t.Fatalf("compact marshal error = %v", err)
+ }
+
+ pipeline := NewPipeline(errorFormatter{err: wantErr}, errorSink{})
+ if err := pipeline.Deliver(transport.Event{}); !errors.Is(err, wantErr) {
+ t.Fatalf("formatter pipeline error = %v", err)
+ }
+ pipeline = NewPipeline(rawFormatter{}, errorSink{err: wantErr})
+ if err := pipeline.Deliver(transport.Event{}); !errors.Is(err, wantErr) {
+ t.Fatalf("sink pipeline error = %v", err)
+ }
+ if err := pipeline.Close(); !errors.Is(err, wantErr) {
+ t.Fatalf("pipeline close error = %v", err)
+ }
+ if _, err := BuildPipeline(Format("invalid"), "", nil, nil); err == nil {
+ t.Fatal("invalid pipeline format should fail")
+ }
+ if p, err := BuildPipeline(FormatNDJSON, "", nil, nil); err != nil || p == nil {
+ t.Fatalf("nil stdout pipeline = %#v, %v", p, err)
+ }
+ if (*Router)(nil).Rules() != nil || (*Router)(nil).Match(transport.Event{}) != "" {
+ t.Fatal("nil router should have no rules or match")
+ }
+ if rules := NewRouter([]Route{}).Rules(); rules == nil {
+ t.Fatal("non-nil router should return its rule slice")
+ }
+ if err := ValidateNoOutputConflict(validRunConfig(), "captured.out"); err != nil {
+ t.Fatalf("global output without stream sink: %v", err)
+ }
+
+ if err := NewStdoutSink(errorWriter{err: syscall.EPIPE}).Write(transport.Event{}, []byte("x")); !errors.Is(err, ErrPipeClosed) {
+ t.Fatalf("broken pipe error = %v", err)
+ }
+ mkdirSinkDir = func(string, os.FileMode) error { return wantErr }
+ if err := NewFileDirSink("dir").Write(transport.Event{}, nil); !errors.Is(err, wantErr) {
+ t.Fatalf("sink mkdir error = %v", err)
+ }
+ mkdirSinkDir = oldMkdir
+ writeSinkFile = func(string, []byte, os.FileMode) error { return wantErr }
+ if err := atomicWrite("path", nil); !errors.Is(err, wantErr) {
+ t.Fatalf("atomic write error = %v", err)
+ }
+ writeSinkFile = func(string, []byte, os.FileMode) error { return nil }
+ renameSinkFile = func(string, string) error { return wantErr }
+ removeSinkFile = func(string) error { return nil }
+ if err := atomicWrite("path", nil); !errors.Is(err, wantErr) {
+ t.Fatalf("atomic rename error = %v", err)
+ }
+}
+
+type errorWriter struct{ err error }
+
+func (w errorWriter) Write([]byte) (int, error) { return 0, w.err }
+
+func validRunConfig() Config {
+ return Config{WorkDir: "work", IPCEndpoint: "endpoint", ClientID: "client", Stdout: io.Discard, Stderr: io.Discard, Format: FormatNDJSON}
+}
+
+func TestRunSetupAndDiscoveryEdges(t *testing.T) {
+ if err := Run(context.Background(), Config{}); err == nil {
+ t.Fatal("missing required run config should fail")
+ }
+ if err := Run(context.Background(), Config{WorkDir: "w", IPCEndpoint: "e", ClientID: "c", Quiet: true, DryRun: true}); err != nil {
+ t.Fatalf("defaulted quiet dry run: %v", err)
+ }
+ cfg := validRunConfig()
+ cfg.Format = Format("invalid")
+ if err := Run(context.Background(), cfg); err == nil {
+ t.Fatal("invalid format should fail before discovery")
+ }
+
+ oldDiscover := discoverBus
+ t.Cleanup(func() { discoverBus = oldDiscover })
+ wantErr := errors.New("discover failed")
+ discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) { return nil, wantErr }
+ if err := Run(context.Background(), validRunConfig()); !errors.Is(err, wantErr) {
+ t.Fatalf("discover error = %v", err)
+ }
+
+ discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) { return &faultConn{writeErr: wantErr}, nil }
+ if err := Run(context.Background(), validRunConfig()); !errors.Is(err, wantErr) {
+ t.Fatalf("hello write error = %v", err)
+ }
+
+ discoverBus = pipeDiscover(t, nil, true)
+ if err := Run(context.Background(), validRunConfig()); err == nil || !strings.Contains(err.Error(), "hello_ack") {
+ t.Fatalf("ack read error = %v", err)
+ }
+ discoverBus = pipeDiscover(t, [][]byte{mustJSONFrame(t, transport.HelloAck{Type: transport.FrameTypeHeartbeat})}, false)
+ if err := Run(context.Background(), validRunConfig()); err == nil || !strings.Contains(err.Error(), "unexpected") {
+ t.Fatalf("wrong ack error = %v", err)
+ }
+}
+
+func TestRunFrameAndDeliveryEdges(t *testing.T) {
+ oldDiscover := discoverBus
+ t.Cleanup(func() { discoverBus = oldDiscover })
+ ack := mustJSONFrame(t, transport.HelloAck{Type: transport.FrameTypeHelloAck, BusPID: 1})
+ frames := [][]byte{
+ ack,
+ []byte("{"),
+ []byte(`{"type":"event","event_id":{}}`),
+ mustJSONFrame(t, transport.SourceState{Type: transport.FrameTypeSourceState, State: "running"}),
+ mustJSONFrame(t, transport.Heartbeat{Type: transport.FrameTypeHeartbeat}),
+ []byte(`{"type":"future"}`),
+ mustJSONFrame(t, transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}),
+ }
+ discoverBus = pipeDiscover(t, frames, true)
+ cfg := validRunConfig()
+ cfg.Stderr = &bytes.Buffer{}
+ if err := Run(context.Background(), cfg); err != nil {
+ t.Fatalf("mixed frames run: %v", err)
+ }
+ discoverBus = pipeDiscover(t, [][]byte{ack}, true)
+ if err := Run(context.Background(), validRunConfig()); err != nil {
+ t.Fatalf("clean peer EOF run: %v", err)
+ }
+
+ event := mustJSONFrame(t, transport.Event{Type: transport.FrameTypeEvent, EventID: "id", EventType: "type", Data: `{}`})
+ for _, tc := range []struct {
+ name string
+ writer io.Writer
+ want error
+ }{
+ {"pipe closed", errorWriter{err: syscall.EPIPE}, nil},
+ {"delivery failure", errorWriter{err: errors.New("output failed")}, errors.New("want error")},
+ } {
+ discoverBus = pipeDiscover(t, [][]byte{ack, event}, true)
+ cfg = validRunConfig()
+ cfg.Stdout = tc.writer
+ err := Run(context.Background(), cfg)
+ if tc.want == nil && err != nil {
+ t.Errorf("%s run error = %v", tc.name, err)
+ }
+ if tc.want != nil && err == nil {
+ t.Errorf("%s run should fail", tc.name)
+ }
+ }
+
+ discoverBus = pipeDiscover(t, [][]byte{ack, event}, true)
+ cfg = validRunConfig()
+ cfg.MaxEvents = 1
+ if err := Run(context.Background(), cfg); err != nil {
+ t.Fatalf("max-events run: %v", err)
+ }
+
+ readErr := errors.New("read failed")
+ discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
+ return &faultConn{readData: append(append([]byte{}, ack...), '\n'), readErr: readErr}, nil
+ }
+ if err := Run(context.Background(), validRunConfig()); !errors.Is(err, readErr) {
+ t.Fatalf("frame read error = %v", err)
+ }
+
+ discoverBus = pipeDiscover(t, [][]byte{ack}, false)
+ cfg = validRunConfig()
+ cfg.Duration = 10 * time.Millisecond
+ if err := Run(context.Background(), cfg); err != nil {
+ t.Fatalf("duration cancellation run: %v", err)
+ }
+}
+
+func mustJSONFrame(t *testing.T, value any) []byte {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("marshal frame: %v", err)
+ }
+ return raw
+}
+
+func pipeDiscover(t *testing.T, frames [][]byte, closeAfter bool) func(busctl.DiscoverConfig) (net.Conn, error) {
+ t.Helper()
+ return func(busctl.DiscoverConfig) (net.Conn, error) {
+ client, server := net.Pipe()
+ go func() {
+ defer func() {
+ if closeAfter {
+ _ = server.Close()
+ }
+ }()
+ var hello transport.Hello
+ if err := transport.NewReader(server).ReadJSON(&hello); err != nil {
+ _ = server.Close()
+ return
+ }
+ for _, frame := range frames {
+ if _, err := server.Write(append(append([]byte{}, frame...), '\n')); err != nil {
+ return
+ }
+ }
+ }()
+ return client, nil
+ }
+}
+
+type faultConn struct {
+ readData []byte
+ readErr error
+ writeErr error
+}
+
+func (c *faultConn) Read(p []byte) (int, error) {
+ if len(c.readData) > 0 {
+ n := copy(p, c.readData)
+ c.readData = c.readData[n:]
+ return n, nil
+ }
+ return 0, c.readErr
+}
+func (c *faultConn) Write(p []byte) (int, error) {
+ if c.writeErr != nil {
+ return 0, c.writeErr
+ }
+ return len(p), nil
+}
+func (*faultConn) Close() error { return nil }
+func (*faultConn) LocalAddr() net.Addr { return consumeAddr("local") }
+func (*faultConn) RemoteAddr() net.Addr { return consumeAddr("remote") }
+func (*faultConn) SetDeadline(time.Time) error { return nil }
+func (*faultConn) SetReadDeadline(time.Time) error { return nil }
+func (*faultConn) SetWriteDeadline(time.Time) error { return nil }
+
+type consumeAddr string
+
+func (a consumeAddr) Network() string { return "test" }
+func (a consumeAddr) String() string { return string(a) }
diff --git a/internal/event/consume/formatter.go b/internal/event/consume/formatter.go
index d22b8716..9ef684e3 100644
--- a/internal/event/consume/formatter.go
+++ b/internal/event/consume/formatter.go
@@ -21,6 +21,12 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
+var (
+ marshalEvent = json.Marshal
+ marshalEventIndent = json.MarshalIndent
+ marshalCompact = json.Marshal
+)
+
// Format identifies the wire shape `dws event consume` writes per event.
// Values mirror dws's global -f/--format flag vocabulary (defined in
// internal/output) with the subset that makes sense for streaming.
@@ -108,7 +114,7 @@ func NewFormatter(format Format) (Formatter, error) {
type ndjsonFormatter struct{}
func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
- b, err := json.Marshal(ev)
+ b, err := marshalEvent(ev)
if err != nil {
return nil, err
}
@@ -121,7 +127,7 @@ func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) {
type prettyFormatter struct{}
func (prettyFormatter) Render(ev transport.Event) ([]byte, error) {
- b, err := json.MarshalIndent(ev, "", " ")
+ b, err := marshalEventIndent(ev, "", " ")
if err != nil {
return nil, err
}
@@ -149,7 +155,7 @@ type compactFormatter struct{}
func (compactFormatter) Render(ev transport.Event) ([]byte, error) {
p := registry.LookupProcessor(ev.EventType)
v := p(ev)
- b, err := json.Marshal(v)
+ b, err := marshalCompact(v)
if err != nil {
return nil, err
}
diff --git a/internal/event/consume/router.go b/internal/event/consume/router.go
index 5179e40a..61c4f5f5 100644
--- a/internal/event/consume/router.go
+++ b/internal/event/consume/router.go
@@ -66,9 +66,6 @@ func ParseRoute(spec string) (Route, error) {
if err != nil {
return Route{}, fmt.Errorf("consume: route regex %q: %w", pattern, err)
}
- if path == "" {
- return Route{}, fmt.Errorf("consume: route path is empty in %q", spec)
- }
return Route{Pattern: re, Dir: path, Raw: spec}, nil
}
diff --git a/internal/event/consume/run.go b/internal/event/consume/run.go
index fa5d049b..5ccf122d 100644
--- a/internal/event/consume/run.go
+++ b/internal/event/consume/run.go
@@ -118,6 +118,8 @@ type Config struct {
Quiet bool
}
+var discoverBus = busctl.Discover
+
// Run dials the bus (forking one if necessary), sends Hello, and writes
// each received Event frame as one NDJSON line to stdout. Blocks until
// ctx is cancelled, MaxEvents is reached, the bus sends Bye, or the
@@ -179,7 +181,7 @@ func Run(ctx context.Context, cfg Config) error {
}
defer pipeline.Close()
- conn, err := busctl.Discover(busctl.DiscoverConfig{
+ conn, err := discoverBus(busctl.DiscoverConfig{
WorkDir: cfg.WorkDir,
IPCEndpoint: cfg.IPCEndpoint,
ClientID: cfg.ClientID,
diff --git a/internal/event/consume/sink.go b/internal/event/consume/sink.go
index 1a9ae436..f513197a 100644
--- a/internal/event/consume/sink.go
+++ b/internal/event/consume/sink.go
@@ -45,6 +45,13 @@ type Sink interface {
// surfacing it as a fatal error.
var ErrPipeClosed = errors.New("sink: downstream pipe closed")
+var (
+ mkdirSinkDir = os.MkdirAll
+ writeSinkFile = os.WriteFile
+ renameSinkFile = os.Rename
+ removeSinkFile = os.Remove
+)
+
// NewStdoutSink wraps the given writer (typically os.Stdout) in a Sink
// that writes formatted bytes verbatim. Detects broken-pipe on the host
// platform and returns ErrPipeClosed so the caller can exit code 0.
@@ -75,7 +82,7 @@ func NewFileDirSink(dir string) Sink { return &fileDirSink{dir: dir} }
type fileDirSink struct{ dir string }
func (s *fileDirSink) Write(ev transport.Event, formatted []byte) error {
- if err := os.MkdirAll(s.dir, 0o700); err != nil {
+ if err := mkdirSinkDir(s.dir, 0o700); err != nil {
return fmt.Errorf("sink: mkdir %s: %w", s.dir, err)
}
name := buildFilename(ev)
@@ -159,11 +166,11 @@ func safePart(s string) string {
// half-written file.
func atomicWrite(path string, content []byte) error {
tmp := path + ".tmp"
- if err := os.WriteFile(tmp, content, 0o600); err != nil {
+ if err := writeSinkFile(tmp, content, 0o600); err != nil {
return fmt.Errorf("sink: write tmp %s: %w", tmp, err)
}
- if err := os.Rename(tmp, path); err != nil {
- _ = os.Remove(tmp)
+ if err := renameSinkFile(tmp, path); err != nil {
+ _ = removeSinkFile(tmp)
return fmt.Errorf("sink: rename to %s: %w", path, err)
}
return nil
diff --git a/internal/event/endpoint.go b/internal/event/endpoint.go
index c0d03747..a9dcec26 100644
--- a/internal/event/endpoint.go
+++ b/internal/event/endpoint.go
@@ -24,7 +24,11 @@ import (
// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the
// BSDs and 108 on Linux; the usable budget is one less.
func MaxUnixSocketPath() int {
- if runtime.GOOS == "linux" {
+ return maxUnixSocketPath(runtime.GOOS)
+}
+
+func maxUnixSocketPath(goos string) int {
+ if goos == "linux" {
return 107
}
return 103
@@ -46,14 +50,18 @@ func MaxUnixSocketPath() int {
// This is the single source of truth for endpoint derivation; the cobra
// layer and busctl must not re-implement the shape.
func IPCEndpoint(workDir, editionName string, sourceKind SourceKind, identityHash string) string {
+ return ipcEndpointForOS(runtime.GOOS, workDir, editionName, sourceKind, identityHash)
+}
+
+func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind, identityHash string) string {
if sourceKind == "" {
sourceKind = SourceKindAppStream
}
- if runtime.GOOS == "windows" {
+ if goos == "windows" {
return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash
}
sock := filepath.Join(workDir, "bus.sock")
- if len(sock) <= MaxUnixSocketPath() {
+ if len(sock) <= maxUnixSocketPath(goos) {
return sock
}
return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock")
diff --git a/internal/event/endpoint_test.go b/internal/event/endpoint_test.go
index 53b684fc..0d1d1f18 100644
--- a/internal/event/endpoint_test.go
+++ b/internal/event/endpoint_test.go
@@ -22,6 +22,24 @@ import (
"testing"
)
+func TestEndpointPlatformVariants(t *testing.T) {
+ if maxUnixSocketPath("linux") != 107 || maxUnixSocketPath("darwin") != 103 {
+ t.Fatal("Unix socket limits changed")
+ }
+ pipe := ipcEndpointForOS("windows", "ignored", "open", "", "hash")
+ if pipe != `\\.\pipe\dws-event-open-app_stream-hash` {
+ t.Fatalf("Windows pipe = %q", pipe)
+ }
+ short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash")
+ if short != filepath.Join("/tmp/events", "bus.sock") {
+ t.Fatalf("short Unix endpoint = %q", short)
+ }
+ long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash")
+ if !strings.Contains(long, "dws-evt-") {
+ t.Fatalf("long Unix endpoint = %q", long)
+ }
+}
+
func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) {
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
diff --git a/internal/event/lock/lock.go b/internal/event/lock/lock.go
index 97eb3bee..66ba9da7 100644
--- a/internal/event/lock/lock.go
+++ b/internal/event/lock/lock.go
@@ -32,6 +32,8 @@ type File struct {
f *os.File
}
+var acquireFileLock = lockFile
+
// TryAcquire opens path (creating it if absent, mode 0600) and attempts to
// take an exclusive non-blocking lock. Returns ErrBusy when the lock is held
// by another process; any other error wraps the underlying I/O failure.
@@ -43,7 +45,7 @@ func TryAcquire(path string) (*File, error) {
if err != nil {
return nil, fmt.Errorf("lock: open %s: %w", path, err)
}
- if err := lockFile(f); err != nil {
+ if err := acquireFileLock(f); err != nil {
_ = f.Close()
if isBusy(err) {
return nil, ErrBusy
diff --git a/internal/event/lock/lock_test.go b/internal/event/lock/lock_test.go
index a33b15ca..6cf00772 100644
--- a/internal/event/lock/lock_test.go
+++ b/internal/event/lock/lock_test.go
@@ -16,10 +16,28 @@ package lock
import (
"errors"
"io"
+ "os"
"path/filepath"
"testing"
)
+func TestTryAcquireErrorBranchesAndEmptyPath(t *testing.T) {
+ if lock, err := TryAcquire(filepath.Join(t.TempDir(), "missing", "lock")); err == nil || lock != nil {
+ t.Fatalf("TryAcquire missing parent = %#v, %v", lock, err)
+ }
+ var empty *File
+ if empty.Path() != "" || (&File{}).Path() != "" {
+ t.Fatal("empty lock path should be empty")
+ }
+
+ previous := acquireFileLock
+ t.Cleanup(func() { acquireFileLock = previous })
+ acquireFileLock = func(*os.File) error { return errors.New("unexpected lock failure") }
+ if lock, err := TryAcquire(filepath.Join(t.TempDir(), "lock")); err == nil || lock != nil || errors.Is(err, ErrBusy) {
+ t.Fatalf("TryAcquire injected failure = %#v, %v", lock, err)
+ }
+}
+
func TestTryAcquire_FirstCallerWins(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.lock")
l, err := TryAcquire(path)
diff --git a/internal/event/personal/client.go b/internal/event/personal/client.go
index f7228236..5f973f22 100644
--- a/internal/event/personal/client.go
+++ b/internal/event/personal/client.go
@@ -37,6 +37,8 @@ const (
subscriptionListMaxPageGuard = 10000
)
+var subscriptionListMaxPages = subscriptionListMaxPageGuard
+
type Identity struct {
AccessToken string `json:"-"`
LocalSubject string `json:"-"`
@@ -230,7 +232,7 @@ func (c *Client) ListSubscriptions(ctx context.Context, opts ListOptions) ([]Sub
q.Set("pageSize", fmt.Sprintf("%d", subscriptionListPageSize))
all := make([]Subscription, 0, subscriptionListPageSize)
seen := make(map[string]struct{}, subscriptionListPageSize)
- for pageNo := 1; pageNo <= subscriptionListMaxPageGuard; pageNo++ {
+ for pageNo := 1; pageNo <= subscriptionListMaxPages; pageNo++ {
q.Set("pageNo", fmt.Sprintf("%d", pageNo))
var result dwsSubListResult
if err := c.do(ctx, http.MethodGet, "/event/sublist", q, nil, &result); err != nil {
@@ -265,8 +267,8 @@ func (c *Client) ListSubscriptions(ctx context.Context, opts ListOptions) ([]Sub
if len(result.Items) < effectivePageSize {
break
}
- if pageNo == subscriptionListMaxPageGuard {
- return nil, fmt.Errorf("personal event: subscription pagination exceeded %d pages", subscriptionListMaxPageGuard)
+ if pageNo == subscriptionListMaxPages {
+ return nil, fmt.Errorf("personal event: subscription pagination exceeded %d pages", subscriptionListMaxPages)
}
}
@@ -462,35 +464,29 @@ func decodeResult(raw json.RawMessage, out any) error {
return nil
}
if sub, ok := out.(*Subscription); ok {
- if decoded, ok, err := decodeSubscriptionResult(raw); ok || err != nil {
- if err != nil {
- return err
- }
+ if decoded, ok := decodeSubscriptionResult(raw); ok {
*sub = decoded
return nil
}
}
- if err := json.Unmarshal(raw, out); err == nil {
- return nil
- }
return json.Unmarshal(raw, out)
}
-func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool, error) {
+func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool) {
var ids []string
if err := json.Unmarshal(raw, &ids); err == nil {
if len(ids) == 0 {
- return Subscription{}, true, nil
+ return Subscription{}, true
}
- return Subscription{SubscribeID: ids[0]}, true, nil
+ return Subscription{SubscribeID: ids[0]}, true
}
var item dwsSubscription
if err := json.Unmarshal(raw, &item); err == nil &&
(firstNonEmpty(item.SubID, item.SubscribeID) != "" ||
firstNonEmpty(item.EventKey, item.EventKeySnake) != "") {
- return item.toSubscription(), true, nil
+ return item.toSubscription(), true
}
- return Subscription{}, false, nil
+ return Subscription{}, false
}
func decodeAPIError(data []byte) *APIError {
@@ -571,9 +567,8 @@ func sanitizeLogPayload(data []byte) string {
var parsed any
if err := json.Unmarshal(data, &parsed); err == nil {
redacted := redactJSONValue(parsed)
- if s, err := marshalLogJSON(redacted); err == nil {
- return truncateLogPayload(s)
- }
+ s, _ := marshalLogJSON(redacted) // JSON-decoded values are always encodable.
+ return truncateLogPayload(s)
}
return truncateLogPayload(string(data))
}
diff --git a/internal/event/personal/coverage_edges_test.go b/internal/event/personal/coverage_edges_test.go
new file mode 100644
index 00000000..9cbafd2f
--- /dev/null
+++ b/internal/event/personal/coverage_edges_test.go
@@ -0,0 +1,450 @@
+// 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 personal
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
+)
+
+type personalRoundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f personalRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+type personalErrorReadCloser struct{}
+
+func (personalErrorReadCloser) Read([]byte) (int, error) { return 0, errors.New("read failed") }
+func (personalErrorReadCloser) Close() error { return nil }
+
+func personalHTTPClient(status int, body string) *http.Client {
+ return &http.Client{Transport: personalRoundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }, nil
+ })}
+}
+
+func TestPersonalClientEdgeCases(t *testing.T) {
+ if got := (Identity{}).Key(); !strings.HasPrefix(got, "unknown\x00") {
+ t.Fatalf("unknown identity key = %q", got)
+ }
+ var nilAPIError *APIError
+ for name, apiErr := range map[string]*APIError{
+ "nil": nilAPIError,
+ "code_msg": {Code: "C", Message: "message"},
+ "code": {Code: "C"},
+ "message": {Message: "message"},
+ } {
+ if name != "nil" && apiErr.Error() == "" {
+ t.Fatalf("%s APIError rendered empty", name)
+ }
+ if name == "nil" && apiErr.Error() != "" {
+ t.Fatalf("nil APIError = %q", apiErr.Error())
+ }
+ }
+ if got := NewClient("", Identity{}); got.BaseURL == "" || got.HTTPClient == nil {
+ t.Fatalf("NewClient fallback = %#v", got)
+ }
+
+ var nilClient *Client
+ if err := nilClient.do(t.Context(), http.MethodGet, "/x", nil, nil, nil); err == nil {
+ t.Fatal("nil client did not fail")
+ }
+ c := &Client{BaseURL: "http://example.test", Identity: Identity{}}
+ if err := c.do(t.Context(), http.MethodGet, "/x", nil, nil, nil); err == nil {
+ t.Fatal("missing access token did not fail")
+ }
+ c.Identity.AccessToken = "token"
+ if err := c.do(t.Context(), http.MethodPost, "/x", nil, make(chan int), nil); err == nil {
+ t.Fatal("unencodable body did not fail")
+ }
+ c.BaseURL = ":"
+ if err := c.do(t.Context(), http.MethodGet, "/x", nil, nil, nil); err == nil {
+ t.Fatal("invalid URL did not fail")
+ }
+ c.BaseURL = "http://example.test"
+ c.HTTPClient = &http.Client{Transport: personalRoundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("send failed")
+ })}
+ if err := c.do(t.Context(), http.MethodGet, "/x", nil, nil, nil); err == nil {
+ t.Fatal("transport error did not propagate")
+ }
+ c.HTTPClient = &http.Client{Transport: personalRoundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: personalErrorReadCloser{}, Header: make(http.Header)}, nil
+ })}
+ if err := c.do(t.Context(), http.MethodGet, "/x", nil, nil, nil); err == nil {
+ t.Fatal("response read error did not propagate")
+ }
+
+ oldDefaultClient := http.DefaultClient
+ http.DefaultClient = personalHTTPClient(http.StatusOK, "")
+ t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
+ c.HTTPClient = nil
+ if err := c.do(t.Context(), http.MethodGet, "/x", nil, nil, nil); err != nil {
+ t.Fatalf("default HTTP client: %v", err)
+ }
+
+ cases := []struct {
+ name string
+ status int
+ body string
+ out any
+ wantErr bool
+ }{
+ {"http envelope error", 400, `{"error":{"code":"BAD","message":"bad"},"requestId":"r1"}`, nil, true},
+ {"http legacy error", 400, `{"errorCode":"OLD","errorMsg":"bad"}`, nil, true},
+ {"http direct error", 400, `{"code":"DIRECT","message":"bad"}`, nil, true},
+ {"http plain error", 500, `plain`, nil, true},
+ {"empty", 204, ``, nil, false},
+ {"implicit envelope error", 200, `{"error":{"code":"BAD","message":"bad"}}`, nil, true},
+ {"false envelope error", 200, `{"success":false,"errorCode":"BAD","errorMsg":"bad"}`, nil, true},
+ {"false envelope", 200, `{"success":false}`, nil, true},
+ {"nil result", 200, `{"success":true}`, &Subscription{}, false},
+ {"ignored result", 200, `{"success":true,"result":{"subId":"s"}}`, nil, false},
+ {"decoded result", 200, `{"success":true,"result":{"subId":"s"}}`, &Subscription{}, false},
+ {"bad result", 200, `{"success":true,"result":"bad"}`, &dwsSubListResult{}, true},
+ {"ignored plain", 200, `{"plain":true}`, nil, false},
+ {"decoded plain", 200, `{"total":2}`, &dwsSubListResult{}, false},
+ {"bad plain", 200, `{`, &dwsSubListResult{}, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ c.HTTPClient = personalHTTPClient(tc.status, tc.body)
+ err := c.do(t.Context(), http.MethodPost, "/x", url.Values{"token": {"secret"}}, map[string]string{"name": "x"}, tc.out)
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("do() error = %v, wantErr %v", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestPersonalClientHelpersAndOperations(t *testing.T) {
+ trueValue := true
+ if (responseEnvelope{}).apiError() != nil {
+ t.Fatal("empty response envelope returned an error")
+ }
+ if got := (responseEnvelope{ErrorCode: "E"}).apiError(); got == nil || got.Code != "E" {
+ t.Fatalf("legacy envelope error = %#v", got)
+ }
+ if got := (responseEnvelope{Success: &trueValue, RequestID2: "r2"}).requestID(); got != "r2" {
+ t.Fatalf("request id = %q", got)
+ }
+ for _, raw := range []json.RawMessage{nil, json.RawMessage(`null`)} {
+ if err := decodeResult(raw, &Subscription{}); err != nil {
+ t.Fatalf("decodeResult(%s): %v", raw, err)
+ }
+ }
+ var sub Subscription
+ if err := decodeResult(json.RawMessage(`[]`), &sub); err != nil {
+ t.Fatal(err)
+ }
+ if err := decodeResult(json.RawMessage(`{"subscribe_id":"s","event_key":"e"}`), &sub); err != nil || sub.SubscribeID != "s" {
+ t.Fatalf("object subscription = %#v, %v", sub, err)
+ }
+ if err := decodeResult(json.RawMessage(`{"value":1}`), &map[string]any{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := decodeResult(json.RawMessage(`{`), &map[string]any{}); err == nil {
+ t.Fatal("invalid result decoded")
+ }
+ if got, ok := decodeSubscriptionResult(json.RawMessage(`{"other":true}`)); ok || got.SubscribeID != "" {
+ t.Fatalf("unexpected subscription result = %#v, %v", got, ok)
+ }
+
+ for _, raw := range [][]byte{
+ []byte(`{"error":{"code":"E"}}`),
+ []byte(`{"errorCode":"E2"}`),
+ []byte(`{"code":"E3"}`),
+ } {
+ if decodeAPIError(raw) == nil {
+ t.Fatalf("decodeAPIError(%s) = nil", raw)
+ }
+ }
+ if decodeAPIError([]byte(`{}`)) != nil || decodeAPIError([]byte(`{`)) != nil {
+ t.Fatal("non-error payload decoded as API error")
+ }
+ if responseRequestID([]byte(`{"request_id":"r"}`)) != "r" || responseRequestID([]byte(`{`)) != "" {
+ t.Fatal("response request ID decoding failed")
+ }
+ if withRequestDetails(nil, "GET", "/", 200, "") != nil {
+ t.Fatal("nil API error was changed")
+ }
+ detailed := withRequestDetails(&APIError{Details: map[string]any{}}, "GET", "/", 400, "")
+ if detailed.Details["http_status"] != 400 {
+ t.Fatalf("details = %#v", detailed.Details)
+ }
+ if sanitizeLogPayload(nil) != "" || sanitizeLogPayload([]byte(`not-json`)) != "not-json" {
+ t.Fatal("payload sanitization failed")
+ }
+ if _, err := marshalLogJSON(make(chan int)); err == nil {
+ t.Fatal("marshalLogJSON accepted channel")
+ }
+ if got := redactedQueryString(url.Values{"authorization": {"secret"}, "q": {"ok"}}); !strings.Contains(got, "%3Credacted%3E") {
+ t.Fatalf("redacted query = %q", got)
+ }
+ for raw, want := range map[string]string{"": "", "null": "", `"paused"`: "paused", "1": "active", "2": "paused", "3": "deleted", "4": "4", "true": "true"} {
+ if got := dwsStatusString(json.RawMessage(raw)); got != want {
+ t.Fatalf("status %q = %q, want %q", raw, got, want)
+ }
+ }
+
+ base := &Client{BaseURL: "http://example.test", Identity: Identity{AccessToken: "token", ClientID: " c ", SourceID: " s "}}
+ if _, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{}); err == nil {
+ t.Fatal("invalid create request accepted")
+ }
+ base.HTTPClient = personalHTTPClient(400, `{"error":{"code":"DUP","details":{"subscribe_id":"existing"}}}`)
+ created, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{EventKey: "e", RuleType: "r"})
+ if err != nil || created.SubscribeID != "existing" {
+ t.Fatalf("duplicate create = %#v, %v", created, err)
+ }
+ request := base.buildCreateRequest(CreateSubscriptionRequest{
+ EventKey: "e", RuleType: "r", Name: "n", RuleParam: map[string]any{"bad": make(chan int)},
+ Filter: true, IdempotencyKey: "i", TTLSeconds: 1,
+ })
+ if request.ExpiresAt == "" || request.Ext["name"] != "n" || request.FilterRule != "" {
+ t.Fatalf("create request = %#v", request)
+ }
+ if _, err := base.GetSubscription(t.Context(), " "); err == nil {
+ t.Fatal("empty get accepted")
+ }
+ base.HTTPClient = personalHTTPClient(200, `{"success":true,"result":{"items":[]}}`)
+ if _, err := base.GetSubscription(t.Context(), "missing"); err == nil {
+ t.Fatal("missing subscription did not fail")
+ }
+ base.HTTPClient = &http.Client{Transport: personalRoundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("list failed")
+ })}
+ if _, err := base.GetSubscription(t.Context(), "id"); err == nil {
+ t.Fatal("list error did not propagate through get")
+ }
+ if err := base.DeleteSubscription(t.Context(), " "); err == nil {
+ t.Fatal("empty delete accepted")
+ }
+}
+
+func TestPersonalPaginationFiltersAndGuard(t *testing.T) {
+ client := &Client{BaseURL: "http://example.test", Identity: Identity{AccessToken: "token"}}
+ items := `[{"subId":"id1","status":2,"eventKey":"target"},{"subId":"id2","status":1,"eventKey":"other"},{"subId":"idx","status":1,"eventKey":"target"},{"subId":"id3","status":1,"eventKey":"target"}]`
+ client.HTTPClient = personalHTTPClient(200, `{"success":true,"result":{"items":`+items+`}}`)
+ got, err := client.ListSubscriptions(t.Context(), ListOptions{Status: "active", EventKey: "target", SubscribeID: "id3"})
+ if err != nil || len(got) != 1 || got[0].SubscribeID != "id3" {
+ t.Fatalf("filtered list = %#v, %v", got, err)
+ }
+
+ oldMax := subscriptionListMaxPages
+ subscriptionListMaxPages = 1
+ t.Cleanup(func() { subscriptionListMaxPages = oldMax })
+ client.HTTPClient = personalHTTPClient(200, `{"success":true,"result":{"total":2,"pageSize":1,"items":[{"eventKey":"target"}]}}`)
+ if _, err := client.ListSubscriptions(t.Context(), ListOptions{}); err == nil || !strings.Contains(err.Error(), "exceeded 1 pages") {
+ t.Fatalf("pagination guard error = %v", err)
+ }
+}
+
+func TestPersonalRegistryEdgeCases(t *testing.T) {
+ if got := (&SchemaPendingError{EventKey: "pending"}).Error(); !strings.Contains(got, "pending") {
+ t.Fatalf("pending error = %q", got)
+ }
+ if got := PublicAvailabilityError("private"); got == nil || !strings.Contains(got.Error(), "private") {
+ t.Fatalf("availability error = %v", got)
+ }
+ if _, _, err := BuildRuleParam("unknown", RuleOptions{}); err == nil {
+ t.Fatal("unknown event accepted")
+ }
+ if _, _, err := BuildRuleParam(EventMention, RuleOptions{RuleType: "group"}); err == nil {
+ t.Fatal("wrong rule accepted")
+ }
+ if _, _, err := BuildRuleParam(EventMention, RuleOptions{GroupID: "g"}); err == nil {
+ t.Fatal("mention group accepted")
+ }
+ if _, _, err := BuildRuleParam(EventFromUser, RuleOptions{UserID: "u", GroupID: "g"}); err == nil {
+ t.Fatal("sender group accepted")
+ }
+ if _, _, err := BuildFilter(`{`, ""); err == nil {
+ t.Fatal("invalid filter accepted")
+ }
+ if value, canonical, err := BuildFilter("", ""); err != nil || value != nil || canonical != "" {
+ t.Fatalf("empty filter = %#v, %q, %v", value, canonical, err)
+ }
+ if value, canonical, err := BuildFilter("", "one"); err != nil || value == nil || canonical == "" {
+ t.Fatalf("query filter = %#v, %q, %v", value, canonical, err)
+ }
+ if value, canonical, err := BuildFilter(`{"field":"content"}`, ""); err != nil || value == nil || canonical == "" {
+ t.Fatalf("JSON filter = %#v, %q, %v", value, canonical, err)
+ }
+ if got, err := CanonicalJSON(nil); err != nil || got != "" {
+ t.Fatalf("CanonicalJSON(nil) = %q, %v", got, err)
+ }
+ if _, err := CanonicalJSON(make(chan int)); err == nil {
+ t.Fatal("CanonicalJSON accepted channel")
+ }
+ normalized := normalizeFilterAliases(map[string]any{
+ "field": 12,
+ "nested": []any{map[string]any{"field": "unmapped"}, "value"},
+ }).(map[string]any)
+ if normalized["field"] != 12 {
+ t.Fatalf("normalized filter = %#v", normalized)
+ }
+ if IsSchemaPending(errors.New("other")) || !IsSchemaPending(&SchemaPendingError{EventKey: "x"}) {
+ t.Fatal("schema pending detection failed")
+ }
+
+ original := append([]Definition(nil), definitions...)
+ definitions = append(definitions,
+ Definition{EventKey: "pending", Category: "other", RuleType: "at", Status: StatusPending, Public: true},
+ Definition{EventKey: "unknown-rule", Category: "im", RuleType: "future", Status: StatusEnabled, Public: true},
+ )
+ t.Cleanup(func() { definitions = original })
+ if got := Catalog("im", true, false); len(got) == 0 {
+ t.Fatal("enabled catalog is empty")
+ }
+ if got := Catalog("other", true, true); len(got) != 0 {
+ t.Fatalf("enabled pending catalog = %#v", got)
+ }
+ if got := Catalog("other", false, false); len(got) != 0 {
+ t.Fatalf("excluded pending catalog = %#v", got)
+ }
+ if got := Catalog("other", false, true); len(got) != 1 {
+ t.Fatalf("included pending catalog = %#v", got)
+ }
+ if _, _, err := BuildRuleParam("pending", RuleOptions{}); !IsSchemaPending(err) {
+ t.Fatalf("pending rule error = %v", err)
+ }
+ if _, _, err := BuildRuleParam("unknown-rule", RuleOptions{}); !IsSchemaPending(err) {
+ t.Fatalf("future rule error = %v", err)
+ }
+}
+
+func TestRunStateEdgeCases(t *testing.T) {
+ workDir := t.TempDir()
+ if err := UpsertRunState(workDir, RunState{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := RemoveRunStates(workDir, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpsertRunState(workDir, RunState{SubscribeID: "b"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpsertRunState(workDir, RunState{SubscribeID: "b", EventKey: "updated", CreatedAt: time.Now()}); err != nil {
+ t.Fatal(err)
+ }
+ if err := RemoveRunStates(workDir, []string{"", "missing"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(workDir, StateFileName), []byte(`{`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadRunStates(workDir); err == nil {
+ t.Fatal("invalid state JSON decoded")
+ }
+ if err := UpsertRunState(workDir, RunState{SubscribeID: "x"}); err == nil {
+ t.Fatal("upsert ignored load error")
+ }
+ if err := RemoveRunStates(workDir, []string{"x"}); err == nil {
+ t.Fatal("remove ignored load error")
+ }
+}
+
+func TestRunStateInjectedFailures(t *testing.T) {
+ originalRead := runStateReadFile
+ originalMkdir := runStateMkdirAll
+ originalAcquire := runStateTryAcquire
+ originalRemove := runStateRemove
+ originalMarshal := runStateMarshalIndent
+ originalWrite := runStateWriteFile
+ originalRename := runStateRename
+ originalSleep := runStateSleep
+ t.Cleanup(func() {
+ runStateReadFile = originalRead
+ runStateMkdirAll = originalMkdir
+ runStateTryAcquire = originalAcquire
+ runStateRemove = originalRemove
+ runStateMarshalIndent = originalMarshal
+ runStateWriteFile = originalWrite
+ runStateRename = originalRename
+ runStateSleep = originalSleep
+ })
+ failure := errors.New("injected failure")
+ workDir := t.TempDir()
+
+ runStateReadFile = func(string) ([]byte, error) { return nil, failure }
+ if _, err := LoadRunStates(workDir); !errors.Is(err, failure) {
+ t.Fatalf("read error = %v", err)
+ }
+ runStateReadFile = originalRead
+ runStateMkdirAll = func(string, os.FileMode) error { return failure }
+ if err := withRunStateLock(workDir, time.Second, func() error { return nil }); !errors.Is(err, failure) {
+ t.Fatalf("lock mkdir error = %v", err)
+ }
+ if err := writeRunStates(workDir, []RunState{{SubscribeID: "x"}}); !errors.Is(err, failure) {
+ t.Fatalf("write mkdir error = %v", err)
+ }
+ runStateMkdirAll = originalMkdir
+ runStateTryAcquire = func(string) (*eventlock.File, error) { return nil, failure }
+ if err := withRunStateLock(workDir, time.Second, func() error { return nil }); !errors.Is(err, failure) {
+ t.Fatalf("lock acquire error = %v", err)
+ }
+ runStateTryAcquire = originalAcquire
+
+ runStateRemove = func(string) error { return failure }
+ if err := writeRunStates(workDir, nil); !errors.Is(err, failure) {
+ t.Fatalf("remove error = %v", err)
+ }
+ runStateRemove = originalRemove
+ if err := writeRunStates(workDir, nil); err != nil {
+ t.Fatalf("remove empty state file: %v", err)
+ }
+ runStateMarshalIndent = func(any, string, string) ([]byte, error) { return nil, failure }
+ if err := writeRunStates(workDir, []RunState{{SubscribeID: "x"}}); !errors.Is(err, failure) {
+ t.Fatalf("marshal error = %v", err)
+ }
+ runStateMarshalIndent = originalMarshal
+ runStateWriteFile = func(string, []byte, os.FileMode) error { return failure }
+ if err := writeRunStates(workDir, []RunState{{SubscribeID: "x"}}); !errors.Is(err, failure) {
+ t.Fatalf("write error = %v", err)
+ }
+ runStateWriteFile = originalWrite
+ runStateRename = func(string, string) error { return failure }
+ if err := writeRunStates(workDir, []RunState{{SubscribeID: "x"}}); !errors.Is(err, failure) {
+ t.Fatalf("rename error = %v", err)
+ }
+ runStateRename = originalRename
+
+ attempts := 0
+ runStateTryAcquire = func(path string) (*eventlock.File, error) {
+ attempts++
+ if attempts == 1 {
+ return nil, eventlock.ErrBusy
+ }
+ return originalAcquire(path)
+ }
+ runStateSleep = func(time.Duration) {}
+ if err := withRunStateLock(workDir, time.Millisecond, func() error { return nil }); err != nil {
+ t.Fatalf("busy retry error = %v", err)
+ }
+}
diff --git a/internal/event/personal/state.go b/internal/event/personal/state.go
index fa74fc07..0b97c52d 100644
--- a/internal/event/personal/state.go
+++ b/internal/event/personal/state.go
@@ -33,6 +33,17 @@ const (
stateLockRetryDelay = 25 * time.Millisecond
)
+var (
+ runStateReadFile = os.ReadFile
+ runStateMkdirAll = os.MkdirAll
+ runStateTryAcquire = eventlock.TryAcquire
+ runStateRemove = os.Remove
+ runStateMarshalIndent = json.MarshalIndent
+ runStateWriteFile = os.WriteFile
+ runStateRename = os.Rename
+ runStateSleep = time.Sleep
+)
+
type RunState struct {
SubscribeID string `json:"subscribe_id"`
EventKey string `json:"event_key,omitempty"`
@@ -45,7 +56,7 @@ type RunState struct {
func LoadRunStates(workDir string) ([]RunState, error) {
path := filepath.Join(workDir, StateFileName)
- b, err := os.ReadFile(path)
+ b, err := runStateReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
@@ -115,13 +126,13 @@ func RemoveRunStates(workDir string, subscribeIDs []string) error {
}
func withRunStateLock(workDir string, wait time.Duration, fn func() error) error {
- if err := os.MkdirAll(workDir, config.DirPerm); err != nil {
+ if err := runStateMkdirAll(workDir, config.DirPerm); err != nil {
return err
}
lockPath := filepath.Join(workDir, StateLockFileName)
deadline := time.Now().Add(wait)
for {
- held, err := eventlock.TryAcquire(lockPath)
+ held, err := runStateTryAcquire(lockPath)
if err == nil {
defer held.Close()
return fn()
@@ -137,12 +148,12 @@ func withRunStateLock(workDir string, wait time.Duration, fn func() error) error
if remaining < delay {
delay = remaining
}
- time.Sleep(delay)
+ runStateSleep(delay)
}
}
func writeRunStates(workDir string, states []RunState) error {
- if err := os.MkdirAll(workDir, config.DirPerm); err != nil {
+ if err := runStateMkdirAll(workDir, config.DirPerm); err != nil {
return err
}
sort.Slice(states, func(i, j int) bool {
@@ -150,22 +161,22 @@ func writeRunStates(workDir string, states []RunState) error {
})
path := filepath.Join(workDir, StateFileName)
if len(states) == 0 {
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ if err := runStateRemove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
- b, err := json.MarshalIndent(states, "", " ")
+ b, err := runStateMarshalIndent(states, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
tmp := path + ".tmp"
- if err := os.WriteFile(tmp, b, config.FilePerm); err != nil {
+ if err := runStateWriteFile(tmp, b, config.FilePerm); err != nil {
return err
}
- if err := os.Rename(tmp, path); err != nil {
- _ = os.Remove(tmp)
+ if err := runStateRename(tmp, path); err != nil {
+ _ = runStateRemove(tmp)
return err
}
return nil
diff --git a/internal/event/process/process_alive_unix.go b/internal/event/process/process_alive_unix.go
index c42526e3..26b1fd89 100644
--- a/internal/event/process/process_alive_unix.go
+++ b/internal/event/process/process_alive_unix.go
@@ -20,6 +20,8 @@ import (
"syscall"
)
+var killProcess = syscall.Kill
+
// Alive reports whether a process with the given PID is currently running.
// Returns false for non-positive PIDs without performing any syscall.
//
@@ -36,7 +38,7 @@ func Alive(pid int) bool {
if pid <= 0 {
return false
}
- err := syscall.Kill(pid, 0)
+ err := killProcess(pid, 0)
if err == nil {
return true
}
diff --git a/internal/event/process/process_alive_unix_test.go b/internal/event/process/process_alive_unix_test.go
new file mode 100644
index 00000000..36dbf638
--- /dev/null
+++ b/internal/event/process/process_alive_unix_test.go
@@ -0,0 +1,23 @@
+//go:build !windows
+
+package process
+
+import (
+ "errors"
+ "syscall"
+ "testing"
+)
+
+func TestAliveUnixErrorClassification(t *testing.T) {
+ previous := killProcess
+ t.Cleanup(func() { killProcess = previous })
+ for _, tc := range []struct {
+ err error
+ want bool
+ }{{nil, true}, {syscall.EPERM, true}, {syscall.ESRCH, false}, {errors.New("ambiguous"), true}} {
+ killProcess = func(int, syscall.Signal) error { return tc.err }
+ if got := Alive(42); got != tc.want {
+ t.Errorf("Alive with %v = %v, want %v", tc.err, got, tc.want)
+ }
+ }
+}
diff --git a/internal/event/registry/compact_processors_test.go b/internal/event/registry/compact_processors_test.go
index d40c919a..ec8fc5a8 100644
--- a/internal/event/registry/compact_processors_test.go
+++ b/internal/event/registry/compact_processors_test.go
@@ -103,6 +103,14 @@ func TestLookupProcessor_FallsBackToGeneric(t *testing.T) {
}
}
+func TestLookupProcessorReturnsRegisteredProcessor(t *testing.T) {
+ p := LookupProcessor("im.message.read_v1")
+ view := p(transport.Event{EventType: "im.message.read_v1", Data: `{"reader":{"open_id":"ou"}}`})
+ if view.Extra["open_id"] != "ou" {
+ t.Fatalf("registered processor output = %#v", view)
+ }
+}
+
func TestCompactIMMessage_LiftsNestedFields(t *testing.T) {
// Simulate the nested SDK shape: {"message": {...}, "sender": {...}}.
ev := transport.Event{
@@ -226,6 +234,25 @@ func TestCompactContactUser(t *testing.T) {
}
}
+func TestRemainingSpecializedProcessors(t *testing.T) {
+ read := mustJSON(t, compactIMMessageRead(transport.Event{Data: `{"reader":{"open_id":"ou","read_time":1},"context":{"open_message_ids":["m1"]}}`}))
+ if read["open_id"] != "ou" || read["open_message_ids"] == nil {
+ t.Errorf("message read fields missing: %#v", read)
+ }
+ task := mustJSON(t, compactApprovalTask(transport.Event{Data: `{"task":{"task_id":"t1","status":"pending"},"assignee":{"userid":"u1"}}`}))
+ if task["task_id"] != "t1" || task["assignee_userid"] != "u1" {
+ t.Errorf("approval task fields missing: %#v", task)
+ }
+ contact := mustJSON(t, compactContactUser(transport.Event{Data: `{"open_ids":["ou-old"]}`}))
+ if contact["open_id"] != "ou-old" {
+ t.Errorf("legacy contact fields missing: %#v", contact)
+ }
+ attendance := mustJSON(t, compactAttendanceCheck(transport.Event{Data: `{"punch":{"userid":"u1","check_time":1,"check_type":"on"}}`}))
+ if attendance["userid"] != "u1" || attendance["check_type"] != "on" {
+ t.Errorf("attendance fields missing: %#v", attendance)
+ }
+}
+
func TestCompactCalendarEvent(t *testing.T) {
ev := transport.Event{
EventType: "cal.event.created_v1",
diff --git a/internal/event/source/coverage_edges_test.go b/internal/event/source/coverage_edges_test.go
new file mode 100644
index 00000000..87ddf255
--- /dev/null
+++ b/internal/event/source/coverage_edges_test.go
@@ -0,0 +1,568 @@
+package source
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
+ "github.com/gorilla/websocket"
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/handler"
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
+)
+
+var errSourceInjected = errors.New("injected source failure")
+
+type fakeStreamClient struct {
+ handler handler.IFrameHandler
+ startErr error
+ closed bool
+}
+
+func (c *fakeStreamClient) RegisterAllEventRouter(h handler.IFrameHandler) { c.handler = h }
+func (c *fakeStreamClient) Start(context.Context) error { return c.startErr }
+func (c *fakeStreamClient) Close() { c.closed = true }
+
+func TestDingtalkStartEdges(t *testing.T) {
+ s, err := New(Config{ClientID: "id", ClientSecret: "secret"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.cli = &fakeStreamClient{}
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("second Start error expected")
+ }
+}
+
+func TestDingtalkStartInjected(t *testing.T) {
+ origNew := newStreamClient
+ t.Cleanup(func() { newStreamClient = origNew })
+ _ = origNew()
+ failing := &fakeStreamClient{startErr: errSourceInjected}
+ newStreamClient = func(_ ...client.ClientOption) streamClient { return failing }
+ s, _ := New(Config{ClientID: "id", ClientSecret: "secret"})
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); !errors.Is(err, errSourceInjected) {
+ t.Fatalf("Start failure = %v", err)
+ }
+ if s.State().State != StateStopped || failing.handler == nil {
+ t.Fatal("failed start state/handler")
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ success := &fakeStreamClient{}
+ newStreamClient = func(_ ...client.ClientOption) streamClient { return success }
+ s, _ = New(Config{ClientID: "id", ClientSecret: "secret"})
+ if err := s.Start(ctx, func(*dwsevent.RawEvent) {}); !errors.Is(err, context.Canceled) {
+ t.Fatalf("Start cancel = %v", err)
+ }
+ if !success.closed {
+ t.Fatal("stream client not closed")
+ }
+}
+
+func staticPersonalTicketClient(endpoint, body string) *http.Client {
+ return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ if body == "" {
+ body = `{"endpoint":` + strconvQuote(endpoint) + `,"ticket":"t"}`
+ }
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
+ })}
+}
+
+func TestPersonalRunAttemptAndFrameFailures(t *testing.T) {
+ base := PersonalConfig{
+ AccessToken: "token", ClientID: "id", SourceID: "source", TicketURL: "https://ticket",
+ ReconnectMin: time.Millisecond, ReconnectMax: time.Millisecond,
+ }
+ base.HTTPClient = staticPersonalTicketClient("http://bad", "")
+ s, _ := NewPersonal(base)
+ if _, err := s.runAttempt(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("endpoint error expected")
+ }
+ base.HTTPClient = staticPersonalTicketClient("ws://127.0.0.1:1", "")
+ s, _ = NewPersonal(base)
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if _, err := s.runAttempt(ctx, func(*dwsevent.RawEvent) {}); !isRetryablePersonalError(err) {
+ t.Fatalf("dial error = %v", err)
+ }
+ base.HTTPClient = staticPersonalTicketClient("wss://x", `{`)
+ s, _ = NewPersonal(base)
+ if _, err := s.fetchTicket(context.Background()); err == nil {
+ t.Fatal("ticket decode error expected")
+ }
+
+ if err := s.handleFrame(nil, []byte("invalid"), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("dataframe decode error expected")
+ }
+ upgrader := websocket.Upgrader{}
+ serverConn := make(chan *websocket.Conn, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err == nil {
+ serverConn <- conn
+ }
+ }))
+ defer srv.Close()
+ clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ peer := <-serverConn
+ _ = clientConn.UnderlyingConn().Close()
+ dfRaw, _ := json.Marshal(payload.DataFrame{Type: "event", Data: `{}`})
+ if err := s.handleFrame(clientConn, dfRaw, func(*dwsevent.RawEvent) {}); !isRetryablePersonalError(err) {
+ t.Fatalf("ack write error = %v", err)
+ }
+ _ = peer.Close()
+
+ wsEndpoint := ""
+ mux := http.NewServeMux()
+ mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"endpoint":`+strconvQuote(wsEndpoint)+`,"ticket":"t"}`)
+ })
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ _ = conn.WriteMessage(websocket.BinaryMessage, []byte("ignored"))
+ })
+ wsSrv := httptest.NewServer(mux)
+ defer wsSrv.Close()
+ wsEndpoint = "ws" + strings.TrimPrefix(wsSrv.URL, "http") + "/ws"
+ base.HTTPClient = wsSrv.Client()
+ base.TicketURL = wsSrv.URL + "/ticket"
+ s, _ = NewPersonal(base)
+ if _, err := s.runAttempt(context.Background(), func(*dwsevent.RawEvent) {}); !isRetryablePersonalError(err) {
+ t.Fatalf("binary/read error = %v", err)
+ }
+ textSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ _ = conn.WriteMessage(websocket.TextMessage, []byte("invalid"))
+ }))
+ defer textSrv.Close()
+ base.HTTPClient = staticPersonalTicketClient("ws"+strings.TrimPrefix(textSrv.URL, "http"), "")
+ base.TicketURL = "https://ticket"
+ s, _ = NewPersonal(base)
+ if _, err := s.runAttempt(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("text dataframe error expected")
+ }
+}
+
+func TestNewAndPersonalValidationEdges(t *testing.T) {
+ invalidPortal := &PortalTicketConfig{}
+ if _, err := New(Config{PortalTicket: invalidPortal}); err == nil {
+ t.Fatal("invalid portal expected")
+ }
+ validCustom := &PortalTicketConfig{
+ TicketURL: "https://ticket", AccessToken: "token", SourceID: "source",
+ Mode: PortalTicketModeCustom, ClientID: "portal-id", ClientSecret: "portal-secret",
+ }
+ if _, err := New(Config{PortalTicket: validCustom}); err == nil {
+ t.Fatal("custom ClientID expected")
+ }
+
+ base := PersonalConfig{AccessToken: "token", ClientID: "id", SourceID: "source", TicketURL: "https://ticket"}
+ for name, mutate := range map[string]func(*PersonalConfig){
+ "token": func(c *PersonalConfig) { c.AccessToken = "" },
+ "client": func(c *PersonalConfig) { c.ClientID = "" },
+ "source": func(c *PersonalConfig) { c.SourceID = "" },
+ "ticket": func(c *PersonalConfig) { c.TicketURL = "" },
+ "custom-secret": func(c *PersonalConfig) { c.TicketMode = "custom" },
+ } {
+ cfg := base
+ mutate(&cfg)
+ if _, err := NewPersonal(cfg); err == nil {
+ t.Fatalf("%s validation expected", name)
+ }
+ }
+ base.ReconnectMin = 5 * time.Second
+ base.ReconnectMax = time.Second
+ s, err := NewPersonal(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.cfg.HTTPClient == nil || s.cfg.WebSocketDialer == nil || s.cfg.Now == nil || s.cfg.ReconnectMax != s.cfg.ReconnectMin {
+ t.Fatalf("personal defaults = %#v", s.cfg)
+ }
+ if err := s.Start(context.Background(), nil); err == nil {
+ t.Fatal("nil emit expected")
+ }
+ s.started.Store(true)
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("second Start expected")
+ }
+ re := retryPersonal(errSourceInjected)
+ if !errors.Is(re, errSourceInjected) || re.(*retryablePersonalError).Unwrap() != errSourceInjected {
+ t.Fatal("retryable error contract")
+ }
+ if retryPersonal(nil) != nil {
+ t.Fatal("retryPersonal(nil)")
+ }
+}
+
+type errorReadCloser struct{}
+
+func (errorReadCloser) Read([]byte) (int, error) { return 0, errSourceInjected }
+func (errorReadCloser) Close() error { return nil }
+
+func TestPersonalTicketAndHelperEdges(t *testing.T) {
+ clientWith := func(fn func(*http.Request) (*http.Response, error)) *http.Client {
+ return &http.Client{Transport: roundTripFunc(fn)}
+ }
+ base := PersonalConfig{
+ AccessToken: "token", ClientID: "id", ClientSecret: "secret", SourceID: "source",
+ TicketURL: "https://ticket", TicketMode: "custom",
+ }
+ base.HTTPClient = clientWith(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 200, Body: errorReadCloser{}}, nil
+ })
+ s, _ := NewPersonal(base)
+ if _, err := s.fetchTicket(context.Background()); !isRetryablePersonalError(err) {
+ t.Fatalf("read error = %v", err)
+ }
+ base.TicketURL = ":"
+ base.HTTPClient = http.DefaultClient
+ s, _ = NewPersonal(base)
+ if _, err := s.fetchTicket(context.Background()); err == nil {
+ t.Fatal("request creation error expected")
+ }
+
+ for _, tc := range []struct {
+ data string
+ wantErr bool
+ }{
+ {`{"result":{"endpoint":"wss://x","ticket":"t"}}`, false},
+ {`{"result":null,"data":{"endpoint":"wss://x","ticket":"t"}}`, false},
+ {`{"result":"bad"}`, true},
+ {`{"endpoint":"wss://x","ticket":"t"}`, false},
+ {`{`, true},
+ } {
+ _, err := decodeTicket([]byte(tc.data))
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("decodeTicket(%q) = %v", tc.data, err)
+ }
+ }
+ for _, endpoint := range []string{"%", "http://host", "ws://", "ws://host/path?ticket=old", "wss://host/path"} {
+ _, _ = endpointWithTicket(endpoint, "new")
+ }
+ for _, message := range []string{
+ "ticket HTTP 500", "fetch ticket x", "read ticket response x", "dial websocket x",
+ "read websocket x", "write ack x", "something else",
+ } {
+ _ = personalRetryLogError(retryPersonal(errors.New(message)))
+ }
+ for _, status := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, 500, 400} {
+ _ = retryableTicketStatus(status)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := waitPersonalReconnect(ctx, time.Hour); !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+ if err := waitPersonalReconnect(context.Background(), time.Nanosecond); err != nil {
+ t.Fatal(err)
+ }
+ _ = nextPersonalBackoff(30*time.Second, 30*time.Second)
+ _ = nextPersonalBackoff(20*time.Second, 30*time.Second)
+
+ if headerValue(nil, "x") != "" {
+ t.Fatal("nil header")
+ }
+ h := payload.DataFrameHeader{"Mixed": " value "}
+ if headerValue(h, "mixed") == "" || headerAny(h, "none", "mixed") == "" {
+ t.Fatal("case-insensitive header")
+ }
+ for _, raw := range []string{"", "not-json", `[]`, `"not-json"`, `"{\"Name\":\" value \"}"`} {
+ _ = parsePersonalDataFields(raw)
+ }
+ f := personalDataFields{"Name": " value ", "bad": 1, "nested": map[string]any{"Value": " ok "}}
+ if f.string("name") != "value" || f.string("bad") != "" || f.nestedString([]string{"NESTED", "value"}) != "ok" {
+ t.Fatalf("personal fields = %#v", f)
+ }
+ _ = personalDataFields(nil).string("x")
+ _ = personalDataFields(nil).nestedString([]string{"x"})
+ for _, path := range [][]string{{"missing"}, {"bad", "x"}, {"nested"}, {"nested", "missing"}} {
+ _ = nestedStringCI(map[string]any(f), path...)
+ }
+ _ = firstNonEmpty("", " value ")
+ _ = firstInt64(0, 2)
+ _ = firstInt64(0)
+
+ logPersonalDataFrame(nil, "")
+ if redactPersonalRawStringMap(nil) != nil {
+ t.Fatal("nil redaction")
+ }
+ _ = redactPersonalRawStringMap(map[string]string{"token": "secret", "safe": "value"})
+ for _, data := range [][]byte{nil, []byte("not-json"), []byte(`{"token":"x","nested":[{"safe":1}]}`)} {
+ _ = sanitizePersonalRawPayload(data)
+ }
+ _ = redactPersonalRawJSONValue("scalar")
+ if _, err := marshalPersonalRawJSON(make(chan int)); err == nil {
+ t.Fatal("marshal error expected")
+ }
+ _ = personalRawSensitiveKey(" Authorization ")
+ _ = truncatePersonalRawLog(strings.Repeat("x", personalRawDebugPayloadLimit+1))
+
+ var nilSource *PersonalSource
+ raw := nilSource.rawEventFromDataFrame(&payload.DataFrame{Data: `{}`})
+ if raw.SourceID != "" || raw.ReceivedAt.IsZero() {
+ t.Fatalf("nil source event = %#v", raw)
+ }
+}
+
+func TestPortalConfigAndRequestEdges(t *testing.T) {
+ for name, cfg := range map[string]*PortalTicketConfig{
+ "nil": nil,
+ "url": {AccessToken: "t", SourceID: "s"},
+ "token": {TicketURL: "https://x", SourceID: "s"},
+ "source": {TicketURL: "https://x", AccessToken: "t"},
+ "mode": {TicketURL: "https://x", AccessToken: "t", SourceID: "s", Mode: "bad"},
+ "custom": {TicketURL: "https://x", AccessToken: "t", SourceID: "s", Mode: "custom"},
+ } {
+ if err := cfg.Valid(); err == nil {
+ t.Fatalf("%s validation expected", name)
+ }
+ }
+ valid := &PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", Mode: " CUSTOM ", ClientID: "id", ClientSecret: "secret"}
+ if err := valid.Valid(); err != nil || valid.normalizedMode() != PortalTicketModeCustom {
+ t.Fatal(err)
+ }
+ for _, mode := range []string{"", "normal", "custom", "bad"} {
+ _ = normalizePortalTicketMode(mode)
+ }
+
+ if _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
+ TicketURL: ":", AccessToken: "t", SourceID: "s",
+ }); err == nil {
+ t.Fatal("request URL error expected")
+ }
+ network := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errSourceInjected })}
+ if _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
+ TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network,
+ }); err == nil {
+ t.Fatal("network error expected")
+ }
+
+ for name, response := range map[string]struct {
+ status int
+ body string
+ wantErr bool
+ }{
+ "http": {500, "failure", true},
+ "direct": {200, `{"endpoint":"wss://x","ticket":"t"}`, false},
+ "parse": {200, `{`, true},
+ "failed": {200, `{"success":false,"errorCode":"E","errorMsg":"bad"}`, true},
+ "missing": {200, `{"success":true,"result":{}}`, true},
+ "envelope": {200, `{"success":true,"result":{"endpoint":"wss://x","ticket":"t"}}`, false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if name == "direct" && req.Header.Get("User-Agent") != "agent" {
+ t.Fatal("user agent missing")
+ }
+ return &http.Response{StatusCode: response.status, Body: io.NopCloser(strings.NewReader(response.body)), Header: make(http.Header)}, nil
+ })}
+ _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
+ TicketURL: "https://x", AccessToken: "t", SourceID: "s", UserAgent: "agent", HTTPClient: client,
+ })
+ if (err != nil) != response.wantErr {
+ t.Fatalf("request error = %v", err)
+ }
+ })
+ }
+ for _, ticket := range []portalStreamTicket{{Endpoint: "%", Ticket: "t"}, {Endpoint: "wss://host/path?x=1", Ticket: "t"}} {
+ _, _ = websocketURL(ticket)
+ }
+ resp := payload.NewSuccessDataFrameResponse()
+ df := &payload.DataFrame{Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "msg"}}
+ ensurePortalAckHeaders(resp, df)
+ ensurePortalAckHeaders(resp, df)
+ ctx, cancel := context.WithCancel(context.Background())
+ if isContextDone(ctx) {
+ t.Fatal("fresh context done")
+ }
+ cancel()
+ if !isContextDone(ctx) {
+ t.Fatal("canceled context not done")
+ }
+ _ = truncatePortalTicketLog(" short ", 10)
+ _ = truncatePortalTicketLog(" long value ", 4)
+}
+
+func TestPortalStartEndToEndAndFailures(t *testing.T) {
+ makeSource := func(cfg *PortalTicketConfig) *DingtalkSource {
+ s, err := New(Config{PortalTicket: cfg})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+ }
+ network := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errSourceInjected })}
+ s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network})
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("ticket failure expected")
+ }
+
+ returnTicket := func(endpoint string) *http.Client {
+ return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ body := `{"endpoint":` + strconvQuote(endpoint) + `,"ticket":"t"}`
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
+ })}
+ }
+ s = makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: returnTicket("%")})
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("websocket URL error expected")
+ }
+ s = makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: returnTicket("ws://127.0.0.1:1")})
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if err := s.Start(ctx, func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("dial error expected")
+ }
+
+ upgrader := websocket.Upgrader{}
+ var wsURL string
+ mux := http.NewServeMux()
+ mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"endpoint":`+strconvQuote(wsURL)+`,"ticket":"ticket"}`)
+ })
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ _ = conn.WriteMessage(websocket.TextMessage, []byte("invalid"))
+ df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "msg"}, Data: `{}`}
+ _ = conn.WriteJSON(df)
+ _, _, _ = conn.ReadMessage()
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+ wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
+ s = makeSource(&PortalTicketConfig{TicketURL: srv.URL + "/ticket", AccessToken: "t", SourceID: "s", HTTPClient: srv.Client()})
+ ctx, cancel = context.WithCancel(context.Background())
+ emitted := make(chan struct{}, 1)
+ done := make(chan error, 1)
+ go func() { done <- s.Start(ctx, func(*dwsevent.RawEvent) { emitted <- struct{}{} }) }()
+ select {
+ case <-emitted:
+ case <-time.After(time.Second):
+ t.Fatal("portal event timeout")
+ }
+ cancel()
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("portal stop = %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("portal stop timeout")
+ }
+}
+
+func TestPortalStartHandshakeReadAndAckErrors(t *testing.T) {
+ origWriteMessage := portalWriteMessage
+ t.Cleanup(func() { portalWriteMessage = origWriteMessage })
+ makeSource := func(endpoint string) *DingtalkSource {
+ s, err := New(Config{PortalTicket: &PortalTicketConfig{
+ TicketURL: "https://ticket", AccessToken: "token", SourceID: "source",
+ HTTPClient: staticPersonalTicketClient(endpoint, ""),
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+ }
+ forbidden := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = io.WriteString(w, "forbidden")
+ }))
+ defer forbidden.Close()
+ endpoint := "ws" + strings.TrimPrefix(forbidden.URL, "http")
+ s := makeSource(endpoint)
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil || !strings.Contains(err.Error(), "HTTP 403") {
+ t.Fatalf("handshake error = %v", err)
+ }
+
+ upgrader := websocket.Upgrader{}
+ closedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err == nil {
+ _ = conn.Close()
+ }
+ }))
+ defer closedSrv.Close()
+ endpoint = "ws" + strings.TrimPrefix(closedSrv.URL, "http")
+ s = makeSource(endpoint)
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil || !strings.Contains(err.Error(), "stream read") {
+ t.Fatalf("read error = %v", err)
+ }
+
+ frame := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "m"}, Data: `{}`}
+ abruptSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ _ = conn.WriteJSON(frame)
+ _ = conn.UnderlyingConn().Close()
+ }))
+ defer abruptSrv.Close()
+ endpoint = "ws" + strings.TrimPrefix(abruptSrv.URL, "http")
+ s = makeSource(endpoint)
+ portalWriteMessage = func(*websocket.Conn, int, []byte) error { return errSourceInjected }
+ if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
+ t.Fatal("abrupt ack/read error expected")
+ }
+
+ cancelSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ _ = conn.WriteJSON(frame)
+ time.Sleep(100 * time.Millisecond)
+ }))
+ defer cancelSrv.Close()
+ endpoint = "ws" + strings.TrimPrefix(cancelSrv.URL, "http")
+ s = makeSource(endpoint)
+ ctx, cancel := context.WithCancel(context.Background())
+ portalWriteMessage = func(*websocket.Conn, int, []byte) error {
+ cancel()
+ return errSourceInjected
+ }
+ errCh := make(chan error, 1)
+ go func() { errCh <- s.Start(ctx, func(*dwsevent.RawEvent) {}) }()
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("ack cancellation = %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("ack cancellation timeout")
+ }
+}
+
+func strconvQuote(s string) string {
+ var b bytes.Buffer
+ _, _ = io.WriteString(&b, `"`+strings.ReplaceAll(s, `"`, `\"`)+`"`)
+ return b.String()
+}
diff --git a/internal/event/source/dingtalk.go b/internal/event/source/dingtalk.go
index 37d11c93..a6ff4d54 100644
--- a/internal/event/source/dingtalk.go
+++ b/internal/event/source/dingtalk.go
@@ -26,6 +26,7 @@ import (
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/event"
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/handler"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
)
@@ -61,7 +62,17 @@ type sourceConfig struct {
type DingtalkSource struct {
cfg Config
machine *Machine
- cli *client.StreamClient
+ cli streamClient
+}
+
+type streamClient interface {
+ RegisterAllEventRouter(handler.IFrameHandler)
+ Start(context.Context) error
+ Close()
+}
+
+var newStreamClient = func(options ...client.ClientOption) streamClient {
+ return client.NewStreamClient(options...)
}
// New constructs a DingtalkSource. Returns an error if required Config
@@ -117,7 +128,7 @@ func (s *DingtalkSource) Start(ctx context.Context, emit dwsevent.EmitFn) error
options := []client.ClientOption{
client.WithAppCredential(client.NewAppCredentialConfig(s.cfg.ClientID, s.cfg.ClientSecret)),
}
- s.cli = client.NewStreamClient(options...)
+ s.cli = newStreamClient(options...)
s.cli.RegisterAllEventRouter(s.makeHandler(emit))
s.machine.OnConnecting()
@@ -159,9 +170,7 @@ func (s *DingtalkSource) makeHandler(emit dwsevent.EmitFn) func(context.Context,
s.machine.OnEvent()
resp := payload.NewSuccessDataFrameResponse()
- if err := resp.SetJson(event.NewEventProcessResultSuccess()); err != nil {
- return nil, err
- }
+ _ = resp.SetJson(event.NewEventProcessResultSuccess())
return resp, nil
}
}
diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go
index 78575f11..1d4233a6 100644
--- a/internal/event/source/personal.go
+++ b/internal/event/source/personal.go
@@ -129,9 +129,6 @@ func (s *PersonalSource) Start(ctx context.Context, emit dwsevent.EmitFn) error
backoff := s.cfg.ReconnectMin
for {
acked, err := s.runAttempt(ctx, emit)
- if err == nil {
- return nil
- }
if ctx.Err() != nil {
return ctx.Err()
}
@@ -203,10 +200,7 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
body["clientId"] = s.cfg.ClientID
body["clientSecret"] = s.cfg.ClientSecret
}
- b, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
+ b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TicketURL, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("personal source: create ticket request: %w", err)
@@ -255,9 +249,7 @@ func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dws
resp := payload.NewSuccessDataFrameResponse()
resp.SetHeader(payload.DataFrameHeaderKMessageId, df.GetMessageId())
resp.SetHeader(payload.DataFrameHeaderKContentType, payload.DataFrameContentTypeKJson)
- if err := resp.SetJson(event.NewEventProcessResultSuccess()); err != nil {
- return err
- }
+ _ = resp.SetJson(event.NewEventProcessResultSuccess())
if err := conn.WriteJSON(resp); err != nil {
return retryPersonal(fmt.Errorf("personal source: write ack: %w", err))
}
diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go
index 98d20d86..7f5444e9 100644
--- a/internal/event/source/portal_ticket.go
+++ b/internal/event/source/portal_ticket.go
@@ -49,6 +49,10 @@ type PortalTicketConfig struct {
HTTPClient *http.Client
}
+var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error {
+ return conn.WriteMessage(messageType, data)
+}
+
func (c *PortalTicketConfig) Valid() error {
if c == nil {
return errors.New("source: PortalTicketConfig is nil")
@@ -127,7 +131,7 @@ func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.Em
closeOnContext(ctx, conn)
handler := s.makeHandler(emit)
for {
- messageType, message, err := conn.ReadMessage()
+ _, message, err := conn.ReadMessage()
if err != nil {
s.machine.OnStopped()
if isContextDone(ctx) {
@@ -135,23 +139,13 @@ func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.Em
}
return fmt.Errorf("source: portal stream read: %w", err)
}
- if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage {
- continue
- }
df, err := payload.DecodeDataFrame(message)
if err != nil {
continue
}
- resp, err := handler(ctx, df)
- if err != nil {
- s.machine.OnStopped()
- return err
- }
- if resp == nil {
- continue
- }
+ resp, _ := handler(ctx, df)
ensurePortalAckHeaders(resp, df)
- if err := conn.WriteMessage(websocket.TextMessage, resp.Encode()); err != nil {
+ if err := portalWriteMessage(conn, websocket.TextMessage, resp.Encode()); err != nil {
s.machine.OnStopped()
if isContextDone(ctx) {
return ctx.Err()
@@ -180,10 +174,7 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt
body["clientId"] = strings.TrimSpace(cfg.ClientID)
body["clientSecret"] = strings.TrimSpace(cfg.ClientSecret)
}
- rawBody, err := json.Marshal(body)
- if err != nil {
- return portalStreamTicket{}, err
- }
+ rawBody, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TicketURL), bytes.NewReader(rawBody))
if err != nil {
return portalStreamTicket{}, err
diff --git a/internal/event/transport/coverage_edges_test.go b/internal/event/transport/coverage_edges_test.go
new file mode 100644
index 00000000..df54832f
--- /dev/null
+++ b/internal/event/transport/coverage_edges_test.go
@@ -0,0 +1,82 @@
+//go:build !windows
+
+package transport
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestFrameCodecCoverageEdges(t *testing.T) {
+ if _, err := NewReader(strings.NewReader("partial")).Read(); !errors.Is(err, io.EOF) {
+ t.Fatalf("partial frame error = %v, want EOF", err)
+ }
+ if err := NewReader(strings.NewReader("")).ReadJSON(&map[string]any{}); !errors.Is(err, io.EOF) {
+ t.Fatalf("ReadJSON EOF = %v", err)
+ }
+ if err := NewWriter(io.Discard).WriteJSON(make(chan int)); err == nil {
+ t.Fatal("unsupported JSON value should fail")
+ }
+}
+
+func TestUnixListenErrorCoverage(t *testing.T) {
+ oldStat, oldRemove, oldListen, oldChmod := statSocket, removeSocket, listenUnix, chmodSocket
+ t.Cleanup(func() {
+ statSocket, removeSocket, listenUnix, chmodSocket = oldStat, oldRemove, oldListen, oldChmod
+ })
+ wantErr := errors.New("synthetic failure")
+ path := filepath.Join(t.TempDir(), "bus.sock")
+ if _, err := listen(strings.Repeat("x", 512)); err == nil {
+ t.Fatal("overlong socket path should fail")
+ }
+
+ statSocket = func(string) (os.FileInfo, error) { return nil, nil }
+ removeSocket = func(string) error { return wantErr }
+ if _, err := listen(path); !errors.Is(err, wantErr) {
+ t.Fatalf("stale remove error = %v", err)
+ }
+
+ statSocket = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
+ listenUnix = func(string, string) (net.Listener, error) { return nil, wantErr }
+ if _, err := listen(path); !errors.Is(err, wantErr) {
+ t.Fatalf("listen error = %v", err)
+ }
+
+ closed := false
+ listener := &stubNetListener{close: func() error { closed = true; return nil }}
+ listenUnix = func(string, string) (net.Listener, error) { return listener, nil }
+ chmodSocket = func(string, os.FileMode) error { return wantErr }
+ removeSocket = func(string) error { return nil }
+ if _, err := listen(path); !errors.Is(err, wantErr) || !closed {
+ t.Fatalf("chmod error = %v, closed=%v", err, closed)
+ }
+
+ removeSocket = func(string) error { return wantErr }
+ if err := (&unixListener{l: listener, path: path}).Close(); err != nil {
+ t.Fatalf("listener close should ignore unlink error: %v", err)
+ }
+
+ var buf bytes.Buffer
+ if err := NewWriter(&buf).WriteJSON(map[string]any{"ok": true}); err != nil {
+ t.Fatalf("sanity frame write: %v", err)
+ }
+}
+
+type stubNetListener struct {
+ close func() error
+}
+
+func (*stubNetListener) Accept() (net.Conn, error) { return nil, io.EOF }
+func (l *stubNetListener) Close() error { return l.close() }
+func (*stubNetListener) Addr() net.Addr { return stubNetAddr{} }
+
+type stubNetAddr struct{}
+
+func (stubNetAddr) Network() string { return "unix" }
+func (stubNetAddr) String() string { return "stub" }
diff --git a/internal/event/transport/frame.go b/internal/event/transport/frame.go
index 8e2dbe58..e310bec1 100644
--- a/internal/event/transport/frame.go
+++ b/internal/event/transport/frame.go
@@ -71,10 +71,7 @@ func (r *Reader) Read() ([]byte, error) {
if errors.Is(err, io.EOF) && len(line) == 0 {
return nil, io.EOF
}
- if err != nil {
- return nil, err
- }
- return nil, nil // unreachable
+ return nil, err
}
// ReadJSON decodes the next frame into dst. dst must be a non-nil pointer.
diff --git a/internal/event/transport/transport_unix.go b/internal/event/transport/transport_unix.go
index 69a0e1f9..d14f69dd 100644
--- a/internal/event/transport/transport_unix.go
+++ b/internal/event/transport/transport_unix.go
@@ -29,13 +29,20 @@ type unixListener struct {
path string
}
+var (
+ statSocket = os.Stat
+ removeSocket = os.Remove
+ listenUnix = net.Listen
+ chmodSocket = os.Chmod
+)
+
func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() }
func (u *unixListener) Endpoint() string { return u.path }
func (u *unixListener) Close() error {
err := u.l.Close()
// Best-effort unlink. Ignored errors here because the bus may have
// already been replaced by a competing bus that unlinked first.
- _ = os.Remove(u.path)
+ _ = removeSocket(u.path)
return err
}
@@ -54,18 +61,18 @@ func listen(path string) (Listener, error) {
return nil, err
}
// Stale socket cleanup. Caller holds bus.lock so this is race-safe.
- if _, err := os.Stat(path); err == nil {
- if err := os.Remove(path); err != nil {
+ if _, err := statSocket(path); err == nil {
+ if err := removeSocket(path); err != nil {
return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err)
}
}
- l, err := net.Listen("unix", path)
+ l, err := listenUnix("unix", path)
if err != nil {
return nil, fmt.Errorf("transport: listen %s: %w", path, err)
}
- if err := os.Chmod(path, config.FilePerm); err != nil {
+ if err := chmodSocket(path, config.FilePerm); err != nil {
_ = l.Close()
- _ = os.Remove(path)
+ _ = removeSocket(path)
return nil, fmt.Errorf("transport: chmod %s: %w", path, err)
}
return &unixListener{l: l, path: path}, nil
diff --git a/internal/event/types.go b/internal/event/types.go
index e36d6c69..f3fff6f1 100644
--- a/internal/event/types.go
+++ b/internal/event/types.go
@@ -79,13 +79,16 @@ func itoa(n int64) string {
var buf [20]byte
i := len(buf)
neg := n < 0
+ value := uint64(n)
if neg {
- n = -n
+ // -(MinInt64) overflows int64. Adjust before negating, then restore
+ // the dropped unit in unsigned space.
+ value = uint64(-(n + 1)) + 1
}
- for n > 0 {
+ for value > 0 {
i--
- buf[i] = byte('0' + n%10)
- n /= 10
+ buf[i] = byte('0' + value%10)
+ value /= 10
}
if neg {
i--
diff --git a/internal/event/types_test.go b/internal/event/types_test.go
index 6f2a315a..023fc783 100644
--- a/internal/event/types_test.go
+++ b/internal/event/types_test.go
@@ -14,12 +14,23 @@
package event
import (
+ "math"
"path/filepath"
"regexp"
"strings"
"testing"
)
+func TestItoaCoversSignedBoundaries(t *testing.T) {
+ for value, want := range map[int64]string{
+ 0: "0", 1: "1", -1: "-1", math.MaxInt64: "9223372036854775807", math.MinInt64: "-9223372036854775808",
+ } {
+ if got := itoa(value); got != want {
+ t.Errorf("itoa(%d) = %q, want %q", value, got, want)
+ }
+ }
+}
+
func TestRawEvent_DedupKey_UsesEventID(t *testing.T) {
e := &RawEvent{EventID: "ev_abc"}
if got := e.DedupKey(); got != "ev_abc" {
diff --git a/internal/executor/invocation_test.go b/internal/executor/invocation_test.go
new file mode 100644
index 00000000..ee730a44
--- /dev/null
+++ b/internal/executor/invocation_test.go
@@ -0,0 +1,70 @@
+package executor
+
+import (
+ "context"
+ "reflect"
+ "testing"
+)
+
+func TestInvocationBuildersAndEchoRunner(t *testing.T) {
+ params := map[string]any{"name": "value"}
+ compat := NewCompatibilityInvocation("old path", "doc", "read", params)
+ if compat.Kind != "compat_invocation" || compat.CanonicalPath != "doc.read" || !reflect.DeepEqual(compat.Params, params) {
+ t.Fatalf("unexpected compatibility invocation: %#v", compat)
+ }
+ if got := NewCompatibilityInvocation("old", "doc", "read", nil).Params; got == nil {
+ t.Fatal("nil compatibility params were not normalized")
+ }
+ help := NewHelperInvocation("old path", "chat", "send", params)
+ if help.Kind != "helper_invocation" || help.Stage != "helper_override" {
+ t.Fatalf("unexpected helper invocation: %#v", help)
+ }
+ if got := NewHelperInvocation("old", "chat", "send", nil).Params; got == nil {
+ t.Fatal("nil helper params were not normalized")
+ }
+
+ workflow := NewWorkflowInvocation("old", "publish", []Invocation{compat, help})
+ steps, ok := workflow.Params["steps"].([]any)
+ if !ok || len(steps) != 2 || workflow.CanonicalPath != "workflow.publish" {
+ t.Fatalf("unexpected workflow invocation: %#v", workflow)
+ }
+
+ runner := EchoRunner{}
+ result, err := runner.Run(context.Background(), compat)
+ if err != nil || result.Invocation.Tool != "read" || result.Response != nil {
+ t.Fatalf("EchoRunner normal result = %#v, %v", result, err)
+ }
+ compat.DryRun = true
+ result, err = runner.Run(context.Background(), compat)
+ if err != nil || result.Response["dry_run"] != true {
+ t.Fatalf("EchoRunner dry-run result = %#v, %v", result, err)
+ }
+}
+
+func TestMergePayloadsAndToolCallRequest(t *testing.T) {
+ merged, err := MergePayloads(`{"a":1,"same":"json"}`, `{"b":2,"same":"params"}`, map[string]any{"c": 3, "same": "override"})
+ if err != nil {
+ t.Fatalf("MergePayloads() error = %v", err)
+ }
+ if merged["same"] != "override" || merged["a"] != float64(1) || merged["b"] != float64(2) || merged["c"] != 3 {
+ t.Fatalf("MergePayloads() = %#v", merged)
+ }
+ if empty, err := MergePayloads(" ", "", nil); err != nil || len(empty) != 0 {
+ t.Fatalf("empty MergePayloads() = %#v, %v", empty, err)
+ }
+ for _, input := range []string{`{`, `[]`, `null`} {
+ if _, err := MergePayloads(input, "", nil); err == nil {
+ t.Errorf("MergePayloads(%q) error = nil", input)
+ }
+ }
+
+ request := ToolCallRequest("read", map[string]any{"id": "1"})
+ if request["method"] != "tools/call" || request["jsonrpc"] != "2.0" {
+ t.Fatalf("ToolCallRequest() = %#v", request)
+ }
+ request = ToolCallRequest("read", nil)
+ params := request["params"].(map[string]any)
+ if params["arguments"] == nil {
+ t.Fatal("ToolCallRequest() left nil arguments")
+ }
+}
diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go
index 0c6374e6..9394f48e 100644
--- a/internal/helpers/aitable.go
+++ b/internal/helpers/aitable.go
@@ -98,7 +98,7 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
page++
if page > 1 {
- time.Sleep(time.Duration(pageDelayMs) * time.Millisecond)
+ helperSleep(time.Duration(pageDelayMs) * time.Millisecond)
}
fmt.Fprintf(os.Stderr, "[page %d] fetching...\n", page)
@@ -509,7 +509,7 @@ func callAitableTool(toolName string, args map[string]any) error {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s
fmt.Fprintf(os.Stderr, "[aitable retry %d/%d] %s after %v...\n", attempt, aitableMaxRetries, toolName, backoff)
- time.Sleep(backoff)
+ helperSleep(backoff)
}
err := callMCPTool(toolName, args)
@@ -533,7 +533,7 @@ func callAitableHelperTool(toolName string, args map[string]any) error {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
fmt.Fprintf(os.Stderr, "[aitable-helper retry %d/%d] %s after %v...\n", attempt, aitableMaxRetries, toolName, backoff)
- time.Sleep(backoff)
+ helperSleep(backoff)
}
err := callMCPToolOnServer("aitable-helper", toolName, args)
@@ -843,10 +843,7 @@ func newAitableCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "base-id", "table-id", "record-id"); err != nil {
return err
}
- baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
- if err != nil {
- return err
- }
+ baseID, _ := mustFlagOrFallback(cmd, "base-id", "base")
return callAitableTool("get_base_primary_doc_id", map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
@@ -1013,10 +1010,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错
RunE: func(cmd *cobra.Command, args []string) error {
baseID := mustGetFlag(cmd, "base-id")
targetFolderID := mustGetFlag(cmd, "target-folder-id")
- onlyCopyMeta, err := cmd.Flags().GetBool("only-struct")
- if err != nil {
- return err
- }
+ onlyCopyMeta, _ := cmd.Flags().GetBool("only-struct")
toolArgs := map[string]any{
"baseId": baseID,
"targetFolderId": targetFolderID,
@@ -1125,10 +1119,7 @@ config 结构参考:
if err != nil {
return err
}
- baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
- if err != nil {
- return err
- }
+ baseID, _ := mustFlagOrFallback(cmd, "base-id", "base")
return callMCPTool("create_table", map[string]any{
"baseId": baseID,
"tableName": flagOrFallback(cmd, "name", "table-name"),
@@ -1515,9 +1506,6 @@ newFieldName、config、aiConfig 至少传入一项。
return err
}
tableID := mustGetFlag(cmd, "table-id")
- if tableID == "" {
- return fmt.Errorf("--table-id must not be empty\n hint: first run: dws aitable table get --base-id to find the tableId")
- }
if v, _ := cmd.Flags().GetString("view-id"); v != "" {
fmt.Fprintf(os.Stderr, "warning: --view-id is not supported by record query; records are queried by table, not by view. The --view-id flag has been ignored.\n")
}
@@ -1577,14 +1565,8 @@ newFieldName、config、aiConfig 至少传入一项。
if !fetchAll {
return callAitableTool("query_records", toolArgs)
}
+ // The flag default is already 50; an explicit zero means unlimited.
pageLimit, _ := cmd.Flags().GetInt("page-limit")
- if pageLimit == 0 {
- // 用户未显式传 --page-limit 时,检查是否有默认值
- if !cmd.Flags().Changed("page-limit") {
- pageLimit = 50 // 默认最多 50 页
- }
- // 显式传 --page-limit 0 表示无限制,保持 0
- }
return recordQueryFetchAll(toolArgs, pageLimit)
},
}
@@ -2108,10 +2090,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
return fmt.Errorf("missing required flag --size")
}
- size, err := cmd.Flags().GetInt64("size")
- if err != nil {
- return fmt.Errorf("invalid --size value: %w", err)
- }
+ size, _ := cmd.Flags().GetInt64("size")
toolArgs := map[string]any{
"baseId": mustGetFlag(cmd, "base-id"),
@@ -2386,10 +2365,10 @@ fieldWidths 仅支持 Grid 视图。
- Kanban → kanbanCard {coverFieldId, coverResizeMode, hiddenFieldTitle}
- Gallery → galleryCard {coverMode, coverFieldId, coverResizeMode, displayFieldName}
typed flag 与 --json 同时存在时,typed flag 优先。--no-cover 与 --cover-field-id 互斥。`,
- Example: ` dws aitable view update card --view-id VIEW_ID --cover-field-id fldXXX --cover-resize-mode contain
- dws aitable view update card --view-id VIEW_ID --no-cover
- dws aitable view update card --view-id VIEW_ID --cover-mode auto # Gallery
- dws aitable view update card --view-id VIEW_ID --json '{"hiddenFieldTitle":true}'`,
+ Example: ` dws aitable view update card --table-id TABLE_ID --view-id VIEW_ID --cover-field-id fldXXX --cover-resize-mode contain
+ dws aitable view update card --table-id TABLE_ID --view-id VIEW_ID --no-cover
+ dws aitable view update card --table-id TABLE_ID --view-id VIEW_ID --cover-mode auto # Gallery
+ dws aitable view update card --table-id TABLE_ID --view-id VIEW_ID --json '{"hiddenFieldTitle":true}'`,
RunE: func(cmd *cobra.Command, args []string) error {
// flag 级互斥先校验,避免无效请求触发 preflight GET
noCover, _ := cmd.Flags().GetBool("no-cover")
@@ -2427,8 +2406,8 @@ typed flag 与 --json 同时存在时,typed flag 优先。--no-cover 与 --cov
Long: `按属性局部更新 Gantt 视图的 ganttTimebar 配置。
子字段:startField / endField (date 字段) / displayFieldId / timelineScale (year|quarter|month|weeks) /
colorConfigs (JSON 数组) / officialHoliday (bool)。`,
- Example: ` dws aitable view update timebar --view-id VIEW_ID --start-field fldStart --end-field fldEnd --timeline-scale month
- dws aitable view update timebar --view-id VIEW_ID --json '{"colorConfigs":[]}'`,
+ Example: ` dws aitable view update timebar --table-id TABLE_ID --view-id VIEW_ID --start-field fldStart --end-field fldEnd --timeline-scale month
+ dws aitable view update timebar --table-id TABLE_ID --view-id VIEW_ID --json '{"colorConfigs":[]}'`,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, tableID, viewID, blockKey, err := viewUpdateCommonPreflight(cmd, "ganttTimebar", []string{"Gantt"}, false)
if err != nil {
@@ -2441,12 +2420,11 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
collectStringFlag(cmd, "timeline-scale", "timelineScale", typed)
collectBoolFlag(cmd, "official-holiday", "officialHoliday", typed)
if v, _ := cmd.Flags().GetString("color-configs"); v != "" {
- parsed := jsonStringToMap(v)
- if arr, ok := parsed.([]any); ok {
- typed["colorConfigs"] = arr
- } else {
- return fmt.Errorf("--color-configs 必须是 JSON 数组,got %T", parsed)
+ var arr []any
+ if err := json.Unmarshal([]byte(v), &arr); err != nil {
+ return fmt.Errorf("--color-configs 必须是 JSON 数组: %w", err)
}
+ typed["colorConfigs"] = arr
}
jsonStr, _ := cmd.Flags().GetString("json")
block, err := mergeUpdateBlock(jsonStr, typed)
@@ -2462,9 +2440,9 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
Short: "更新视图字段聚合统计(仅 Grid)",
Long: `更新 Grid 视图的 aggregate 配置。value 为 map[fieldId]→AggregateAction string,
传 null 清除单个字段聚合。便捷 flag:--field-id / --action 单字段写入;--clear-field-id 单/多清除。`,
- Example: ` dws aitable view update aggregate --view-id VIEW_ID --field-id fldX --action SUM
- dws aitable view update aggregate --view-id VIEW_ID --clear-field-id fldX,fldY
- dws aitable view update aggregate --view-id VIEW_ID --json '{"fldA":"AVG","fldB":"MAX"}'`,
+ Example: ` dws aitable view update aggregate --table-id TABLE_ID --view-id VIEW_ID --field-id fldX --action SUM
+ dws aitable view update aggregate --table-id TABLE_ID --view-id VIEW_ID --clear-field-id fldX,fldY
+ dws aitable view update aggregate --table-id TABLE_ID --view-id VIEW_ID --json '{"fldA":"AVG","fldB":"MAX"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, tableID, viewID, blockKey, err := viewUpdateCommonPreflight(cmd, "aggregate", []string{"Grid"}, false)
if err != nil {
@@ -2497,8 +2475,8 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
Use: "field-widths",
Short: "更新视图字段列宽(仅 Grid)",
Long: `更新 Grid 视图的字段列宽。value 为 map[fieldId]→width(int),可单字段或批量。`,
- Example: ` dws aitable view update field-widths --view-id VIEW_ID --field-id fldX --width 200
- dws aitable view update field-widths --view-id VIEW_ID --json '{"fldA":120,"fldB":200}'`,
+ Example: ` dws aitable view update field-widths --table-id TABLE_ID --view-id VIEW_ID --field-id fldX --width 200
+ dws aitable view update field-widths --table-id TABLE_ID --view-id VIEW_ID --json '{"fldA":120,"fldB":200}'`,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, tableID, viewID, blockKey, err := viewUpdateCommonPreflight(cmd, "fieldWidths", []string{"Grid"}, false)
if err != nil {
@@ -2527,8 +2505,8 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
Short: "更新视图可见字段列表",
Long: `按属性更新视图的 visibleFieldIds(即列顺序)。传入的字段 ID 列表
完全替换原有顺序;首列字段不可隐藏。所有视图类型都支持。`,
- Example: ` dws aitable view update visible-fields --view-id VIEW_ID --field-ids fld1,fld2,fld3
- dws aitable view update visible-fields --view-id VIEW_ID --json '["fld1","fld2"]'`,
+ Example: ` dws aitable view update visible-fields --table-id TABLE_ID --view-id VIEW_ID --field-ids fld1,fld2,fld3
+ dws aitable view update visible-fields --table-id TABLE_ID --view-id VIEW_ID --json '["fld1","fld2"]'`,
RunE: func(cmd *cobra.Command, args []string) error {
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "visibleFieldIds", nil, false)
if err != nil {
@@ -2539,10 +2517,9 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
fieldIDs = parseCSVValues(v)
}
if jsonStr, _ := cmd.Flags().GetString("json"); jsonStr != "" {
- parsed := jsonStringToMap(jsonStr)
- arr, ok := parsed.([]any)
- if !ok {
- return fmt.Errorf("--json 必须是字符串数组,got %T", parsed)
+ var arr []any
+ if err := json.Unmarshal([]byte(jsonStr), &arr); err != nil {
+ return fmt.Errorf("--json 必须是字符串数组: %w", err)
}
// --json 优先(与其他 update 子命令"typed 优先"相反,因为 visible-fields
// 不存在 key-level 合并;只能整组替换)。若同时设置则提示。
@@ -2611,7 +2588,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
Use: "name",
Short: "重命名视图(= view update --name 的便捷子命令)",
Long: `重命名指定视图,等价于 dws aitable view update --name X。无 config 参数。`,
- Example: ` dws aitable view update name --view-id VIEW_ID --name "新视图名"`,
+ Example: ` dws aitable view update name --table-id TABLE_ID --view-id VIEW_ID --name "新视图名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "view-id", "name"); err != nil {
return err
@@ -2711,8 +2688,8 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
Short: "更新视图冻结列数",
Long: `设置视图冻结列数。--count N 表示从首列起冻结 N 列;--count 0 表示取消冻结。
返回 {baseId, tableId, viewId, count}。`,
- Example: ` dws aitable view update frozen-cols --view-id VIEW_ID --count 1
- dws aitable view update frozen-cols --view-id VIEW_ID --count 0`,
+ Example: ` dws aitable view update frozen-cols --table-id TABLE_ID --view-id VIEW_ID --count 1
+ dws aitable view update frozen-cols --table-id TABLE_ID --view-id VIEW_ID --count 0`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "view-id"); err != nil {
return err
@@ -2755,8 +2732,8 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
Short: "更新视图行高(单元格高度)",
Long: `设置视图单元格高度,单位为像素。--cell-height 必填,合法档位 32 / 56 / 88 / 128,默认 32。
返回 {baseId, tableId, viewId, cellHeight}。`,
- Example: ` dws aitable view update row-height --view-id VIEW_ID --cell-height 32
- dws aitable view update row-height --view-id VIEW_ID --cell-height 56`,
+ Example: ` dws aitable view update row-height --table-id TABLE_ID --view-id VIEW_ID --cell-height 32
+ dws aitable view update row-height --table-id TABLE_ID --view-id VIEW_ID --cell-height 56`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "view-id"); err != nil {
return err
diff --git a/internal/helpers/aitable_command_edges_test.go b/internal/helpers/aitable_command_edges_test.go
new file mode 100644
index 00000000..a1456737
--- /dev/null
+++ b/internal/helpers/aitable_command_edges_test.go
@@ -0,0 +1,298 @@
+package helpers
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type aitableCommandCoverageCaller struct {
+ viewType string
+ err error
+ response map[string]string
+}
+
+func (c *aitableCommandCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
+ if c.err != nil {
+ return nil, c.err
+ }
+ if response, ok := c.response[tool]; ok {
+ return textToolResult(response), nil
+ }
+ viewType := c.viewType
+ if viewType == "" {
+ viewType = "Grid"
+ }
+ var response string
+ switch tool {
+ case "get_views":
+ response = fmt.Sprintf(`{"data":{"views":[{"viewId":"view","viewType":%q,"kanbanCard":{},"galleryCard":{},"ganttTimebar":{},"aggregate":{},"filter":[],"sort":[],"group":[],"visibleFieldIds":[],"custom":{"widthMap":{}}}]}}`, viewType)
+ case "list_form_views":
+ response = `{"data":[{"viewId":"view","title":"Form"}]}`
+ case "query_records":
+ response = `{"data":{"records":[],"hasMore":false,"nextCursor":""}}`
+ default:
+ response = `{"success":true,"data":{}}`
+ }
+ return textToolResult(response), nil
+}
+
+func (*aitableCommandCoverageCaller) Format() string { return "json" }
+func (*aitableCommandCoverageCaller) DryRun() bool { return false }
+func (*aitableCommandCoverageCaller) Fields() string { return "" }
+func (*aitableCommandCoverageCaller) JQ() string { return "" }
+
+func runAitableCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
+ t.Helper()
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ root := newAitableCommand()
+ installExampleGlobalFlags(root)
+ root.SilenceErrors = true
+ root.SilenceUsage = true
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.SetArgs(args)
+ return root.ExecuteContext(context.Background())
+}
+
+func TestAitableRetryWrappersExhaustAndRecover(t *testing.T) {
+ oldDeps, oldSleep := deps, helperSleep
+ t.Cleanup(func() { deps, helperSleep = oldDeps, oldSleep })
+ helperSleep = func(time.Duration) {}
+
+ retryable := fmt.Errorf("timeout: retryable: true")
+ caller := &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
+ installAitableDeps(t, caller)
+ if err := callAitableTool("retry", nil); err == nil {
+ t.Fatal("exhausted aitable retries returned nil")
+ }
+
+ caller = &aitableTestCaller{errors: []error{retryable, retryable}}
+ installAitableDeps(t, caller)
+ if err := callAitableHelperTool("retry", nil); err != nil {
+ t.Fatalf("helper retry did not recover: %v", err)
+ }
+
+ caller = &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
+ installAitableDeps(t, caller)
+ if err := callAitableHelperTool("retry", nil); err == nil {
+ t.Fatal("exhausted helper retries returned nil")
+ }
+}
+
+func TestAitableCommandValidationEdges(t *testing.T) {
+ oldDeps, oldArgs, oldStdin, oldSleep := deps, os.Args, os.Stdin, helperSleep
+ t.Cleanup(func() {
+ deps, os.Args, os.Stdin, helperSleep = oldDeps, oldArgs, oldStdin, oldSleep
+ })
+ helperSleep = func(time.Duration) {}
+ os.Args = []string{"dws", "aitable", "--yes"}
+ caller := &aitableCommandCoverageCaller{}
+
+ manyIDs := make([]string, 101)
+ for i := range manyIDs {
+ manyIDs[i] = fmt.Sprintf("r%d", i)
+ }
+ manyRecords := "[" + strings.TrimSuffix(strings.Repeat(`{"cells":{}},`, 101), ",") + "]"
+
+ scenarios := []struct {
+ name string
+ args []string
+ }{
+ {"primary doc missing base", []string{"base", "get-primary-doc-id", "--table-id=t", "--record-id=r"}},
+ {"table create invalid fields", []string{"table", "create", "--base-id=b", "--name=n", "--fields={"}},
+ {"table create missing base", []string{"table", "create", "--name=n", `--fields=[{"fieldName":"N","type":"text"}]`}},
+ {"field create invalid fields", []string{"field", "create", "--base-id=b", "--table-id=t", "--fields={"}},
+ {"field create invalid configured options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", "--config={}", "--options={"}},
+ {"field create configured options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", "--config={}", `--options=[{"name":"A"}]`}},
+ {"field create scalar config", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=text", "--config=[]"}},
+ {"field create invalid options", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", "--options={"}},
+ {"field create options and ai", []string{"field", "create", "--base-id=b", "--table-id=t", "--name=n", "--type=singleSelect", `--options=[{"name":"A"}]`, `--ai-config={"enabled":true}`}},
+ {"field update no changes", []string{"field", "update", "--base-id=b", "--table-id=t", "--field-id=f"}},
+ {"query explicitly empty table", []string{"record", "query", "--base-id=b", "--table-id="}},
+ {"query rich options", []string{"record", "query", "--base-id=b", "--table-id=t", "--view-id=v", "--record-ids=r1,r2", "--field-ids=f1,f2", `--filters={"operator":"and","operands":[{"operator":"eq","operands":["f","v"]}]}`, `--sort=[{"fieldId":"f","order":"desc"},{"fieldId":"g","order":"asc","direction":"desc"}]`, "--keyword=k", "--page-size=2", "--cursor=c"}},
+ {"query primary options", []string{"record", "query", "--base-id=b", "--table-id=t", "--query=k", "--limit=2"}},
+ {"query invalid sort", []string{"record", "query", "--base-id=b", "--table-id=t", "--sort={"}},
+ {"query all default page limit", []string{"record", "query", "--base-id=b", "--table-id=t", "--all"}},
+ {"query all unlimited", []string{"record", "query", "--base-id=b", "--table-id=t", "--all", "--page-limit=0"}},
+ {"create invalid cells", []string{"record", "create", "--base-id=b", "--table-id=t", "--cells={"}},
+ {"create cells shortcut", []string{"record", "create", "--base-id=b", "--table-id=t", `--cells={"f":"v"}`}},
+ {"create invalid records", []string{"record", "create", "--base-id=b", "--table-id=t", "--records={"}},
+ {"update half shortcut", []string{"record", "update", "--base-id=b", "--table-id=t", "--record-id=r"}},
+ {"update invalid shortcut cells", []string{"record", "update", "--base-id=b", "--table-id=t", "--record-id=r", "--cells={"}},
+ {"update shortcut missing base", []string{"record", "update", "--table-id=t", "--record-id=r", `--cells={"f":"v"}`}},
+ {"update invalid records", []string{"record", "update", "--base-id=b", "--table-id=t", "--records={"}},
+ {"update records", []string{"record", "update", "--base-id=b", "--table-id=t", `--records=[{"recordId":"r","cells":{}}]`}},
+ {"batch empty ids", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=,", `--cells={"f":"v"}`}},
+ {"batch too many ids", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=" + strings.Join(manyIDs, ","), `--cells={"f":"v"}`}},
+ {"batch invalid cells", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=r", "--cells={"}},
+ {"batch empty cells", []string{"record", "batch-update", "--base-id=b", "--table-id=t", "--record-ids=r", "--cells={}"}},
+ {"batch missing base", []string{"record", "batch-update", "--table-id=t", "--record-ids=r", `--cells={"f":"v"}`}},
+ {"query empty invalid limit", []string{"record", "query-empty", "--base-id=b", "--table-id=t", "--limit=0"}},
+ {"history invalid offset", []string{"record", "history-list", "--base-id=b", "--table-id=t", "--record-id=r", "--offset=-1"}},
+ {"history invalid limit", []string{"record", "history-list", "--base-id=b", "--table-id=t", "--record-id=r", "--limit=0"}},
+ {"share empty ids", []string{"record", "share-url", "--base-id=b", "--table-id=t", "--record-ids=,"}},
+ {"share too many ids", []string{"record", "share-url", "--base-id=b", "--table-id=t", "--record-ids=" + strings.Join(manyIDs[:21], ",")}},
+ {"upsert invalid records", []string{"record", "upsert", "--base-id=b", "--table-id=t", "--records={"}},
+ {"upsert empty records", []string{"record", "upsert", "--base-id=b", "--table-id=t", "--records=[]"}},
+ {"upsert too many records", []string{"record", "upsert", "--base-id=b", "--table-id=t", "--records=" + manyRecords}},
+ {"primary doc create missing base", []string{"record", "primary-doc-create", "--table-id=t", "--field-id=f", "--record-id=r"}},
+ {"attachment all options", []string{"attachment", "upload", "--base-id=b", "--file-name=x", "--size=1", "--mime-type=text/plain"}},
+ {"form update no changes", []string{"form", "update", "--base-id=b", "--table-id=t", "--view-id=v"}},
+ {"form field update missing base", []string{"form", "field", "update", "--table-id=t", "--view-id=v", "--field-id=f"}},
+ {"form field hide missing base", []string{"form", "field", "hide", "--table-id=t", "--view-id=v", "--field-id=f", "--hidden=true"}},
+ {"form share update missing base", []string{"form", "share", "update", "--table-id=t", "--view-id=v", "--enabled=true"}},
+ {"dashboard create empty", []string{"dashboard", "create", "--base-id=b"}},
+ {"dashboard create invalid config scalar", []string{"dashboard", "create", "--base-id=b", "--config=[]"}},
+ {"dashboard create config", []string{"dashboard", "create", "--base-id=b", `--config={"name":"n"}`}},
+ {"dashboard update config", []string{"dashboard", "update", "--base-id=b", "--dashboard-id=d", `--config={"name":"n"}`}},
+ {"dashboard share invalid bool", []string{"dashboard", "share", "update", "--base-id=b", "--dashboard-id=d", "--enabled=invalid"}},
+ {"dashboard share missing base", []string{"dashboard", "share", "update", "--dashboard-id=d", "--enabled=true"}},
+ {"dashboard share all options", []string{"dashboard", "share", "update", "--base-id=b", "--dashboard-id=d", "--enabled=true", "--share-type=PUBLIC", "--allow-back-to-doc"}},
+ {"chart create missing base", []string{"chart", "create", "--dashboard-id=d", `--config={"name":"n"}`, `--layout={"x":0}`}},
+ {"chart update missing base", []string{"chart", "update", "--dashboard-id=d", "--chart-id=c", `--config={"name":"n"}`}},
+ {"chart share invalid bool", []string{"chart", "share", "update", "--base-id=b", "--dashboard-id=d", "--chart-id=c", "--enabled=invalid"}},
+ {"chart share missing base", []string{"chart", "share", "update", "--dashboard-id=d", "--chart-id=c", "--enabled=true"}},
+ {"chart share all options", []string{"chart", "share", "update", "--base-id=b", "--dashboard-id=d", "--chart-id=c", "--enabled=true", "--share-type=ORG", "--allow-back-to-doc"}},
+ {"workflow invalid limit", []string{"workflow", "list", "--base-id=b", "--limit=0"}},
+ {"workflow invalid offset", []string{"workflow", "list", "--base-id=b", "--offset=-1"}},
+ {"export create task", []string{"export", "data", "--base-id=b", "--scope=all", "--format=excel", "--table-id=t", "--view-id=v", "--timeout-ms=1"}},
+ {"role create invalid json", []string{"advperm", "role-create", "--base-id=b", "--name=n", "--sub-roles={"}},
+ {"role create non-array", []string{"advperm", "role-create", "--base-id=b", "--name=n", "--sub-roles={}"}},
+ {"role create array", []string{"advperm", "role-create", "--base-id=b", "--name=n", "--sub-roles=[]"}},
+ {"role update invalid json", []string{"advperm", "role-update", "--base-id=b", "--role-id=r", "--sub-roles={"}},
+ {"role update non-array", []string{"advperm", "role-update", "--base-id=b", "--role-id=r", "--sub-roles={}"}},
+ {"role update array", []string{"advperm", "role-update", "--base-id=b", "--role-id=r", "--sub-roles=[]"}},
+ {"import rich options", []string{"import", "data", "--import-id=i", "--table-id=t", "--timeout=1", "--header-row=2", "--src-sheet-name=Sheet1", `--field-mapping={"A":"f"}`}},
+ {"import invalid mapping", []string{"import", "data", "--import-id=i", "--field-mapping={"}},
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ if err := runAitableCoverageCommand(t, caller, scenario.args...); err != nil {
+ t.Logf("command returned: %v", err)
+ }
+ })
+ }
+
+ formGet := []string{"form", "get", "--base-id=b", "--table-id=t", "--view-id=view"}
+ _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{err: fmt.Errorf("transport")}, formGet...)
+ _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{response: map[string]string{"list_form_views": "{"}}, formGet...)
+ _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{response: map[string]string{"list_form_views": `{}`}}, formGet...)
+ _ = runAitableCoverageCommand(t, caller, formGet...)
+}
+
+func TestAitableViewCommandEdges(t *testing.T) {
+ oldDeps, oldArgs, oldSleep := deps, os.Args, helperSleep
+ t.Cleanup(func() { deps, os.Args, helperSleep = oldDeps, oldArgs, oldSleep })
+ helperSleep = func(time.Duration) {}
+ os.Args = []string{"dws", "aitable", "--yes"}
+
+ type scenario struct {
+ name string
+ viewType string
+ args []string
+ }
+ base := []string{"--base-id=b", "--table-id=t", "--view-id=view"}
+ with := func(prefix []string, flags ...string) []string {
+ out := append([]string(nil), prefix...)
+ out = append(out, base...)
+ return append(out, flags...)
+ }
+ scenarios := []scenario{
+ {"get card unsupported", "Grid", with([]string{"view", "get", "card"})},
+ {"get card", "Kanban", with([]string{"view", "get", "card"})},
+ {"get timebar wrong type", "Grid", with([]string{"view", "get", "timebar"})},
+ {"get timebar", "Gantt", with([]string{"view", "get", "timebar"})},
+ {"get filter", "Grid", with([]string{"view", "get", "filter"})},
+ {"update invalid config", "Grid", with([]string{"view", "update"}, `--config={"filter":1}`)},
+ {"update normalized config", "Grid", with([]string{"view", "update"}, `--config={"filter":{"operator":"and","operands":[]}}`)},
+ {"card conflict", "Kanban", with([]string{"view", "update", "card"}, "--no-cover", "--cover-field-id=f")},
+ {"card unsupported", "Grid", with([]string{"view", "update", "card"}, "--no-cover")},
+ {"card no cover", "Kanban", with([]string{"view", "update", "card"}, "--no-cover", "--hidden-field-title", "--display-field-name")},
+ {"card cover", "Gallery", with([]string{"view", "update", "card"}, "--cover-field-id=f", "--cover-mode=auto")},
+ {"card invalid json", "Kanban", with([]string{"view", "update", "card"}, "--json=[]")},
+ {"timebar wrong type", "Grid", with([]string{"view", "update", "timebar"}, "--start-field=f")},
+ {"timebar invalid colors", "Gantt", with([]string{"view", "update", "timebar"}, "--color-configs={}")},
+ {"timebar colors", "Gantt", with([]string{"view", "update", "timebar"}, "--color-configs=[]", "--official-holiday")},
+ {"timebar invalid json", "Gantt", with([]string{"view", "update", "timebar"}, "--json=[]")},
+ {"aggregate half pair", "Grid", with([]string{"view", "update", "aggregate"}, "--field-id=f")},
+ {"aggregate typed clear", "Grid", with([]string{"view", "update", "aggregate"}, "--field-id=f", "--action=SUM", "--clear-field-id=x,y")},
+ {"aggregate invalid json", "Grid", with([]string{"view", "update", "aggregate"}, "--json=[]")},
+ {"width half pair", "Grid", with([]string{"view", "update", "field-widths"}, "--field-id=f")},
+ {"width typed", "Grid", with([]string{"view", "update", "field-widths"}, "--field-id=f", "--width=120")},
+ {"width invalid json", "Grid", with([]string{"view", "update", "field-widths"}, "--json=[]")},
+ {"visible non-array", "Grid", with([]string{"view", "update", "visible-fields"}, "--json={}")},
+ {"visible mixed array", "Grid", with([]string{"view", "update", "visible-fields"}, `--json=["f",1]`)},
+ {"visible empty", "Grid", with([]string{"view", "update", "visible-fields"})},
+ {"visible both", "Grid", with([]string{"view", "update", "visible-fields"}, "--field-ids=x", `--json=["f","g"]`)},
+ {"filter missing json", "Grid", with([]string{"view", "update", "filter"})},
+ {"filter invalid json", "Grid", with([]string{"view", "update", "filter"}, "--json={")},
+ {"filter invalid shape", "Grid", with([]string{"view", "update", "filter"}, "--json=1")},
+ {"filter valid object", "Grid", with([]string{"view", "update", "filter"}, `--json={"operator":"and","operands":[]}`)},
+ {"sort valid", "Grid", with([]string{"view", "update", "sort"}, `--json=[{"fieldId":"f","direction":"asc"}]`)},
+ {"group valid", "Grid", with([]string{"view", "update", "group"}, `--json=[{"fieldId":"f","direction":"asc"}]`)},
+ {"name missing base", "Grid", []string{"view", "update", "name", "--table-id=t", "--view-id=view", "--name=n"}},
+ {"frozen negative", "Grid", with([]string{"view", "update", "frozen-cols"}, "--count=-1")},
+ {"frozen missing base", "Grid", []string{"view", "update", "frozen-cols", "--table-id=t", "--view-id=view", "--count=1"}},
+ {"row height invalid", "Grid", with([]string{"view", "update", "row-height"}, "--cell-height=0")},
+ {"row height missing base", "Grid", []string{"view", "update", "row-height", "--table-id=t", "--view-id=view", "--cell-height=32"}},
+ {"fill missing json", "Grid", with([]string{"view", "update", "fill-color-rule"})},
+ {"fill invalid json", "Grid", with([]string{"view", "update", "fill-color-rule"}, "--json={")},
+ {"fill non-array", "Grid", with([]string{"view", "update", "fill-color-rule"}, "--json={}")},
+ {"fill valid", "Grid", with([]string{"view", "update", "fill-color-rule"}, "--json=[]")},
+ {"fill missing base", "Grid", []string{"view", "update", "fill-color-rule", "--table-id=t", "--view-id=view", "--json=[]"}},
+ }
+ for _, item := range scenarios {
+ t.Run(item.name, func(t *testing.T) {
+ if err := runAitableCoverageCommand(t, &aitableCommandCoverageCaller{viewType: item.viewType}, item.args...); err != nil {
+ t.Logf("command returned: %v", err)
+ }
+ })
+ }
+
+ // Exercise the shared view preflight's base-ID and transport-error exits.
+ _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, "view", "update", "visible-fields", "--table-id=t", "--view-id=view", "--field-ids=f")
+ _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{err: fmt.Errorf("transport")}, with([]string{"view", "update", "card"}, "--no-cover")...)
+}
+
+func TestAitableDeleteCancellationEdges(t *testing.T) {
+ oldDeps, oldArgs, oldStdin := deps, os.Args, os.Stdin
+ t.Cleanup(func() { deps, os.Args, os.Stdin = oldDeps, oldArgs, oldStdin })
+ os.Args = []string{"dws", "aitable"}
+ input, err := os.CreateTemp(t.TempDir(), "answers")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := input.WriteString(strings.Repeat("no\n", 20)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := input.Seek(0, 0); err != nil {
+ t.Fatal(err)
+ }
+ os.Stdin = input
+
+ commands := [][]string{
+ {"base", "delete", "--base-id=b"},
+ {"table", "delete", "--base-id=b", "--table-id=t"},
+ {"field", "delete", "--base-id=b", "--table-id=t", "--field-id=f"},
+ {"record", "delete", "--base-id=b", "--table-id=t", "--record-ids=r"},
+ {"view", "delete", "--base-id=b", "--table-id=t", "--view-id=v"},
+ {"form", "delete", "--base-id=b", "--table-id=t", "--view-id=v"},
+ {"workflow", "disable", "--base-id=b", "--workflow-id=w"},
+ {"dashboard", "delete", "--base-id=b", "--dashboard-id=d"},
+ {"chart", "delete", "--base-id=b", "--dashboard-id=d", "--chart-id=c"},
+ {"advperm", "disable", "--base-id=b"},
+ {"advperm", "role-delete", "--base-id=b", "--role-id=r"},
+ }
+ for _, args := range commands {
+ _ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
+ }
+}
diff --git a/internal/helpers/aitable_core_test.go b/internal/helpers/aitable_core_test.go
new file mode 100644
index 00000000..e6567eec
--- /dev/null
+++ b/internal/helpers/aitable_core_test.go
@@ -0,0 +1,315 @@
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type aitableTestCall struct {
+ server string
+ tool string
+ args map[string]any
+}
+
+type aitableTestCaller struct {
+ responses []string
+ errors []error
+ calls []aitableTestCall
+}
+
+func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
+ c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
+ index := len(c.calls) - 1
+ if index < len(c.errors) && c.errors[index] != nil {
+ return nil, c.errors[index]
+ }
+ response := `{"success":true}`
+ if index < len(c.responses) {
+ response = c.responses[index]
+ }
+ return textToolResult(response), nil
+}
+func (*aitableTestCaller) Format() string { return "json" }
+func (*aitableTestCaller) DryRun() bool { return false }
+func (*aitableTestCaller) Fields() string { return "" }
+func (*aitableTestCaller) JQ() string { return "" }
+
+func installAitableDeps(t *testing.T, caller *aitableTestCaller) *bytes.Buffer {
+ t.Helper()
+ oldDeps, oldArgs := deps, os.Args
+ t.Cleanup(func() { deps, os.Args = oldDeps, oldArgs })
+ InitDeps(caller)
+ out := &bytes.Buffer{}
+ deps.Out.w, deps.Out.errW = out, out
+ os.Args = []string{"dws", "aitable"}
+ return out
+}
+
+func TestAitableFlagAndJSONNormalizers(t *testing.T) {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("enabled", "", "")
+ cmd.Flags().String("records", "", "")
+ cmd.Flags().String("records-file", "", "")
+ cmd.Flags().String("fields", "", "")
+ if err := cmd.Flags().Set("enabled", "true"); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := parseBoolFlag(cmd, "enabled"); err != nil || !got {
+ t.Fatalf("parseBoolFlag true = %v, %v", got, err)
+ }
+ _ = cmd.Flags().Set("enabled", "invalid")
+ if _, err := parseBoolFlag(cmd, "enabled"); err == nil {
+ t.Fatal("invalid boolean should fail")
+ }
+
+ if _, err := resolveRecordsFlag(cmd); err == nil {
+ t.Fatal("missing records should fail")
+ }
+ _ = cmd.Flags().Set("fields", `[{"cells":{}}]`)
+ if got, err := resolveRecordsFlag(cmd); err != nil || got == "" {
+ t.Fatalf("fields alias = %q, %v", got, err)
+ }
+ _ = cmd.Flags().Set("records", `[{"cells":{"x":1}}]`)
+ if got, _ := resolveRecordsFlag(cmd); !strings.Contains(got, "x") {
+ t.Fatalf("records value = %q", got)
+ }
+ dir := t.TempDir()
+ empty := filepath.Join(dir, "empty.json")
+ if err := os.WriteFile(empty, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _ = cmd.Flags().Set("records-file", empty)
+ if _, err := resolveRecordsFlag(cmd); err == nil {
+ t.Fatal("empty records file should fail")
+ }
+ _ = cmd.Flags().Set("records-file", filepath.Join(dir, "missing"))
+ if _, err := resolveRecordsFlag(cmd); err == nil {
+ t.Fatal("missing records file should fail")
+ }
+ valid := filepath.Join(dir, "records.json")
+ if err := os.WriteFile(valid, []byte(" [] \n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _ = cmd.Flags().Set("records-file", valid)
+ if got, err := resolveRecordsFlag(cmd); err != nil || got != "[]" {
+ t.Fatalf("records file = %q, %v", got, err)
+ }
+
+ filterCases := []any{
+ nil,
+ []any{},
+ map[string]any{},
+ map[string]any{"operator": 1},
+ map[string]any{"operator": "eq"},
+ map[string]any{"operator": "and"},
+ map[string]any{"operator": "and", "operands": "bad"},
+ map[string]any{"operator": "and", "operands": []any{"skip", map[string]any{}, map[string]any{"operator": 1}}},
+ map[string]any{"operator": "and", "operands": []any{map[string]any{"operator": "date_between"}}},
+ map[string]any{"operator": "and", "operands": []any{map[string]any{"operator": "equals"}}},
+ map[string]any{"operator": "and", "operands": []any{map[string]any{"operator": "eq"}}},
+ }
+ for index, value := range filterCases {
+ err := validateFiltersStructure(value, "unused")
+ if index == 0 || index == len(filterCases)-1 || index == 7 {
+ if err != nil {
+ t.Errorf("valid filter case %d: %v", index, err)
+ }
+ } else if err == nil {
+ t.Errorf("invalid filter case %d should fail", index)
+ }
+ }
+
+ input := map[string]any{"operator": "and", "operands": []any{
+ "plain",
+ map[string]any{"fieldId": "f1", "operator": "eq", "value": "v"},
+ map[string]any{"fieldId": "f2", "operator": "exist"},
+ map[string]any{"operator": "or", "operands": []any{map[string]any{"fieldId": "f3", "operator": "eq", "value": 3}}},
+ map[string]any{"operator": "eq"},
+ }}
+ normalized := normalizeFilters(input).(map[string]any)
+ if len(normalized["operands"].([]any)) != 5 {
+ t.Fatalf("normalized filters = %#v", normalized)
+ }
+ for _, value := range []any{nil, "plain", map[string]any{}, map[string]any{"operands": "bad"}} {
+ _ = normalizeFilters(value)
+ }
+ if got := normalizeViewConfigFilter(input); reflect.TypeOf(got).Kind() != reflect.Slice {
+ t.Fatalf("view filter object = %#v", got)
+ }
+ _ = normalizeViewConfigFilter([]any{input})
+ _ = normalizeViewConfigFilter("plain")
+ for _, value := range []any{[]any{1}, map[string]any{"x": 1}, "plain"} {
+ _ = ensureArray(value)
+ }
+}
+
+func TestAitableViewConfigAndHelpers(t *testing.T) {
+ config := map[string]any{
+ "filter": map[string]any{"operator": "and", "operands": []any{}},
+ "sort": map[string]any{"fieldId": "f"}, "group": []any{},
+ "flags": true, "unknown": true,
+ }
+ if err := normalizeViewConfigBlock(config); err != nil {
+ t.Fatalf("normalize view config: %v", err)
+ }
+ for _, key := range []string{"filter", "sort", "group"} {
+ if reflect.TypeOf(config[key]).Kind() != reflect.Slice {
+ t.Errorf("config %s = %#v", key, config[key])
+ }
+ }
+ for _, bad := range []map[string]any{{"filter": 1}, {"sort": 1}, {"group": 1}} {
+ if err := normalizeViewConfigBlock(bad); err == nil {
+ t.Errorf("invalid view config %#v should fail", bad)
+ }
+ }
+
+ for op, want := range map[string]string{"eq": "", "equals": "eq", "unknown": "eq"} {
+ if got := suggestOperator(op); got != want {
+ t.Errorf("suggestOperator(%q) = %q, want %q", op, got, want)
+ }
+ }
+ for _, tc := range []struct {
+ err error
+ want bool
+ }{{nil, false}, {errors.New("timeout"), true}, {errors.New("SYSTEM_ERROR"), true}, {errors.New("retryable: true"), true}, {errors.New("bad request"), false}} {
+ if got := isAitableRetryableError(tc.err); got != tc.want {
+ t.Errorf("isAitableRetryableError(%v) = %v", tc.err, got)
+ }
+ }
+
+ if requireViewType("Grid", "sort", []string{"Grid"}) != nil || requireViewType("Gallery", "sort", []string{"Grid"}) == nil {
+ t.Fatal("view type requirement mismatch")
+ }
+ for viewType, want := range map[string]string{"Kanban": "kanbanCard", "Gallery": "galleryCard"} {
+ got, err := dispatchCardKey(viewType)
+ if err != nil || got != want {
+ t.Errorf("dispatchCardKey(%q) = %q, %v", viewType, got, err)
+ }
+ }
+ if _, err := dispatchCardKey("Grid"); err == nil {
+ t.Fatal("Grid card should fail")
+ }
+ view := map[string]any{"custom": map[string]any{"width": 10}, "scalar": 1}
+ if walkViewPath(view, "custom.width") != 10 || walkViewPath(view, "") != nil || walkViewPath(view, "scalar.child") != nil {
+ t.Fatal("walkViewPath mismatch")
+ }
+ forms := map[string]any{"data": []any{map[string]any{"viewId": "target"}}}
+ if found, ok := findFormViewByID(forms, "target"); !ok || found["viewId"] != "target" {
+ t.Fatalf("find form = %#v, %v", found, ok)
+ }
+ if _, ok := findFormViewByID([]any{"plain"}, "missing"); ok {
+ t.Fatal("missing form should not be found")
+ }
+
+ cmd := &cobra.Command{Use: "flags"}
+ cmd.Flags().String("name", "", "")
+ cmd.Flags().String("title", "", "")
+ cmd.Flags().String("text", "", "")
+ cmd.Flags().Bool("enabled", false, "")
+ out := map[string]any{}
+ collectStringFlag(cmd, "text", "text", out)
+ collectBoolFlag(cmd, "enabled", "enabled", out)
+ _ = cmd.Flags().Set("text", "value")
+ _ = cmd.Flags().Set("enabled", "false")
+ collectStringFlag(cmd, "text", "text", out)
+ collectBoolFlag(cmd, "enabled", "enabled", out)
+ if out["text"] != "value" || out["enabled"] != false {
+ t.Fatalf("collected flags = %#v", out)
+ }
+ if resolveFormUpdateTitle(cmd) != "" {
+ t.Fatal("empty form title should stay empty")
+ }
+ _ = cmd.Flags().Set("name", "name")
+ if resolveFormUpdateTitle(cmd) != "name" {
+ t.Fatal("form name fallback failed")
+ }
+ _ = cmd.Flags().Set("title", "title")
+ if resolveFormUpdateTitle(cmd) != "title" {
+ t.Fatal("form title priority failed")
+ }
+
+ if _, err := mergeUpdateBlock(`[]`, nil); err == nil {
+ t.Fatal("non-object update JSON should fail")
+ }
+ merged, err := mergeUpdateBlock(`{"x":1,"same":"json"}`, map[string]any{"same": "typed", "y": 2})
+ if err != nil || merged["same"] != "typed" || merged["y"] != 2 {
+ t.Fatalf("merged update = %#v, %v", merged, err)
+ }
+ if merged, err := mergeUpdateBlock("", map[string]any{"x": 1}); err != nil || merged["x"] != 1 {
+ t.Fatalf("typed-only update = %#v, %v", merged, err)
+ }
+
+ for _, raw := range []string{`[1]`, `{"fields":[1]}`, `{}`, `{`} {
+ fields, err := parseFieldsJSON(raw)
+ if (raw == `[1]` || strings.Contains(raw, "fields")) && (err != nil || len(fields) != 1) {
+ t.Errorf("parseFieldsJSON(%q) = %#v, %v", raw, fields, err)
+ }
+ if (raw == `{}` || raw == `{`) && err == nil {
+ t.Errorf("parseFieldsJSON(%q) should fail", raw)
+ }
+ }
+}
+
+func TestAitableToolResponseAndPaginationHelpers(t *testing.T) {
+ caller := &aitableTestCaller{responses: []string{`{"data":{"views":[{"viewId":"v","viewType":"Grid"}]}}`}}
+ installAitableDeps(t, caller)
+ view, viewType, err := getViewRaw(context.Background(), "b", "t", "v")
+ if err != nil || viewType != "Grid" || view["viewId"] != "v" {
+ t.Fatalf("getViewRaw success = %#v, %q, %v", view, viewType, err)
+ }
+
+ for _, response := range []string{"{", `{"data":{"views":[]}}`, `{"data":{"views":["bad"]}}`} {
+ caller = &aitableTestCaller{responses: []string{response}}
+ installAitableDeps(t, caller)
+ if _, _, err := getViewRaw(context.Background(), "b", "t", "v"); err == nil {
+ t.Errorf("getViewRaw(%q) should fail", response)
+ }
+ }
+ caller = &aitableTestCaller{errors: []error{errors.New("offline")}}
+ installAitableDeps(t, caller)
+ if _, _, err := getViewRaw(context.Background(), "b", "t", "v"); err == nil {
+ t.Fatal("getViewRaw transport error should fail")
+ }
+
+ caller = &aitableTestCaller{}
+ out := installAitableDeps(t, caller)
+ if err := printViewSubBlock(nil); err != nil || !strings.Contains(out.String(), "status") {
+ t.Fatalf("print view sub-block = %q, %v", out.String(), err)
+ }
+ if err := callUpdateViewWithBlock("b", "t", "v", "kanbanCard", map[string]any{"x": 1}, map[string]any{"extra": true}); err != nil {
+ t.Fatalf("update view block: %v", err)
+ }
+ if err := callUpdateViewWithBlock("b", "t", "v", "", nil, map[string]any{"newViewName": "name"}); err != nil {
+ t.Fatalf("update view top-level: %v", err)
+ }
+
+ caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"nextCursor":"next"}}`}}
+ out = installAitableDeps(t, caller)
+ if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "totalCount") {
+ t.Fatalf("paginated records = %q, %v", out.String(), err)
+ }
+ caller = &aitableTestCaller{responses: []string{"not-json"}}
+ out = installAitableDeps(t, caller)
+ if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "not-json") {
+ t.Fatalf("raw first page = %q, %v", out.String(), err)
+ }
+ caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}}
+ installAitableDeps(t, caller)
+ if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
+ t.Fatalf("flat records pagination: %v", err)
+ }
+ caller = &aitableTestCaller{errors: []error{errors.New("offline")}}
+ installAitableDeps(t, caller)
+ if err := recordQueryFetchAll(map[string]any{}, 1); err == nil {
+ t.Fatal("first-page pagination error should fail")
+ }
+}
diff --git a/internal/helpers/attendance.go b/internal/helpers/attendance.go
index 42757e02..086bd30a 100644
--- a/internal/helpers/attendance.go
+++ b/internal/helpers/attendance.go
@@ -475,10 +475,7 @@ func collectGlobalSettingSaveRequest(cmd *cobra.Command, settingScene string) (m
continue
}
- value, err := readGlobalSettingSaveFlagValue(cmd, spec)
- if err != nil {
- return nil, err
- }
+ value, _ := readGlobalSettingSaveFlagValue(cmd, spec)
request[spec.requestField] = value
validSceneFieldCount++
}
@@ -1559,14 +1556,6 @@ CLI 会在未传筛选条件时补齐默认查询字段,在未传分页参数
groupVO["owner"] = v
}
- // 确保必填字段已填入 groupVO
- if groupVO["name"] == nil || groupVO["name"] == "" {
- return fmt.Errorf("考勤组名称(name)是必填项")
- }
- if groupVO["type"] == nil || groupVO["type"] == "" {
- return fmt.Errorf("考勤组类型(type)是必填项")
- }
-
// type=FIXED 时校验条件必填字段
if groupVO["type"] == "FIXED" {
workDayClassList, ok := groupVO["workDayClassList"]
diff --git a/internal/helpers/attendance_command_edges_test.go b/internal/helpers/attendance_command_edges_test.go
new file mode 100644
index 00000000..a87910fd
--- /dev/null
+++ b/internal/helpers/attendance_command_edges_test.go
@@ -0,0 +1,169 @@
+package helpers
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type attendanceFailWriter struct{}
+
+func (attendanceFailWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
+
+func runAttendanceCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
+ t.Helper()
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ root := newAttendanceCommand()
+ installExampleGlobalFlags(root)
+ root.SilenceErrors = true
+ root.SilenceUsage = true
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.SetArgs(args)
+ return root.ExecuteContext(context.Background())
+}
+
+func runAttendanceCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
+ t.Helper()
+ command, _, err := newAttendanceCommand().Find(path)
+ if err != nil {
+ return err
+ }
+ for name, value := range flags {
+ if err := command.Flags().Set(name, value); err != nil {
+ return err
+ }
+ }
+ return command.RunE(command, nil)
+}
+
+func attendanceInput(t *testing.T, text string) *os.File {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "stdin")
+ if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = file.Close() })
+ return file
+}
+
+func TestAttendanceCommandEdgeValidation(t *testing.T) {
+ previousDeps, previousArgs, previousStdin := deps, os.Args, os.Stdin
+ os.Args = []string{"dws", "attendance"}
+ InitDeps(&productExampleCaller{})
+ deps.Out.w, deps.Out.errW = io.Discard, io.Discard
+ t.Cleanup(func() { deps, os.Args, os.Stdin = previousDeps, previousArgs, previousStdin })
+
+ users := strings.TrimSuffix(strings.Repeat("u,", 101), ",")
+ commands := [][]string{
+ {"check", "result", "--users=" + users, "--start=2026-01-01", "--end=2026-01-02"},
+ {"check", "result", "--users=u", "--start=2026-01-01", "--end=2026-01-02", "--offset=-1"},
+ {"check", "result", "--users=u", "--start=2026-01-01", "--end=2026-01-02", "--limit=0"},
+ {"report", "query-data", "--users=u", "--columns=1,,2", "--start=2026-01-01 00:00:00", "--end=2026-01-02 00:00:00"},
+ {"schedule", "get", "--users=u", "--start=2026-01-01", "--end=bad"},
+ {"schedule", "get", "--users=u", "--start=2026-01-01", "--end=2026-01-02"},
+ {"checkin", "records", "--operator-corp-id=c", "--operator-staff-id=o", "--staff-ids=,", "--start=2026-01-01 00:00:00", "--end=2026-01-02 00:00:00"},
+ {"checkin", "records", "--operator-corp-id=c", "--operator-staff-id=o", "--staff-ids=u", "--start=2026-01-02 00:00:00", "--end=2026-01-01 00:00:00"},
+ }
+ for _, args := range commands {
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, args...)
+ }
+ for _, tc := range []struct {
+ path []string
+ flags map[string]string
+ }{
+ {[]string{"approve", "list"}, map[string]string{"users": "u", "types": "leave", "start": "2026-01-01"}},
+ {[]string{"group", "create"}, map[string]string{"type": "TURN"}},
+ } {
+ _ = runAttendanceCoverageDirect(t, tc.path, tc.flags)
+ }
+}
+
+func TestAttendanceConfirmedMutationEdges(t *testing.T) {
+ previousDeps, previousArgs, previousStdin := deps, os.Args, os.Stdin
+ os.Args = []string{"dws", "attendance"}
+ t.Cleanup(func() { deps, os.Args, os.Stdin = previousDeps, previousArgs, previousStdin })
+
+ validClass := `{"sections":[]}`
+ validFixed := `{"workDayClassList":[1],"defaultClassId":1}`
+ mutations := [][]string{
+ {"class", "create", "--name=shift", "--class-vo=" + validClass},
+ {"class", "update", "--class-id=1", "--name=shift"},
+ {"group", "update-members", "--group-id=1", "--add-users=u"},
+ {"group", "create", "--name=group", "--type=TURN", "--owner=u"},
+ {"group", "update", "--group-id=1", "--name=group", "--enable-outside-check=true", "--classIds=[1]"},
+ {"selfsetting", "save", "--setting-scene=checkResultNotify", "--user=u", "--check-result-msg=1"},
+ {"globalsetting", "save", "--setting-scene=checkRemind", "--scope=企业", "--check-remind-corp=true"},
+ {"vacation", "update-type", "--leave-code=leave", "--name=name"},
+ {"vacation", "save-balance", "--target=u", "--leave-code=leave", "--num=1", "--reason=reason"},
+ {"schedule", "import", "--groupId=1", `--scheduleVOS=[{"userId":"u","workDate":"2026-01-01","classId":1,"isRest":false}]`},
+ {"boss-check", "--result-id=1", "--time=2026-01-01 09:00", "--result=Normal", "--absent-min=1", "--remark=ok"},
+ }
+ for _, args := range mutations {
+ for _, input := range []string{"", "no\n"} {
+ os.Stdin = attendanceInput(t, input)
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, args...)
+ }
+ }
+
+ for _, args := range [][]string{
+ {"group", "create", "--name=group", "--type=FIXED", "--group-vo={}"},
+ {"group", "create", "--name=group", "--type=FIXED", "--group-vo={\"workDayClassList\":[]}"},
+ {"group", "create", "--name=group", "--type=FIXED", "--group-vo={\"workDayClassList\":[1]}"},
+ {"group", "create", "--name=group", "--type=FIXED", "--group-vo=" + validFixed, "--yes"},
+ {"globalsetting", "save", "--setting-scene=checkRemind", "--scope=企业", "--fast-check-corp=true", "--yes"},
+ {"group", "update", "--group-id=1", "--enable-outside-check=true", "--classIds=[1]", "--yes"},
+ } {
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, args...)
+ }
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{dry: true}, "group", "create", "--name=group", "--type=TURN", "--yes")
+
+ InitDeps(&productExampleCaller{})
+ deps.Out.w, deps.Out.errW = attendanceFailWriter{}, io.Discard
+ root := newAttendanceCommand()
+ installExampleGlobalFlags(root)
+ root.SilenceErrors, root.SilenceUsage = true, true
+ root.SetArgs([]string{"group", "create", "--name=group", "--type=TURN", "--yes"})
+ _ = root.Execute()
+}
+
+func TestAttendanceVacationAndScheduleEdges(t *testing.T) {
+ previousDeps, previousArgs, previousStdin := deps, os.Args, os.Stdin
+ os.Args = []string{"dws", "attendance"}
+ t.Cleanup(func() { deps, os.Args, os.Stdin = previousDeps, previousArgs, previousStdin })
+
+ for _, rules := range []string{
+ `[]`,
+ `[{},{"type":"dept","visible":[]}]`,
+ `[{"type":"other","visible":["x"]},{"type":"dept","visible":[]}]`,
+ `[{"visible":["x"]},{"type":"dept","visible":["1"]}]`,
+ `[{"type":"dept","visible":["-1"]},{"type":"dept","visible":["2"]}]`,
+ } {
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, "vacation", "update-type", "--leave-code=leave", "--visibility-rules="+rules, "--paid=false", "--unit=day", "--user-say-yes")
+ }
+
+ for _, schedules := range []string{
+ `[]`,
+ `[{}]`,
+ `[{"userId":"u","workDate":"bad","classId":1,"isRest":false}]`,
+ `[{"userId":"u","workDate":"2026-01-01","classId":1,"isRest":false}]`,
+ } {
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, "schedule", "import", "--groupId=bad", "--scheduleVOS="+schedules, "--user-say-yes")
+ }
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, "schedule", "import", "--groupId=1", `--scheduleVOS=[{"userId":"u","workDate":"2026-01-01","classId":1,"isRest":false}]`, "--user-say-yes")
+ for _, input := range []string{"", "no\n"} {
+ os.Stdin = attendanceInput(t, input)
+ _ = runAttendanceCoverageCommand(t, &productExampleCaller{}, "vacation", "update-type", "--leave-code=leave", `--visibility-rules=[{"type":"dept","visible":["-1"]}]`, "--paid=false")
+ }
+}
diff --git a/internal/helpers/attendance_coverage_more_test.go b/internal/helpers/attendance_coverage_more_test.go
new file mode 100644
index 00000000..15544332
--- /dev/null
+++ b/internal/helpers/attendance_coverage_more_test.go
@@ -0,0 +1,167 @@
+package helpers
+
+import (
+ "encoding/json"
+ "io"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestAttendanceResponseAndDateCoverage(t *testing.T) {
+ previous := deps
+ InitDeps(&productExampleCaller{})
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previous })
+
+ printGroupModifyDeeplink(nil)
+ for _, value := range []map[string]any{
+ {},
+ {"corpId": "corp"},
+ {"corpId": "corp", "groupId": 1},
+ {"result": map[string]any{"corpId": "corp", "id": json.Number("2")}},
+ {"result": map[string]any{"groupSettingVO": map[string]any{"corpId": "corp", "groupId": float64(3)}}},
+ {"result": map[string]any{"groupVO": map[string]any{"corpId": "corp", "groupId": int64(4)}}},
+ } {
+ printGroupModifyDeeplink(value)
+ _ = pickStringField(value, "corpId")
+ _ = pickInt64StringField(value, "missing", "groupId", "id")
+ }
+ _ = jsonStringToMap(`{"ok":true}`)
+ _ = jsonStringToMap(`not-json`)
+ _ = parseUserList(" one, ,two ")
+ for _, tc := range []struct{ value, name string }{
+ {"2026-01-02 03:04:05", "begin"}, {"2026-01-02", "begin"},
+ {"2026-01-02", "endTime"}, {"bad", "end"},
+ } {
+ _, _ = parseDateToTimestamp(tc.value, tc.name)
+ }
+ for _, scene := range []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify", "bad"} {
+ _ = isAttendanceSettingSceneAllowed(scene)
+ }
+}
+
+func TestAttendanceFlagFallbackCoverage(t *testing.T) {
+ newCommand := func() *cobra.Command {
+ cmd := &cobra.Command{Use: "flags"}
+ cmd.Flags().Int64("primary64", 7, "")
+ cmd.Flags().Int64("alias64", 8, "")
+ cmd.Flags().Int("primary", 7, "")
+ cmd.Flags().Int("alias", 8, "")
+ cmd.Flags().Bool("primary-bool", false, "")
+ cmd.Flags().Bool("alias-bool", false, "")
+ return cmd
+ }
+ for _, configure := range []func(*cobra.Command){
+ func(*cobra.Command) {},
+ func(c *cobra.Command) {
+ _ = c.Flags().Set("primary64", "9")
+ _ = c.Flags().Set("primary", "9")
+ _ = c.Flags().Set("primary-bool", "true")
+ },
+ func(c *cobra.Command) {
+ _ = c.Flags().Set("primary64", "0")
+ _ = c.Flags().Set("primary", "0")
+ _ = c.Flags().Set("primary-bool", "false")
+ },
+ func(c *cobra.Command) {
+ _ = c.Flags().Set("alias64", "10")
+ _ = c.Flags().Set("alias", "10")
+ _ = c.Flags().Set("alias-bool", "true")
+ },
+ func(c *cobra.Command) {
+ _ = c.Flags().Set("alias64", "0")
+ _ = c.Flags().Set("alias", "0")
+ _ = c.Flags().Set("alias-bool", "false")
+ },
+ } {
+ cmd := newCommand()
+ configure(cmd)
+ _ = int64FlagOrFallback(cmd, "primary64", "alias64")
+ _ = intFlagOrFallback(cmd, "primary", "alias")
+ _ = boolFlagOrFallback(cmd, "primary-bool", "alias-bool")
+ }
+}
+
+func TestConvertClassCheckTimeCoverage(t *testing.T) {
+ class := map[string]any{
+ "sections": []any{
+ "bad",
+ map[string]any{"times": "bad"},
+ map[string]any{"times": []any{
+ "bad", map[string]any{}, map[string]any{"checkTime": nil},
+ map[string]any{"checkTime": "08:00"}, map[string]any{"checkTime": "bad"},
+ map[string]any{"checkTime": float64(1)}, map[string]any{"checkTime": true},
+ }},
+ },
+ "setting": map[string]any{"topRestTimeList": []any{
+ "bad", map[string]any{"checkTime": "09:00"},
+ }},
+ }
+ convertClassCheckTime(class)
+ convertClassCheckTime(map[string]any{"sections": "bad", "setting": "bad"})
+ convertClassCheckTime(map[string]any{"setting": map[string]any{"topRestTimeList": "bad"}})
+}
+
+func validAttendanceFlagValue(valueType string) string {
+ switch valueType {
+ case selfSettingSaveBool:
+ return "true"
+ case selfSettingSaveInt:
+ return "1"
+ default:
+ return `{"enabled":true}`
+ }
+}
+
+func TestSelfSettingSaveCoverage(t *testing.T) {
+ for _, spec := range selfSettingSaveFlagSpecs {
+ cmd := &cobra.Command{Use: "self"}
+ registerSelfSettingSaveFlags(cmd)
+ _ = cmd.Flags().Set(spec.flagName, validAttendanceFlagValue(spec.valueType))
+ if _, err := collectSelfSettingSaveRequest(cmd, spec.scenes[0], "user"); err != nil {
+ t.Fatalf("%s: %v", spec.flagName, err)
+ }
+ if selfSettingFlagSupportsScene(spec, "bad") {
+ t.Fatalf("%s unexpectedly supports bad scene", spec.flagName)
+ }
+ _, _ = collectSelfSettingSaveRequest(cmd, "bad", "user")
+ }
+ empty := &cobra.Command{Use: "self"}
+ registerSelfSettingSaveFlags(empty)
+ _, _ = collectSelfSettingSaveRequest(empty, "checkRemind", "user")
+ jsonCommand := &cobra.Command{Use: "self"}
+ registerSelfSettingSaveFlags(jsonCommand)
+ _ = jsonCommand.Flags().Set("check-remind-setting", "")
+ _, _ = collectSelfSettingSaveRequest(jsonCommand, "checkRemind", "user")
+ _ = jsonCommand.Flags().Set("check-remind-setting", "{")
+ _, _ = collectSelfSettingSaveRequest(jsonCommand, "checkRemind", "user")
+ _, _ = readSelfSettingSaveFlagValue(jsonCommand, selfSettingSaveFlagSpec{flagName: "check-remind-setting", valueType: "unsupported"})
+
+ for _, scope := range []string{"", "企业", "全公司", "所有人", "invalid"} {
+ cmd := &cobra.Command{Use: "scope"}
+ cmd.Flags().String("scope", "", "")
+ _ = cmd.Flags().Set("scope", scope)
+ _ = validateGlobalSettingScope(cmd)
+ }
+}
+
+func TestGlobalSettingSaveCoverage(t *testing.T) {
+ for _, spec := range globalSettingSaveFlagSpecs {
+ cmd := &cobra.Command{Use: "global"}
+ registerGlobalSettingSaveFlags(cmd)
+ _ = cmd.Flags().Set(spec.flagName, validAttendanceFlagValue(spec.valueType))
+ if _, err := collectGlobalSettingSaveRequest(cmd, spec.scenes[0]); err != nil {
+ t.Fatalf("%s: %v", spec.flagName, err)
+ }
+ if globalSettingFlagSupportsScene(spec, "bad") {
+ t.Fatalf("%s unexpectedly supports bad scene", spec.flagName)
+ }
+ _, _ = collectGlobalSettingSaveRequest(cmd, "bad")
+ }
+ empty := &cobra.Command{Use: "global"}
+ registerGlobalSettingSaveFlags(empty)
+ _, _ = collectGlobalSettingSaveRequest(empty, "checkRemind")
+ _, _ = readGlobalSettingSaveFlagValue(empty, globalSettingSaveFlagSpec{flagName: "missing", valueType: "unsupported"})
+}
diff --git a/internal/helpers/calendar_coverage_more_test.go b/internal/helpers/calendar_coverage_more_test.go
new file mode 100644
index 00000000..57e358fc
--- /dev/null
+++ b/internal/helpers/calendar_coverage_more_test.go
@@ -0,0 +1,216 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+func newRecurrenceTestCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "event"}
+ cmd.Flags().String("recurrence-type", "", "")
+ cmd.Flags().Int("recurrence-interval", 0, "")
+ cmd.Flags().String("recurrence-days-of-week", "", "")
+ cmd.Flags().Int("recurrence-day-of-month", 0, "")
+ cmd.Flags().String("recurrence-index", "", "")
+ cmd.Flags().String("recurrence-first-day-of-week", "", "")
+ cmd.Flags().String("recurrence-range-type", "", "")
+ cmd.Flags().String("recurrence-end-date", "", "")
+ cmd.Flags().Int("recurrence-count", 0, "")
+ return cmd
+}
+
+func TestCalendarMeetingRoomHintCoverage(t *testing.T) {
+ for _, message := range []string{"plain", "所选组织不支持预定钉钉会议室", `{"errorCode": "400056"}`, `{"errorCode":"400056"}`} {
+ _ = isMeetingRoomDisabledError(message)
+ }
+ ordinary := errors.New("ordinary")
+ if withMeetingRoomDisabledHint(nil) != nil || withMeetingRoomDisabledHint(ordinary) != ordinary {
+ t.Fatal("ordinary error wrapping changed")
+ }
+ cli := &CLIError{Message: "plain", Suggestion: "old"}
+ _ = withMeetingRoomDisabledHint(cli)
+ cli = &CLIError{Message: `{"errorCode":"400056"}`, Suggestion: "old"}
+ _ = withMeetingRoomDisabledHint(cli)
+}
+
+func installCalendarCaller(t *testing.T, caller *helpersCoreCaller) {
+ t.Helper()
+ installHelpersCoreDeps(t, caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ oldArgs := os.Args
+ os.Args = []string{"dws", "calendar"}
+ t.Cleanup(func() { os.Args = oldArgs })
+}
+
+func TestCalendarSearchResponseCoverage(t *testing.T) {
+ caller := &helpersCoreCaller{format: "json", result: textToolResult(`{"result":{"rooms":[]}}`)}
+ installCalendarCaller(t, caller)
+ cmd := &cobra.Command{Use: "search"}
+ cmd.SetContext(context.Background())
+ args := map[string]any{"startTime": time.Now().UnixMilli(), "endTime": time.Now().Add(time.Hour).UnixMilli()}
+
+ for _, raw := range []string{"", `{`, `{"result":{"rooms":[]}}`, `{"rooms":[]}`} {
+ caller.err = nil
+ caller.result = textToolResult(raw)
+ for _, format := range []string{"json", "raw", "table", "unknown"} {
+ caller.format = format
+ _ = callSearchRoomsByName(cmd, args)
+ _ = callMeetingRoomSearchResult(cmd, args, false, false, false)
+ }
+ }
+ caller.err = errors.New(`{"errorCode":"400056"}`)
+ _ = callSearchRoomsByName(cmd, args)
+ _ = callMeetingRoomSearchResult(cmd, args, true, true, true)
+}
+
+func TestCalendarRangeAttachmentCoverage(t *testing.T) {
+ now := time.Now().UnixMilli()
+ for _, tc := range []struct{ start, end, corrected bool }{
+ {false, false, false}, {false, true, false}, {true, false, false}, {true, true, false}, {true, true, true},
+ } {
+ hint := map[string]any{}
+ attachRoomSearchRange(hint, map[string]any{"startTime": now, "endTime": now + 1000}, tc.start, tc.end, tc.corrected)
+ }
+ attachRoomSearchRange(map[string]any{}, map[string]any{}, false, false, false)
+ attachRoomSearchRange(map[string]any{}, map[string]any{"startTime": now}, false, false, false)
+
+ newRangeCommand := func() *cobra.Command {
+ cmd := &cobra.Command{Use: "events"}
+ for _, name := range []string{"start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate", "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"} {
+ cmd.Flags().String(name, "", "")
+ }
+ return cmd
+ }
+ for _, tc := range []struct{ start, end bool }{{}, {start: true}, {end: true}, {start: true, end: true}} {
+ cmd := newRangeCommand()
+ if tc.start {
+ _ = cmd.Flags().Set("start", "value")
+ }
+ if tc.end {
+ _ = cmd.Flags().Set("endDate", "value")
+ }
+ for _, parsed := range []map[string]any{{}, {"result": map[string]any{}}} {
+ attachCalendarSearchRange(parsed, cmd, map[string]any{"startTime": now, "endTime": now + 1000})
+ }
+ }
+ attachCalendarSearchRange(map[string]any{}, nil, map[string]any{})
+ attachCalendarSearchRange(map[string]any{}, nil, map[string]any{"startTime": now})
+}
+
+func TestCalendarBusyAndEventResponseCoverage(t *testing.T) {
+ caller := &helpersCoreCaller{format: "json"}
+ installCalendarCaller(t, caller)
+ cmd := &cobra.Command{Use: "events"}
+ cmd.Flags().String("start", "", "")
+ cmd.Flags().String("end", "", "")
+ cmd.SetContext(context.Background())
+ args := map[string]any{"startTime": time.Now().UnixMilli(), "endTime": time.Now().Add(time.Hour).UnixMilli()}
+ responses := []string{
+ "", `{`, `{}`,
+ `{"result":["bad",{"scheduleItems":"bad"},{"scheduleItems":["bad",{}, {"status":null},{"status":""},{"status":"busy"}]}]}`,
+ `{"result":{"events":["bad",{}, {"id":null},{"id":""},{"id":"later","start":{"dateTime":"2026-01-02T04:00:00Z"}},{"id":"earlier","start":{"dateTime":"2026-01-02T03:00:00Z"}}],"nextCursor":"next"}}`,
+ }
+ for _, raw := range responses {
+ caller.result = textToolResult(raw)
+ caller.err = nil
+ for _, format := range []string{"json", "raw", "table", "unknown"} {
+ caller.format = format
+ _ = callFilteredBusyStatus(cmd, args)
+ _ = callSortedCalendarEvents(cmd, "list_events", args)
+ }
+ }
+ caller.err = errors.New("failed")
+ _ = callFilteredBusyStatus(cmd, args)
+ _ = callSortedCalendarEvents(cmd, "list_events", args)
+ caller.err = nil
+ caller.dry = true
+ _ = callSortedCalendarEvents(cmd, "list_events", args)
+
+ for _, event := range []any{
+ "bad", map[string]any{}, map[string]any{"start": "bad"},
+ map[string]any{"start": map[string]any{"dateTime": "bad"}},
+ map[string]any{"created": float64(2)}, map[string]any{"updated": float64(3)},
+ } {
+ _ = calendarEventSortKey(event)
+ }
+}
+
+func TestBuildRecurrenceCoverage(t *testing.T) {
+ _ = buildReminders("5,bad,0,10")
+ if recurrence, err := buildRecurrence(newRecurrenceTestCommand()); err != nil || recurrence != nil {
+ t.Fatalf("empty recurrence = %#v, %v", recurrence, err)
+ }
+ cases := []map[string]string{
+ {"recurrence-type": "daily"},
+ {"recurrence-type": "invalid", "recurrence-interval": "1", "recurrence-range-type": "noEnd"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "invalid"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "noEnd", "recurrence-first-day-of-week": "monday"},
+ {"recurrence-type": "weekly", "recurrence-interval": "1", "recurrence-range-type": "noEnd"},
+ {"recurrence-type": "weekly", "recurrence-interval": "1", "recurrence-range-type": "noEnd", "recurrence-days-of-week": "monday"},
+ {"recurrence-type": "absoluteMonthly", "recurrence-interval": "1", "recurrence-range-type": "noEnd"},
+ {"recurrence-type": "absoluteYearly", "recurrence-interval": "1", "recurrence-range-type": "noEnd", "recurrence-day-of-month": "15"},
+ {"recurrence-type": "relativeMonthly", "recurrence-interval": "1", "recurrence-range-type": "noEnd", "recurrence-days-of-week": "monday"},
+ {"recurrence-type": "relativeMonthly", "recurrence-interval": "1", "recurrence-range-type": "noEnd", "recurrence-days-of-week": "monday", "recurrence-index": "first"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "endDate"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "endDate", "recurrence-end-date": "bad"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "endDate", "recurrence-end-date": "2026-12-31T23:59:59+08:00"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "numbered"},
+ {"recurrence-type": "daily", "recurrence-interval": "1", "recurrence-range-type": "numbered", "recurrence-count": "10"},
+ }
+ for _, values := range cases {
+ cmd := newRecurrenceTestCommand()
+ for name, value := range values {
+ _ = cmd.Flags().Set(name, value)
+ }
+ _, _ = buildRecurrence(cmd)
+ }
+}
+
+func TestCalendarUnknownFallbackCoverage(t *testing.T) {
+ root := &cobra.Command{Use: "calendar"}
+ group := &cobra.Command{Use: "room", RunE: func(*cobra.Command, []string) error { return errors.New("previous") }}
+ known := &cobra.Command{Use: "search", Aliases: []string{"find"}}
+ hidden := &cobra.Command{Use: "secret", Hidden: true}
+ group.AddCommand(known, hidden)
+ root.AddCommand(group)
+ installUnknownVerbFallback(group)
+ _ = group.RunE(group, []string{"unknown"})
+ _ = group.RunE(group, []string{"--ignored", "search"})
+ _ = group.RunE(group, nil)
+ group.HelpFunc()(group, []string{"calendar", "room", "unknown"})
+ group.HelpFunc()(known, nil)
+ printUnknownSubcmdError(group, "searhc")
+ printUnknownSubcmdError(group, "unrelated")
+
+ hint := calendarInfoHintSubCmd("query", "use search")
+ group.AddCommand(hint)
+ _ = hint.RunE(hint, nil)
+
+ oldArgs := os.Args
+ t.Cleanup(func() { os.Args = oldArgs })
+ group.Flags().StringP("known", "k", "", "")
+ for _, args := range [][]string{
+ {"dws", "calendar", "room", "--"},
+ {"dws", "calendar", "room", "--help"},
+ {"dws", "calendar", "room", "--known=value"},
+ {"dws", "calendar", "room", "--unknown=value"},
+ {"dws", "calendar", "room", "-h"},
+ {"dws", "calendar", "room", "-k", "value"},
+ {"dws", "calendar", "room", "-x"},
+ } {
+ os.Args = args
+ _ = findUnknownFlag(group)
+ }
+ nilPrev := &cobra.Command{Use: "empty"}
+ root.AddCommand(nilPrev)
+ installUnknownVerbFallback(nilPrev)
+ os.Args = []string{"dws", "calendar", "empty"}
+ _ = nilPrev.RunE(nilPrev, nil)
+}
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index 3790c9ef..83661bf2 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -91,12 +91,6 @@ func sanitizeTitleFromText(text string) (title string) {
const maxTitleRunes = 30 // conservative: 30 CJK chars = 90 bytes, leaving room for "..."
const fallbackTitle = "消息"
- defer func() {
- if r := recover(); r != nil {
- title = fallbackTitle
- }
- }()
-
if strings.TrimSpace(text) == "" {
return fallbackTitle
}
@@ -700,10 +694,7 @@ func buildChatChmodArgs(cmd *cobra.Command, scope string) (map[string]any, error
func buildChatCrossOrgDataAuthArgs(cmd *cobra.Command) (map[string]any, error) {
targetOrgID := strings.TrimSpace(mustGetFlag(cmd, "target-org-id"))
- all, err := cmd.Flags().GetBool("all")
- if err != nil {
- return nil, err
- }
+ all, _ := cmd.Flags().GetBool("all")
if targetOrgID == "" && !all {
return nil, fmt.Errorf("--target-org-id or --all is required")
}
@@ -718,10 +709,7 @@ func buildChatCrossOrgDataAuthArgs(cmd *cobra.Command) (map[string]any, error) {
return nil, err
}
toolArgs["grantCategory"] = "data"
- paramsJSON, err := marshalJSONRaw(map[string]string{"targetOrgId": targetOrgID})
- if err != nil {
- return nil, err
- }
+ paramsJSON, _ := marshalJSONRaw(map[string]string{"targetOrgId": targetOrgID})
toolArgs["grantParams"] = string(paramsJSON)
return toolArgs, nil
}
@@ -759,10 +747,7 @@ func appendChatChmodParams(cmd *cobra.Command, toolArgs map[string]any) error {
if specified == 0 && len(grantParams) == 0 {
return fmt.Errorf("--conversation-id, --open-dingtalk-id, --user or --permParam is required")
}
- paramsJSON, err := marshalJSONRaw(grantParams)
- if err != nil {
- return err
- }
+ paramsJSON, _ := marshalJSONRaw(grantParams)
toolArgs["grantParams"] = string(paramsJSON)
return nil
}
@@ -848,6 +833,8 @@ type conversationLocalFileMeta struct {
MD5 string
}
+var chatFileMD5 = fileMD5Hex
+
func buildConversationLocalFileMeta(filePath, fileName, md5Value string) (conversationLocalFileMeta, error) {
fi, err := os.Stat(filePath)
if err != nil {
@@ -861,7 +848,7 @@ func buildConversationLocalFileMeta(filePath, fileName, md5Value string) (conver
}
fileType := strings.TrimPrefix(filepath.Ext(fileName), ".")
if md5Value == "" {
- md5Value, err = fileMD5Hex(filePath)
+ md5Value, err = chatFileMD5(filePath)
if err != nil {
return conversationLocalFileMeta{}, err
}
@@ -924,10 +911,7 @@ func buildConversationFileContent(dentryID, spaceID int64, meta conversationLoca
FilePath: meta.ContentPath,
FileSize: meta.FileSize,
}
- body, err := marshalJSONRaw(content)
- if err != nil {
- return "", err
- }
+ body, _ := marshalJSONRaw(content)
return string(body), nil
}
@@ -1548,15 +1532,9 @@ func newChatCommand() *cobra.Command {
if filePath != "" {
meta, err := buildConversationLocalFileMeta(filePath, "", "")
if err == nil && dentryId != 0 && spaceId != 0 {
- contentJSON, err = buildConversationFileContent(dentryId, spaceId, meta)
- if err != nil {
- return err
- }
+ contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta)
} else if err == nil {
- targetArgs, err := buildConversationTargetArgs(cmd)
- if err != nil {
- return err
- }
+ targetArgs, _ := buildConversationTargetArgs(cmd)
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传本地文件并发送 file 消息")
deps.Out.PrintKeyValue("文件", meta.LocalPath)
@@ -1574,10 +1552,7 @@ func newChatCommand() *cobra.Command {
if err != nil {
return err
}
- contentJSON, err = buildConversationFileContent(dentryId, spaceId, meta)
- if err != nil {
- return err
- }
+ contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta)
} else if dentryId == 0 || spaceId == 0 {
return fmt.Errorf("--file-path must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
}
@@ -1607,8 +1582,6 @@ func newChatCommand() *cobra.Command {
params["receiverOpenDingTalkId"] = openDingTalkID
} else if userID != "" {
params["receiverUserId"] = userID
- } else {
- return fmt.Errorf("--group, --user or --open-dingtalk-id is required for media messages")
}
if msgUuid != "" {
params["uuid"] = msgUuid
@@ -3185,7 +3158,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
--output 指定本地保存路径,可以是文件路径或目录。
如果指定目录,文件名从下载 URL 中自动推断。默认保存到当前目录。`,
- Example: ` dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id
+ Example: ` dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./download.bin
dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/
dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./photo.jpg
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
@@ -3503,10 +3476,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
"replyMsgType": "text",
"content": mustGetFlag(cmd, "text"),
}
- contentJSON, err := marshalJSONRaw(replyContent)
- if err != nil {
- return fmt.Errorf("failed to marshal reply content: %w", err)
- }
+ contentJSON, _ := marshalJSONRaw(replyContent)
clawType := ""
aiTag, _ := cmd.Flags().GetBool("ai-tag")
if aiTag {
diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go
new file mode 100644
index 00000000..4d6eca09
--- /dev/null
+++ b/internal/helpers/chat_command_edges_test.go
@@ -0,0 +1,202 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
+ t.Helper()
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ root := newChatCommand()
+ installExampleGlobalFlags(root)
+ root.PersistentFlags().Bool("debug", false, "")
+ root.PersistentFlags().Bool("verbose", false, "")
+ root.SilenceErrors = true
+ root.SilenceUsage = true
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.SetArgs(args)
+ return root.ExecuteContext(context.Background())
+}
+
+func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
+ t.Helper()
+ command, _, err := newChatCommand().Find(path)
+ if err != nil {
+ return err
+ }
+ for name, value := range flags {
+ if err := command.Flags().Set(name, value); err != nil {
+ return err
+ }
+ }
+ return command.RunE(command, nil)
+}
+
+func TestChatCommandValidationAndSuccessEdges(t *testing.T) {
+ previousDeps, previousArgs := deps, os.Args
+ os.Args = []string{"dws", "chat"}
+ t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
+ caller := &productExampleCaller{}
+
+ commands := [][]string{
+ {"chmod", "chat.read", "--ttl="},
+ {"chmod", "chat.read", "--conversation-id=cid"},
+ {"message", "list", "--group=cid", "--time=2026-01-01", "--limit=1"},
+ {"message", "list", "--user=D-user", "--time=2026-01-01", "--limit=1"},
+ {"message", "list", "--open-dingtalk-id=D-user", "--time=2026-01-01", "--direction=sideways"},
+ {"message", "list-direct", "--user=D-user", "--limit=1"},
+ {"message", "list-by-sender", "--sender-user-id=u1", "--start=bad"},
+ {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=bad"},
+ {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
+ {"message", "list-by-sender", "--sender-user-id=u1", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z"},
+ {"message", "list-by-sender", "--sender-open-dingtalk-id=D1", "--start=2026-01-01T00:00:00Z"},
+ {"message", "list-mentions", "--start=bad", "--end=2026-01-02T00:00:00Z"},
+ {"message", "list-mentions", "--start=2026-01-01T00:00:00Z", "--end=bad"},
+ {"message", "list-mentions", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
+ {"message", "list-mentions", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
+ {"message", "search", "--query=q", "--start=bad", "--end=2026-01-02T00:00:00Z"},
+ {"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=bad"},
+ {"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
+ {"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
+ {"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
+ {"category", "rename", "--category-id=1", "--title=renamed"},
+ {"category", "add-conv", "--group=cid", "--category-ids=1,2"},
+ {"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
+ {"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
+ {"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
+ {"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
+ {"group", "set-history", "--group=cid", "--option=ALL"},
+ {"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
+ {"mark-read", "--conversation-id=cid", "--message-id=mid"},
+ {"text", "translate", "--query=hello", "--to=zh_CN"},
+ {"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
+ {"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"},
+ {"group-role", "query-user", "--group=cid", "--user=D1"},
+ {"group", "set-admin", "--group=cid", "--users=u1,D1"},
+ {"group-mute-member", "--group=cid", "--users=D1", "--mute-time=300000"},
+ {"group-mute-member", "--group=cid", "--users=u1", "--off"},
+ }
+ for _, args := range commands {
+ _ = runChatCoverageCommand(t, caller, args...)
+ }
+ for _, tc := range []struct {
+ path []string
+ flags map[string]string
+ }{
+ {[]string{"message", "search"}, map[string]string{"query": "q"}},
+ {[]string{"message", "recall"}, map[string]string{"conversation-id": "cid"}},
+ {[]string{"category", "rename"}, map[string]string{"category-id": "1"}},
+ {[]string{"category", "add-conv"}, map[string]string{"group": "cid"}},
+ {[]string{"category", "remove-conv"}, map[string]string{"group": "cid"}},
+ {[]string{"mark-read"}, map[string]string{"conversation-id": "cid"}},
+ } {
+ _ = runChatCoverageDirect(t, tc.path, tc.flags)
+ }
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"list":[{"memberRoleType":1,"openDingtalkId":"D-owner"}]}}`}}}, "group", "members", "remove", "--id=cid", "--users=D-owner")
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "group-mute-member", "--group=cid", "--users=u1", "--off")
+}
+
+func TestChatCreateAndMessageSendEdges(t *testing.T) {
+ previousDeps, previousArgs := deps, os.Args
+ os.Args = []string{"dws", "chat", "--debug"}
+ t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
+
+ profile := `{"result":{"userId":"owner"}}`
+ for _, response := range []scriptedToolStep{
+ {text: `{"result":{"openCid":"cid-new","cid":"legacy"}}`},
+ {text: `not-json`},
+ {err: errors.New("create")},
+ } {
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: profile}, response}}
+ _ = runChatCoverageCommand(t, caller, "group", "create", "--name=group", "--users=owner,u2", "--thread")
+ }
+
+ file := filepath.Join(t.TempDir(), "message.txt")
+ if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ tests := []struct {
+ args []string
+ steps []scriptedToolStep
+ dry bool
+ }{
+ {args: []string{"message", "send", "--group=cid", "--text=hello", "--at-all", "--at-open-dingtalk-ids=D1,D2", "--uuid=u"}},
+ {args: []string{"message", "send", "--user=D1", "--text=hello", "--uuid=u", "--debug"}},
+ {args: []string{"message", "send", "--open-dingtalk-id=D1", "--text=hello", "--uuid=u"}},
+ {args: []string{"message", "send", "--user=u1", "--text=hello", "--uuid=u", "--debug"}, steps: []scriptedToolStep{{err: errors.New("contact")}, {err: errors.New("search")}, {text: `{}`}}},
+ {args: []string{"message", "send", "--user=u1", "--text=hello", "--verbose"}, steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=image"}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=image", "--media-id=@media", "--uuid=u"}},
+ {args: []string{"message", "send", "--open-dingtalk-id=D1", "--msg-type=image", "--media-id=@media"}},
+ {args: []string{"message", "send", "--user=u1", "--msg-type=image", "--media-id=@media", "--uuid=u"}, steps: []scriptedToolStep{{err: errors.New("contact")}, {err: errors.New("search")}, {text: `{}`}}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--dentry-id=1"}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file"}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--dentry-id=1", "--space-id=2", "--file-name=f.txt", "--file-size=7"}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--file-path=" + file, "--dentry-id=1", "--space-id=2"}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--file-path=" + filepath.Join(t.TempDir(), "missing")}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--file-path=" + file}, dry: true},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--file-path=" + file}, steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {text: `{"dentryId":1,"spaceId":2}`}, {text: `{}`}}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--file-path=" + file}, steps: []scriptedToolStep{{err: errors.New("init")}}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=file", "--file-path=" + file}, steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {text: `{}`}}},
+ {args: []string{"message", "send", "--group=cid", "--msg-type=unknown"}},
+ }
+ oldPut := httpPutFile
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ t.Cleanup(func() { httpPutFile = oldPut })
+ for _, tc := range tests {
+ caller := &scriptedToolCaller{steps: tc.steps, dry: tc.dry}
+ _ = runChatCoverageCommand(t, caller, tc.args...)
+ }
+}
+
+func TestChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
+ previousDeps, previousArgs := deps, os.Args
+ os.Args = []string{"dws", "chat"}
+ t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
+
+ for _, step := range []scriptedToolStep{{text: `{}`}, {text: `not-json`}, {text: `{"errcode":1,"errmsg":"bad"}`}, {err: errors.New("webhook")}} {
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{step}}, "message", "send-by-webhook", "--token=t", "--title=title", "--text=text")
+ }
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "reply", "--conversation-id=cid", "--ref-msg-id=mid", "--ref-sender=D1", "--text=reply", "--ai-tag", "--uuid=u")
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "message", "reply", "--conversation-id=cid", "--ref-msg-id=mid", "--ref-sender=u1", "--text=reply")
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
+
+ oldGet := httpGetFile
+ t.Cleanup(func() { httpGetFile = oldGet })
+ tmp := t.TempDir()
+ base := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--message-id=mid"}
+ _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, append(base, "--output="+filepath.Join(tmp, "dry"))...)
+ for _, tc := range []struct {
+ step scriptedToolStep
+ out string
+ get error
+ }{
+ {scriptedToolStep{err: errors.New("url")}, filepath.Join(tmp, "tool-error"), nil},
+ {scriptedToolStep{text: `{}`}, filepath.Join(tmp, "parse-error"), nil},
+ {scriptedToolStep{text: `{"resourceUrl":"https://example.test/file.txt"}`}, tmp + string(os.PathSeparator), nil},
+ {scriptedToolStep{text: `{"resourceUrl":"https://example.test/file.txt"}`}, filepath.Join(tmp, "get-error"), errors.New("get")},
+ } {
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return tc.get }
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}, append(base, "--output="+tc.out)...)
+ }
+ blocker := filepath.Join(tmp, "blocker")
+ if err := os.WriteFile(blocker, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return nil }
+ _ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"resourceUrl":"https://example.test/file.txt"}`}}}, append(base, "--output="+filepath.Join(blocker, "child"))...)
+}
diff --git a/internal/helpers/chat_coverage_more_test.go b/internal/helpers/chat_coverage_more_test.go
new file mode 100644
index 00000000..b919d75c
--- /dev/null
+++ b/internal/helpers/chat_coverage_more_test.go
@@ -0,0 +1,394 @@
+package helpers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func newChatFlagTestCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "chat"}
+ cmd.Flags().String("forward", "", "")
+ cmd.Flags().String("direction", "", "")
+ cmd.Flags().Int("limit", 1, "")
+ cmd.Flags().Int("size", 2, "")
+ cmd.Flags().String("group", "", "")
+ cmd.Flags().String("conversation-id", "", "")
+ cmd.Flags().String("id", "", "")
+ cmd.Flags().String("chat", "", "")
+ cmd.Flags().String("open-dingtalk-id", "", "")
+ cmd.Flags().String("user", "", "")
+ cmd.Flags().String("userId", "", "")
+ cmd.Flags().String("agentCode", "agent", "")
+ cmd.Flags().String("grant-type", "once", "")
+ cmd.Flags().String("ttl", "", "")
+ cmd.Flags().String("session-id", "", "")
+ cmd.Flags().String("target-org-id", "", "")
+ cmd.Flags().Bool("all", false, "")
+ cmd.Flags().StringArray("permParam", nil, "")
+ return cmd
+}
+
+func TestChatDirectionAndScalarCoverage(t *testing.T) {
+ search := &cobra.Command{Use: "search"}
+ search.Flags().String("nicks", "", "")
+ search.Flags().Int("limit", 20, "")
+ search.Flags().Int("size", 20, "")
+ search.Flags().String("cursor", "", "")
+ search.Flags().String("match-mode", "", "")
+ search.Flags().Bool("exclude-muted", false, "")
+ if err := runChatSearchCommon(search, nil); err == nil {
+ t.Fatal("missing nicks returned nil")
+ }
+ _ = search.Flags().Set("nicks", "One,Two")
+ _ = search.Flags().Set("exclude-muted", "true")
+ installScriptedCaller(t, &scriptedToolCaller{})
+ if err := runChatSearchCommon(search, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, tc := range []struct {
+ forward, direction string
+ changeForward bool
+ changeDirection bool
+ fallback bool
+ }{
+ {fallback: true},
+ {forward: "false", changeForward: true},
+ {direction: "newer", changeDirection: true},
+ {forward: "false", direction: "newer", changeForward: true, changeDirection: true},
+ {direction: "older", changeDirection: true},
+ {forward: "true", direction: "older", changeForward: true, changeDirection: true},
+ {direction: " ", changeDirection: true, fallback: true},
+ {direction: "sideways", changeDirection: true},
+ } {
+ cmd := newChatFlagTestCommand()
+ if tc.changeForward {
+ _ = cmd.Flags().Set("forward", tc.forward)
+ }
+ if tc.changeDirection {
+ _ = cmd.Flags().Set("direction", tc.direction)
+ }
+ _, _ = resolveMessageForward(cmd, tc.fallback)
+ }
+ cmd := newChatFlagTestCommand()
+ _ = cmd.Flags().Set("size", "9")
+ if got := chatIntFlagOrFallback(cmd, "limit", "size"); got != 9 {
+ t.Fatalf("alias int = %d", got)
+ }
+ _ = chatIntFlagOrFallback(newChatFlagTestCommand(), "limit", "size")
+
+ for _, text := range []string{
+ "", "https://example.test/a%20b", "prefix https://example.test", "prefix http://example.test",
+ "contains%20escape", strings.Repeat("中", 50), "short",
+ } {
+ _ = sanitizeTitleFromText(text)
+ }
+ _ = truncateTitleToBytes("abcdef", 4, 100)
+ _ = truncateTitleToBytes("abcdef", 100, 100)
+ if _, err := marshalJSONRaw(map[string]any{"bad": make(chan int)}); err == nil {
+ t.Fatal("unsupported JSON value succeeded")
+ }
+ for _, raw := range []string{"", "1, 2", ",,", "1,bad"} {
+ _, _ = parseCSVInt64(raw)
+ }
+ for _, value := range []string{"", "123", "12a"} {
+ _ = isNumericUserID(value)
+ }
+ for _, raw := range []string{"{", `{}`, `{"errcode":0}`, `{"errcode":"0"}`, `{"errcode":1}`, `{"errcode":"2","errmsg":"failed"}`} {
+ _, _, _ = webhookErrcodeFailure(raw)
+ }
+ _, _ = splitChatIDValues([]string{"", " user ", "D-open", "d-open"})
+ args := map[string]any{"users": []string{"old"}}
+ appendStringSliceArg(args, "users", nil)
+ appendStringSliceArg(args, "users", []string{"new"})
+ appendStringSliceArg(args, "open", []string{"D1"})
+ _ = appendChatIDArgs(args, []string{"u1", "D1"}, "users", "open")
+ for _, wrap := range []bool{true, false} {
+ _ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
+ }
+}
+
+func TestChatContactMappingCoverage(t *testing.T) {
+ openIDs := map[string]string{}
+ names := map[string]string{}
+ collectContactUserMappings([]any{
+ map[string]any{"userId": "u1", "openDingTalkId": "D1", "name": "One"},
+ map[string]any{"employee": map[string]any{"staff_id": json.Number("2"), "open_dingtalk_id": "D2", "display_name": "Two"}},
+ map[string]any{"ignored": true},
+ }, openIDs, names)
+ if openIDs["u1"] != "D1" || openIDs["2"] != "D2" {
+ t.Fatalf("contact mappings = %#v", openIDs)
+ }
+ _ = stringForNestedJSONKeys(map[string]any{"employee": "bad"}, chatContactNestedUserKeys, chatUserIDJSONKeys)
+ _ = stringForJSONKeys(map[string]any{"ignored": "x", "userId": ""}, chatUserIDJSONKeys)
+ for _, value := range []any{" text ", json.Number("12"), float64(1.5), float32(2.5), int(3), int64(4), int32(5), true} {
+ _ = stringFromJSONScalar(value)
+ }
+ if allUserIDsMapped([]string{"u1", "missing"}, openIDs) || !allUserIDsMapped([]string{"", "u1"}, openIDs) {
+ t.Fatal("allUserIDsMapped result changed")
+ }
+}
+
+func TestResolveOpenDingTalkIDsCoverage(t *testing.T) {
+ if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
+ t.Fatalf("direct ID = %q, %v", id, err)
+ }
+ if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
+ t.Fatal("empty ID unexpectedly resolved")
+ }
+ if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
+ t.Fatalf("direct IDs = %#v, %v", ids, err)
+ }
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
+ installScriptedCaller(t, caller)
+ if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
+ t.Fatalf("resolved IDs = %#v, %v", ids, err)
+ }
+ caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
+ caller.index = 0
+ _, _ = resolveOpenDingTalkID(context.Background(), "missing")
+ caller.steps = []scriptedToolStep{{err: errors.New("contact")}, {err: errors.New("fallback")}}
+ caller.index = 0
+ _, _ = resolveOpenDingTalkIDs(context.Background(), []string{"missing"})
+ caller.steps = []scriptedToolStep{{text: `{`}}
+ caller.index = 0
+ _, _ = lookupOpenDingTalkIDsByUserID(context.Background(), []string{"u1"})
+
+ caller.steps = []scriptedToolStep{
+ {text: `{"result":[{"userId":"u2","name":"Alice"}]}`},
+ {text: `{"result":[{"userId":"u2","openDingTalkId":"D2"}]}`},
+ }
+ caller.index = 0
+ if got, err := lookupOpenDingTalkIDsByUserID(context.Background(), []string{"", "u2"}); err != nil || got["u2"] != "D2" {
+ t.Fatalf("aisearch mapping = %#v, %v", got, err)
+ }
+
+ caller.steps = []scriptedToolStep{
+ {text: `{"result":[{"userId":"u3","name":"Same"}]}`},
+ {err: errors.New("aisearch")},
+ {text: `{"result":[{"userId":"u3","openDingTalkId":"D3"}]}`},
+ }
+ caller.index = 0
+ if got, err := lookupOpenDingTalkIDsByUserID(context.Background(), []string{"u3"}); err != nil || got["u3"] != "D3" {
+ t.Fatalf("contact fallback mapping = %#v, %v", got, err)
+ }
+
+ caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{`}}
+ caller.index = 0
+ if _, err := lookupOpenDingTalkIDsByUserID(context.Background(), []string{"u4"}); err == nil {
+ t.Fatal("invalid fallback response returned nil")
+ }
+
+ caller.steps = []scriptedToolStep{
+ {text: `{"result":[{"userId":"u5","name":"u5"}]}`},
+ {text: `{}`},
+ {text: `{}`},
+ }
+ caller.index = 0
+ if got, err := lookupOpenDingTalkIDsByUserID(context.Background(), []string{"u5"}); err != nil || got["u5"] != "" {
+ t.Fatalf("duplicate-keyword fallback = %#v, %v", got, err)
+ }
+
+ caller.steps = []scriptedToolStep{
+ {text: `{"result":[{"userId":"u6","openDingTalkId":"D6"}]}`},
+ {text: `{}`},
+ }
+ caller.index = 0
+ if got, err := lookupOpenDingTalkIDsByUserID(context.Background(), []string{"u6", "u7"}); err != nil || got["u6"] != "D6" {
+ t.Fatalf("partially mapped fallback = %#v, %v", got, err)
+ }
+
+ caller.steps = []scriptedToolStep{{text: `{`}}
+ caller.index = 0
+ if err := lookupOpenDingTalkIDsByAisearchPerson(context.Background(), "bad", map[string]string{}, map[string]string{}); err == nil {
+ t.Fatal("invalid aisearch response returned nil")
+ }
+}
+
+func TestChatTargetAndGrantCoverage(t *testing.T) {
+ for _, configure := range []func(*cobra.Command){
+ func(c *cobra.Command) { _ = c.Flags().Set("group", "group") },
+ func(c *cobra.Command) { _ = c.Flags().Set("open-dingtalk-id", "123") },
+ func(c *cobra.Command) { _ = c.Flags().Set("user", "D-open") },
+ func(c *cobra.Command) { _ = c.Flags().Set("user", "123") },
+ } {
+ cmd := newChatFlagTestCommand()
+ configure(cmd)
+ _, _ = buildConversationTargetArgs(cmd)
+ }
+ for _, scope := range []string{"bad", "chat.read"} {
+ _ = validateChatScope(scope)
+ }
+ cmd := newChatFlagTestCommand()
+ _ = cmd.Flags().Set("conversation-id", "cid")
+ _ = cmd.Flags().Set("grant-type", "bad")
+ _, _ = buildChatChmodArgs(cmd, "chat.read")
+ for _, tc := range []struct{ grantType, ttl, session string }{
+ {"bad", "", ""}, {"timed", "", ""}, {"timed", "1h", ""},
+ {"session", "", ""}, {"session", "", "session"}, {"permanent", "", "extra"},
+ } {
+ cmd := newChatFlagTestCommand()
+ _ = cmd.Flags().Set("grant-type", tc.grantType)
+ _ = cmd.Flags().Set("ttl", tc.ttl)
+ _ = cmd.Flags().Set("session-id", tc.session)
+ _, _ = buildChatGrantBaseArgs(cmd, "chat.read")
+ }
+
+ for _, configure := range []func(*cobra.Command){
+ func(c *cobra.Command) {},
+ func(c *cobra.Command) { _ = c.Flags().Set("conversation-id", "cid") },
+ func(c *cobra.Command) { _ = c.Flags().Set("open-dingtalk-id", "D1") },
+ func(c *cobra.Command) { _ = c.Flags().Set("user", "u1") },
+ func(c *cobra.Command) { _ = c.Flags().Set("permParam", "key=value") },
+ func(c *cobra.Command) { _ = c.Flags().Set("permParam", "bad") },
+ func(c *cobra.Command) { _ = c.Flags().Set("conversation-id", "cid"); _ = c.Flags().Set("user", "u1") },
+ } {
+ cmd := newChatFlagTestCommand()
+ configure(cmd)
+ _, _ = buildChatChmodArgs(cmd, "chat.read")
+ }
+ for _, values := range [][]string{nil, {"", "a=b", " a = override ", "bad"}} {
+ _, _ = parseChatChmodParams(values)
+ }
+ for _, configure := range []func(*cobra.Command){
+ func(c *cobra.Command) {},
+ func(c *cobra.Command) { _ = c.Flags().Set("target-org-id", "org") },
+ func(c *cobra.Command) { _ = c.Flags().Set("all", "true") },
+ func(c *cobra.Command) { _ = c.Flags().Set("target-org-id", "org"); _ = c.Flags().Set("all", "true") },
+ } {
+ cmd := newChatFlagTestCommand()
+ configure(cmd)
+ _, _ = buildChatCrossOrgDataAuthArgs(cmd)
+ }
+ cmd = newChatFlagTestCommand()
+ _ = cmd.Flags().Set("target-org-id", "org")
+ _ = cmd.Flags().Set("grant-type", "bad")
+ _, _ = buildChatCrossOrgDataAuthArgs(cmd)
+ params := map[string]string{"existing": "value"}
+ putChatChmodParam(params, "blank", " ")
+ putChatChmodParam(params, "existing", "new")
+}
+
+func TestChatFileUtilityCoverage(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "file.txt")
+ if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = fileMD5Hex(file)
+ _, _ = fileMD5Hex(filepath.Join(t.TempDir(), "missing"))
+ _, _ = fileMD5Hex(t.TempDir())
+ for _, raw := range []string{
+ `{`, `{}`,
+ `{"resourceUrls":["https://upload"],"uploadKey":"key","headers":{"x-test":1,"blank":null},"ossHeaders":{"x-other":"yes"}}`,
+ `{"result":{"resourceUrl":"https://upload","key":"key"}}`,
+ } {
+ _, _, _, _ = parseConversationFileUploadInfo(raw)
+ }
+ _, _ = buildConversationLocalFileMeta(filepath.Join(t.TempDir(), "missing"), "", "")
+ _, _ = buildConversationLocalFileMeta(t.TempDir(), "", "")
+ meta, err := buildConversationLocalFileMeta(file, "custom.bin", "provided")
+ if err != nil || meta.FileType != "bin" {
+ t.Fatalf("file meta = %#v, %v", meta, err)
+ }
+ _, _ = buildConversationLocalFileMeta(file, "", "")
+ previousMD5 := chatFileMD5
+ chatFileMD5 = func(string) (string, error) { return "", errors.New("md5") }
+ t.Cleanup(func() { chatFileMD5 = previousMD5 })
+ if _, err := buildConversationLocalFileMeta(file, "", ""); err == nil {
+ t.Fatal("MD5 failure returned nil")
+ }
+ chatFileMD5 = previousMD5
+ _, _ = buildConversationFileContent(1, 2, meta)
+ for _, raw := range []string{`{`, `{}`, `{"result":{"dentryId":1,"spaceId":"2"}}`} {
+ _, _, _ = parseConversationFileSendIDs(raw)
+ }
+ for _, value := range []any{
+ map[string]any{"id": json.Number("1")},
+ map[string]any{"nested": map[string]any{"id": "2"}},
+ []any{map[string]any{"id": 3}},
+ `{"id":4}`, `[ {"id":5} ]`, "not-json", true,
+ } {
+ _, _ = findInt64Field(value, "id")
+ }
+ for _, value := range []any{json.Number("bad"), float64(-1), int64(3), int(4), "5", "bad", true} {
+ _, _ = int64FromJSONScalar(value)
+ }
+ _ = cloneStringAnyMap(map[string]any{"key": "value"})
+ _ = unmarshalJSONUseNumber(`{"n":1}`, &map[string]any{})
+ _ = firstStringField(map[string]any{"one": "", "two": 2}, "one", "two")
+
+ previous := deps
+ caller := &helpersCoreCaller{format: "json"}
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previous })
+ _ = printMCPText(`{"ok":true}`)
+ _ = printMCPText(`not-json`)
+ caller.format = "raw"
+ _ = printMCPText(`{"ok":true}`)
+}
+
+func TestUploadConversationLocalFileCoverage(t *testing.T) {
+ oldPut := httpPutFile
+ t.Cleanup(func() { httpPutFile = oldPut })
+ meta := conversationLocalFileMeta{LocalPath: "file", FileName: "file.txt", FileSize: 4, MD5: "md5"}
+ for _, tc := range []struct {
+ name string
+ steps []scriptedToolStep
+ put error
+ }{
+ {"success", []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {text: `{"ok":true}`}}, nil},
+ {"init-error", []scriptedToolStep{{err: errors.New("init")}}, nil},
+ {"parse-error", []scriptedToolStep{{text: `{}`}}, nil},
+ {"put-error", []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}}, errors.New("put")},
+ {"commit-error", []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {err: errors.New("commit")}}, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &scriptedToolCaller{steps: tc.steps}
+ installScriptedCaller(t, caller)
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return tc.put }
+ _, _ = uploadConversationLocalFile(context.Background(), map[string]any{"target": "id"}, meta, "uuid")
+ })
+ }
+}
+
+func TestGuardGroupOwnerRemovalCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "chat"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ for _, tc := range []struct {
+ name string
+ remove []string
+ steps []scriptedToolStep
+ }{
+ {"owner-open", []string{"D-owner"}, []scriptedToolStep{{text: `{"result":{"list":[{"memberRoleType":1,"openDingtalkId":"D-owner"}]}}`}}},
+ {"other-open", []string{"D-other"}, []scriptedToolStep{{text: `{"result":{"list":[{"memberRoleType":1,"openDingtalkId":"D-owner"}]}}`}}},
+ {"owner-user", []string{"u1"}, []scriptedToolStep{
+ {text: `{"result":{"hasMore":true,"nextCursor":"next","list":[]}}`},
+ {text: `{"result":{"list":[{"memberRoleType":1,"openDingtalkId":"D-owner"}]}}`},
+ {text: `{"result":[{"userId":"u1","openDingTalkId":"D-owner"}]}`},
+ }},
+ {"group-error", []string{"u1"}, []scriptedToolStep{{err: errors.New("group")}}},
+ {"group-invalid", []string{"u1"}, []scriptedToolStep{{text: `{`}}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &scriptedToolCaller{steps: tc.steps}
+ installScriptedCaller(t, caller)
+ _ = guardGroupOwnerRemoval(context.Background(), "group", tc.remove)
+ })
+ }
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
+ installScriptedCaller(t, caller)
+ if owner, err := groupOwnerOpenDingTalkID(context.Background(), "group"); err != nil || owner != "" {
+ t.Fatalf("missing owner = %q, %v", owner, err)
+ }
+}
diff --git a/internal/helpers/command_matrix_test.go b/internal/helpers/command_matrix_test.go
new file mode 100644
index 00000000..6056922d
--- /dev/null
+++ b/internal/helpers/command_matrix_test.go
@@ -0,0 +1,382 @@
+package helpers
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// TestProductCommandMatrixCoverage complements the curated examples by walking
+// every public product command, including leaves that intentionally have no
+// Example text. It supplies required flags and a small set of positional/flag
+// variants, exercising validation, compatibility aliases, and response parsing.
+func TestProductCommandMatrixCoverage(t *testing.T) {
+ workingDirectory, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ work := t.TempDir()
+ if err := os.Chdir(work); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(workingDirectory) })
+
+ fixture := filepath.Join(work, "fixture.txt")
+ if err := os.WriteFile(fixture, []byte(`[{"recordId":"record-id","cells":{"field-id":"value"}}]`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ confirmations := filepath.Join(work, "confirmations.txt")
+ if err := os.WriteFile(confirmations, []byte(strings.Repeat("yes\n", 8192)), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stdin, err := os.Open(confirmations)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = stdin.Close() })
+ noPath := filepath.Join(work, "no.txt")
+ if err := os.WriteFile(noPath, []byte("no\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ noStdin, err := os.Open(noPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = noStdin.Close() })
+ emptyPath := filepath.Join(work, "empty.txt")
+ if err := os.WriteFile(emptyPath, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ emptyStdin, err := os.Open(emptyPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = emptyStdin.Close() })
+
+ previousDeps := deps
+ previousArgs := os.Args
+ previousStdin := os.Stdin
+ previousPut := httpPutFile
+ previousGet := httpGetFile
+ t.Cleanup(func() {
+ deps = previousDeps
+ os.Args = previousArgs
+ os.Stdin = previousStdin
+ httpPutFile = previousPut
+ httpGetFile = previousGet
+ })
+
+ caller := &productExampleCaller{}
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ os.Stdin = stdin
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ httpGetFile = func(_ context.Context, _ string, _ map[string]string, destination string) error {
+ if destination == "" {
+ return nil
+ }
+ return os.WriteFile(destination, []byte("download"), 0o600)
+ }
+
+ selected := map[string]bool{
+ "agoal": true, "aisearch": true, "aitable": true, "attendance": true,
+ "calendar": true, "chat": true, "contact": true, "devdoc": true,
+ "ding": true, "doc": true, "drive": true, "live": true,
+ "mail": true, "minutes": true, "oa": true, "report": true,
+ "sheet": true, "todo": true, "wiki": true,
+ }
+ invocations := 0
+ for _, root := range NewPublicCommands(&captureRunner{}) {
+ if !selected[root.Name()] {
+ continue
+ }
+ installExampleGlobalFlags(root)
+ root.SilenceErrors = true
+ root.SilenceUsage = true
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ os.Args = []string{"dws", root.Name(), "--yes"}
+
+ walkCommandMatrix(root, nil, func(command *cobra.Command, path []string) {
+ // Hidden cross-product proxies expect the commands to be mounted below
+ // the real dws root. Executing them on an isolated product root would
+ // resolve the proxy itself instead of its peer product.
+ if command.DisableFlagParsing {
+ return
+ }
+ base := append(append([]string(nil), path...), "--yes=true")
+ command.Flags().VisitAll(func(flag *pflag.Flag) {
+ if _, required := flag.Annotations[cobra.BashCompOneRequiredFlag]; required {
+ base = append(base, matrixFlagArg(flag, fixture))
+ }
+ })
+
+ variants := [][]string{
+ base,
+ matrixWithoutFlag(base, "yes"),
+ }
+ allFlags := append([]string(nil), base...)
+ var localFlags []*pflag.Flag
+ if len(command.Commands()) == 0 {
+ variants = append(variants,
+ append(append([]string(nil), base...), "item-id"),
+ append(append([]string(nil), base...), "item-id", "second-id"),
+ append(append([]string(nil), base...), "item-id", "second-id", "third-id"),
+ )
+ }
+ command.Flags().VisitAll(func(flag *pflag.Flag) {
+ if matrixGlobalFlag(flag.Name) || (flag.Name == "fields" && command.LocalNonPersistentFlags().Lookup(flag.Name) == nil) {
+ return
+ }
+ localFlags = append(localFlags, flag)
+ candidate := append(append([]string(nil), base...), matrixFlagArg(flag, fixture))
+ allFlags = append(allFlags, matrixFlagArg(flag, fixture))
+ variants = append(variants,
+ candidate,
+ append(append([]string(nil), candidate...), "item-id"),
+ append(append([]string(nil), candidate...), "item-id", "second-id"),
+ )
+ for _, invalid := range matrixInvalidFlagArgs(flag) {
+ withoutFlag := matrixWithoutFlag(base, flag.Name)
+ variants = append(variants,
+ append(append([]string(nil), withoutFlag...), invalid),
+ append(append(append([]string(nil), withoutFlag...), invalid), "item-id"),
+ )
+ }
+ for _, semantic := range matrixSemanticFlagArgs(flag) {
+ withoutFlag := matrixWithoutFlag(base, flag.Name)
+ variants = append(variants, append(append([]string(nil), withoutFlag...), semantic))
+ }
+ })
+ for left := 0; left < len(localFlags); left++ {
+ for right := left + 1; right < len(localFlags); right++ {
+ withoutPair := matrixWithoutFlag(matrixWithoutFlag(base, localFlags[left].Name), localFlags[right].Name)
+ variants = append(variants, append(withoutPair,
+ matrixFlagArg(localFlags[left], fixture),
+ matrixFlagArg(localFlags[right], fixture),
+ ))
+ for _, semantic := range matrixSemanticFlagArgs(localFlags[left]) {
+ variants = append(variants, append(append([]string(nil), withoutPair...), semantic, matrixFlagArg(localFlags[right], fixture)))
+ }
+ for _, semantic := range matrixSemanticFlagArgs(localFlags[right]) {
+ variants = append(variants, append(append([]string(nil), withoutPair...), matrixFlagArg(localFlags[left], fixture), semantic))
+ }
+ }
+ }
+ variants = append(variants, allFlags, append(append([]string(nil), allFlags...), "item-id"))
+
+ for index, invocation := range variants {
+ invocations++
+ caller.mode = ""
+ resetCommandFlags(root)
+ root.SetArgs(invocation)
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
+ _ = root.ExecuteContext(ctx)
+ cancel()
+
+ if index == 0 {
+ for _, mode := range []string{"list-shapes", "flat-record", "nested-data", "empty-result", "empty-content", "non-text", "invalid-json", "transport-error"} {
+ caller.mode = mode
+ resetCommandFlags(root)
+ root.SetArgs(invocation)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+ _ = root.ExecuteContext(ctx)
+ cancel()
+ }
+ }
+ }
+
+ noYes := matrixWithoutFlag(base, "yes")
+ for _, input := range []*os.File{noStdin, emptyStdin} {
+ _, _ = input.Seek(0, 0)
+ os.Stdin = input
+ caller.mode = ""
+ caller.dry = false
+ resetCommandFlags(root)
+ root.SetArgs(noYes)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+ _ = root.ExecuteContext(ctx)
+ cancel()
+ }
+ os.Stdin = stdin
+ caller.dry = true
+ resetCommandFlags(root)
+ root.SetArgs(base)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+ _ = root.ExecuteContext(ctx)
+ cancel()
+ caller.dry = false
+ })
+ }
+ if invocations < 1500 {
+ t.Fatalf("command matrix executed %d invocations", invocations)
+ }
+}
+
+func matrixWithoutFlag(args []string, name string) []string {
+ prefix := "--" + name
+ out := make([]string, 0, len(args))
+ for index := 0; index < len(args); index++ {
+ if args[index] == prefix {
+ index++
+ continue
+ }
+ if strings.HasPrefix(args[index], prefix+"=") {
+ continue
+ }
+ out = append(out, args[index])
+ }
+ return out
+}
+
+func matrixInvalidFlagArgs(flag *pflag.Flag) []string {
+ prefix := "--" + flag.Name + "="
+ switch flag.Value.Type() {
+ case "bool":
+ return []string{prefix + "false"}
+ case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64", "count":
+ return []string{prefix + "-1", prefix + "0", prefix + "999999999"}
+ case "duration":
+ return []string{prefix + "0s", prefix + "-1s"}
+ default:
+ return []string{prefix, prefix + "invalid", prefix + "0", prefix + "false"}
+ }
+}
+
+func matrixSemanticFlagArgs(flag *pflag.Flag) []string {
+ prefix := "--" + flag.Name + "="
+ var values []string
+ switch strings.ToLower(flag.Name) {
+ case "direction":
+ values = []string{"newer", "older", "sideways"}
+ case "grant-type":
+ values = []string{"once", "session", "timed", "permanent", "invalid"}
+ case "mode":
+ values = []string{"append", "overwrite", "replace", "invalid"}
+ case "scope":
+ values = []string{"企业", "全公司", "所有人", "chat.read", "invalid"}
+ case "setting-scene":
+ values = []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify", "invalid"}
+ case "content-format":
+ values = []string{"markdown", "jsonml", "plain", "invalid"}
+ case "role-types":
+ values = []string{"creator", "executor", "participant", "creator,executor", "invalid"}
+ case "action":
+ values = []string{"read", "unread", "markRead", "markUnread", "archive", "invalid"}
+ case "operation":
+ values = []string{"include", "exclude", "eq", "invalid"}
+ case "status":
+ values = []string{"true", "false", "pending", "success", "failed", "active", "invalid"}
+ case "type":
+ values = []string{"text", "image", "file", "link", "markdown", "singleSelect", "multiSelect", "invalid"}
+ case "base-time":
+ values = []string{"dueTime", "customTime", "invalid"}
+ case "view-type":
+ values = []string{"Grid", "Kanban", "Gantt", "Gallery", "Form", "invalid"}
+ case "recurrence-range-type":
+ values = []string{"noEnd", "endDate", "numbered", "invalid"}
+ case "recurrence-type":
+ values = []string{"daily", "weekly", "absoluteMonthly", "relativeMonthly", "absoluteYearly", "invalid"}
+ }
+ args := make([]string, 0, len(values))
+ for _, value := range values {
+ args = append(args, prefix+value)
+ }
+ return args
+}
+
+func walkCommandMatrix(command *cobra.Command, path []string, visit func(*cobra.Command, []string)) {
+ if command == nil {
+ return
+ }
+ if len(path) > 0 {
+ visit(command, path)
+ }
+ for _, child := range command.Commands() {
+ walkCommandMatrix(child, append(append([]string(nil), path...), child.Name()), visit)
+ }
+}
+
+func matrixGlobalFlag(name string) bool {
+ switch name {
+ case "format", "jq", "timeout", "dry-run", "yes", "help":
+ return true
+ default:
+ return false
+ }
+}
+
+func matrixFlagArg(flag *pflag.Flag, fixture string) string {
+ name := strings.ToLower(flag.Name)
+ value := "value"
+ switch flag.Value.Type() {
+ case "bool":
+ value = "true"
+ case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64", "count":
+ value = "1"
+ case "duration":
+ value = "1s"
+ case "stringSlice", "stringArray":
+ value = "item-id"
+ default:
+ switch {
+ case name == "records":
+ value = `[{"recordId":"record-id","cells":{"field-id":"value"}}]`
+ case name == "cells":
+ value = `{"field-id":"value"}`
+ case name == "fields":
+ value = `[{"fieldName":"Name","type":"text"}]`
+ case name == "options":
+ value = `[{"name":"Option"}]`
+ case name == "sort":
+ value = `[{"fieldId":"field-id","direction":"asc"}]`
+ case name == "values" || name == "rows":
+ value = `[["value"]]`
+ case name == "conditions":
+ value = `[{"object":"from","or":[{"and":[{"operation":"include","value":"user@example.com"}]}]}]`
+ case name == "properties":
+ value = `{"position":{"row":1,"col":1},"dimensions":{"width":100,"height":100},"chart":{"type":"line","series":[{"value":["A1:A2"]}]}}`
+ case name == "filters":
+ value = `{"fieldId":"field-id","operator":"eq","value":"value"}`
+ case name == "range":
+ value = "A1:B2"
+ case name == "action":
+ value = "markRead"
+ case name == "operation":
+ value = "include"
+ case strings.Contains(name, "file"), strings.Contains(name, "path"), strings.Contains(name, "attachment"), strings.Contains(name, "image"):
+ value = fixture
+ case strings.Contains(name, "email") || name == "from" || name == "to" || name == "cc" || name == "bcc":
+ value = "user@example.com"
+ case strings.Contains(name, "url") || strings.Contains(name, "webhook"):
+ value = "https://example.invalid/item"
+ case strings.Contains(name, "date"):
+ value = "2026-01-02"
+ case strings.Contains(name, "time"):
+ value = "2026-01-02 03:04:05"
+ case strings.Contains(name, "json") || strings.Contains(name, "properties") || strings.Contains(name, "conditions"):
+ value = "{}"
+ case strings.Contains(name, "type"):
+ value = "text"
+ case strings.Contains(name, "status"):
+ value = "active"
+ case strings.Contains(name, "role"):
+ value = "member"
+ case strings.Contains(name, "permission") || strings.Contains(name, "perm"):
+ value = "read"
+ case strings.Contains(name, "ids"):
+ value = "item-id,second-id"
+ case strings.Contains(name, "id") || strings.Contains(name, "cursor") || strings.Contains(name, "token"):
+ value = "item-id"
+ }
+ }
+ return "--" + flag.Name + "=" + value
+}
diff --git a/internal/helpers/connect_approval_card.go b/internal/helpers/connect_approval_card.go
index 21885a28..c7274bb0 100644
--- a/internal/helpers/connect_approval_card.go
+++ b/internal/helpers/connect_approval_card.go
@@ -137,7 +137,7 @@ func (s *sheetAuditSink) record(ctx context.Context, req *ApprovalRequest) {
return
}
if attempt < 3 && isTransientSheetErr(err) {
- time.Sleep(time.Duration(attempt) * 600 * time.Millisecond)
+ helperSleep(time.Duration(attempt) * 600 * time.Millisecond)
continue
}
break
diff --git a/internal/helpers/connect_approval_card_test.go b/internal/helpers/connect_approval_card_test.go
index 524e535e..7d0131a3 100644
--- a/internal/helpers/connect_approval_card_test.go
+++ b/internal/helpers/connect_approval_card_test.go
@@ -112,6 +112,48 @@ func (n *fakeNotifier) toUser(userID string) bool {
func (n *fakeNotifier) count() int { n.mu.Lock(); defer n.mu.Unlock(); return len(n.sent) }
+func TestDingTalkApprovalCardSenderCoverage(t *testing.T) {
+ if newDingtalkApprovalCardSender("client", "secret", " ") != nil {
+ t.Fatal("empty approval template created a sender")
+ }
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ sender, ok := newDingtalkApprovalCardSender("client", "secret", "template").(*dingtalkApprovalCardSender)
+ if !ok {
+ t.Fatal("approval sender type mismatch")
+ }
+ req := &ApprovalRequest{ID: "approval", Summary: "Create todo", Requester: "requester"}
+ track, err := sender.SendApprovalCard(context.Background(), "owner", req)
+ if err != nil || track != "dws_approval_approval" {
+ t.Fatalf("SendApprovalCard() = %q, %v", track, err)
+ }
+ if err := sender.UpdateApprovalCard(context.Background(), track, "done"); err != nil {
+ t.Fatal(err)
+ }
+ rec.fail["PUT /v1.0/card/instances"] = 500
+ if err := sender.UpdateApprovalCard(context.Background(), track, "failed"); err == nil {
+ t.Fatal("approval update HTTP failure was ignored")
+ }
+ rec.fail = map[string]int{"POST /v1.0/card/instances": 500}
+ if _, err := sender.SendApprovalCard(context.Background(), "owner", req); err == nil {
+ t.Fatal("approval create HTTP failure was ignored")
+ }
+}
+
+func TestApprovalAutoRetryLifecycleCoverage(t *testing.T) {
+ var nilOrchestrator *approvalOrchestrator
+ nilOrchestrator.startAutoRetry(context.Background(), time.Millisecond)
+ (&approvalOrchestrator{}).startAutoRetry(context.Background(), time.Millisecond)
+ notifier := &fakeNotifier{}
+ orchestrator := &approvalOrchestrator{notifier: notifier, gate: newApprovalGate("")}
+ orchestrator.startAutoRetry(context.Background(), 0)
+ ctx, cancel := context.WithCancel(context.Background())
+ orchestrator.startAutoRetry(ctx, time.Millisecond)
+ time.Sleep(3 * time.Millisecond)
+ cancel()
+ time.Sleep(time.Millisecond)
+}
+
// cardClickReq builds a card callback as DingTalk delivers it: the tapped
// button's action id plus its private params (approval id + decision).
func cardClickReq(outTrackID, approvalID, decision, actionID, userID string) *card.CardRequest {
diff --git a/internal/helpers/connect_at_poll.go b/internal/helpers/connect_at_poll.go
index 8a33a6d7..85e9772f 100644
--- a/internal/helpers/connect_at_poll.go
+++ b/internal/helpers/connect_at_poll.go
@@ -61,7 +61,7 @@ type atMentionMessage struct {
func (p *atMentionPoller) start(ctx context.Context) {
go func() {
select {
- case <-time.After(3 * time.Second):
+ case <-helperAfter(3 * time.Second):
case <-ctx.Done():
return
}
diff --git a/internal/helpers/connect_card_test.go b/internal/helpers/connect_card_test.go
index c8671034..d3a1ed96 100644
--- a/internal/helpers/connect_card_test.go
+++ b/internal/helpers/connect_card_test.go
@@ -22,6 +22,7 @@ import (
"strings"
"sync"
"testing"
+ "time"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
)
@@ -296,3 +297,45 @@ func TestAICardCustomTemplate(t *testing.T) {
t.Fatalf("cardTemplateId = %v, want custom template", rec.bodies[0]["cardTemplateId"])
}
}
+
+func TestAICardOTOAndRepairCoverage(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
+ if err := c.sendOTOText(context.Background(), nil, "ignored"); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.sendOTOText(context.Background(), []string{"u1", "u2"}, "hello"); err != nil {
+ t.Fatal(err)
+ }
+ c.repair(context.Background(), &aiCardInstance{outTrackID: "track"}, "fixed")
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ oldDelay := aiCardRepairDelay
+ aiCardRepairDelay = time.Hour
+ c.repair(cancelled, &aiCardInstance{outTrackID: "track"}, "ignored")
+ aiCardRepairDelay = oldDelay
+ if len(rec.calls) < 2 || rec.calls[0] != "POST /v1.0/robot/oToMessages/batchSend" {
+ t.Fatalf("OTO/repair calls = %#v", rec.calls)
+ }
+}
+
+func TestAtMentionSendGroupReplyCoverage(t *testing.T) {
+ _, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ poller := &atMentionPoller{
+ clientID: "ding-client",
+ channel: "test",
+ botClient: newAICardClient("ding-client", "ding-secret", ""),
+ }
+ if err := poller.sendGroupReply(context.Background(), "conversation", "reply"); err != nil {
+ t.Fatal(err)
+ }
+
+ oldBase := dingtalkCardAPIBase
+ dingtalkCardAPIBase = "%"
+ if err := poller.sendGroupReply(context.Background(), "conversation", "reply"); err == nil {
+ t.Fatal("invalid group reply URL succeeded")
+ }
+ dingtalkCardAPIBase = oldBase
+}
diff --git a/internal/helpers/connect_coverage_more_test.go b/internal/helpers/connect_coverage_more_test.go
new file mode 100644
index 00000000..3fd45949
--- /dev/null
+++ b/internal/helpers/connect_coverage_more_test.go
@@ -0,0 +1,387 @@
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/spf13/cobra"
+)
+
+type bufferWriteCloser struct{ bytes.Buffer }
+
+func (*bufferWriteCloser) Close() error { return nil }
+
+type connectResponseRunner struct {
+ response map[string]any
+ err error
+}
+
+func (r connectResponseRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
+ return executor.Result{Invocation: invocation, Response: r.response}, r.err
+}
+
+func TestConnectForwarderPureCoverage(t *testing.T) {
+ streamSDKLogger{}.Debugf("debug %s", "x")
+ streamSDKLogger{}.Infof("info %s", "x")
+ streamSDKLogger{}.Warningf("warn %s", "x")
+ streamSDKLogger{}.Errorf("error %s", "x")
+ streamSDKLogger{}.Fatalf("fatal %s", "x")
+ _ = agentNotInstalled("channel", "app", "install")
+ _ = insertBeforeArg([]string{"bin", "--marker", "tail"}, "--marker", "inserted")
+ _ = insertBeforeArg([]string{"bin", "tail"}, "--missing", "inserted")
+
+ sessions := newConvSessions("")
+ execFwd := &execForwarder{name: "agent", argv: []string{"bin"}, sessions: sessions}
+ _ = execFwd.label()
+ _ = execFwd.hasSession("conversation")
+ execFwd.resetSession("")
+ execFwd.resetSession("conversation")
+ statelessExec := &execForwarder{name: "agent", argv: []string{"bin"}}
+ _ = statelessExec.label()
+ statelessExec.resetSession("conversation")
+
+ qoder := newQoderStreamForwarder("qoder", "bin", nil, time.Second, connectAgentOptions{}, sessions).(*qoderStreamForwarder)
+ if !qoder.canStream() || qoder.label() == "" {
+ t.Fatal("qoder capabilities")
+ }
+ qoder.resetSession("conversation")
+ qoderStateless := newQoderStreamForwarder("qoder", "bin", nil, time.Second, connectAgentOptions{}, nil).(*qoderStreamForwarder)
+ _ = qoderStateless.label()
+ qoderStateless.resetSession("conversation")
+ _, _ = qoderStateless.forward(context.Background(), "conversation", "hello")
+ var locked lockedStringBuffer
+ _, _ = locked.Write([]byte("stderr"))
+ if locked.String() != "stderr" {
+ t.Fatal("locked string buffer")
+ }
+
+ codex := newCodexAppServerForwarder("bin", nil, time.Second, connectAgentOptions{Memory: true, Model: "model", Yolo: true}, "").(*codexAppServerForwarder)
+ if !codex.canStream() || codex.label() == "" {
+ t.Fatal("codex capabilities")
+ }
+ codex.resetSession("conversation")
+ _ = codex.threadParams("")
+ _ = codex.threadParams("thread")
+ codexStateless := newCodexAppServerForwarder("bin", nil, time.Second, connectAgentOptions{}, "").(*codexAppServerForwarder)
+ _ = codexStateless.label()
+ codexStateless.resetSession("conversation")
+ var codexBuffer lockedBuffer
+ _, _ = codexBuffer.Write([]byte("stderr"))
+ _ = codexBuffer.String()
+ stdin := &bufferWriteCloser{}
+ client := &codexAppServerClient{stdin: stdin, stderr: &lockedBuffer{}}
+ client.rejectServerRequest(1, "request/input")
+ _ = client.withStderr("prefix", errors.New("failure"))
+ _, _ = client.stderr.Write([]byte("detail"))
+ _ = client.withStderr("prefix", errors.New("failure"))
+
+ opencode := newOpencodeForwarder("bin", nil, time.Second, connectAgentOptions{Memory: true}, "").(*opencodeForwarder)
+ if opencode.canStream() || opencode.label() == "" {
+ t.Fatal("opencode capabilities")
+ }
+ opencode.resetSession("conversation")
+ if err := opencode.close(); err != nil {
+ t.Fatal(err)
+ }
+ opencodeStateless := newOpencodeForwarder("bin", nil, time.Second, connectAgentOptions{}, "").(*opencodeForwarder)
+ _ = opencodeStateless.label()
+ opencodeStateless.resetSession("conversation")
+ opencodeStateless.server = nil
+ if err := opencodeStateless.close(); err != nil {
+ t.Fatal(err)
+ }
+ opencodeBroken := newOpencodeForwarder(filepath.Join(t.TempDir(), "missing"), nil, time.Second, connectAgentOptions{}, "").(*opencodeForwarder)
+ _, _ = opencodeBroken.forward(context.Background(), "conversation", "hello")
+ if port, err := freeLocalPort(); err != nil || port == 0 {
+ t.Fatalf("free port = %d, %v", port, err)
+ }
+ if randomHex(4) == "" {
+ t.Fatal("random hex is empty")
+ }
+
+ cmd := exec.Command("sh", "-c", "exit 0")
+ applyDetach(cmd)
+ configureWorkerProcessGroup(cmd)
+ _ = detachSysProcAttr()
+ cleanupWorkerProcessGroup(0)
+ _ = runAgentInstall("channel", agentSpec{app: "app", install: []string{"sh", "-c", "exit 0"}})
+ _ = runAgentInstall("channel", agentSpec{app: "app", install: []string{"sh", "-c", "exit 1"}})
+ _ = officialChannelGuidance("openclaw")
+ _ = officialChannelGuidance("hermes")
+ _ = officialChannelGuidance("unknown")
+}
+
+func TestGeminiAPIForwarderCoverage(t *testing.T) {
+ t.Setenv("GEMINI_API_KEY", "")
+ t.Setenv("GOOGLE_API_KEY", "")
+ if _, err := newGeminiAPIForwarder(0, connectAgentOptions{}); err == nil {
+ t.Fatal("missing Gemini key succeeded")
+ }
+ t.Setenv("GOOGLE_API_KEY", " key ")
+ t.Setenv("GOOGLE_GEMINI_API_BASE_URL", "https://proxy.example/")
+ fwd, err := newGeminiAPIForwarder(0, connectAgentOptions{})
+ if err != nil || fwd.label() == "" {
+ t.Fatalf("Gemini forwarder = %#v, %v", fwd, err)
+ }
+
+ responses := []struct {
+ status int
+ body string
+ }{
+ {http.StatusInternalServerError, `failure`},
+ {http.StatusOK, `{`},
+ {http.StatusOK, `{"error":{"status":"BAD","message":"failed"}}`},
+ {http.StatusOK, `{"promptFeedback":{"blockReason":"SAFETY"}}`},
+ {http.StatusOK, `{}`},
+ {http.StatusOK, `{"candidates":[{"content":{"parts":[{"text":" one "},{"text":"two"}]}}]}`},
+ }
+ for _, response := range responses {
+ response := response
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(response.status)
+ _, _ = io.WriteString(w, response.body)
+ }))
+ g := &geminiAPIForwarder{model: "models/test/model", apiKey: "key", baseURL: server.URL, timeout: time.Second, httpClient: server.Client()}
+ _, _ = g.forward(context.Background(), "", "hello")
+ server.Close()
+ }
+ for _, g := range []*geminiAPIForwarder{{baseURL: "%", model: "m"}, {baseURL: "", model: ""}} {
+ _, _ = g.generateContentEndpoint()
+ }
+}
+
+func TestConnectDaemonAndApprovalPureCoverage(t *testing.T) {
+ oldOverride := connectDaemonDirOverride
+ connectDaemonDirOverride = t.TempDir()
+ t.Cleanup(func() { connectDaemonDirOverride = oldOverride })
+
+ cmd := &cobra.Command{Use: "status"}
+ cmd.Flags().String("robot-client-id", "", "")
+ cmd.Flags().String("unified-app-id", "", "")
+ if _, err := connectDaemonDirKeyFromFlags(cmd); err == nil {
+ t.Fatal("empty daemon identity succeeded")
+ }
+ _ = cmd.Flags().Set("robot-client-id", "client")
+ if key, err := connectDaemonDirKeyFromFlags(cmd); err != nil || key == "" {
+ t.Fatalf("daemon key = %q, %v", key, err)
+ }
+ _ = colorConnectState(healthHealthy)
+ _ = colorConnectState(healthDegraded)
+ _ = colorConnectState(healthDown)
+ _ = colorConnectState("unknown")
+ var table bytes.Buffer
+ if err := writeConnectListTable(&table, []connectHealthReport{{State: healthHealthy, AppName: strings.Repeat("长", 60), ClientID: "client", Pid: 2, Channel: "codex", UptimeSec: 3}, {State: healthNotRunning}}); err != nil {
+ t.Fatal(err)
+ }
+ daemonNotifyStateChange("", "channel", "client", "started", "")
+ daemonNotifyStateChange("staff", "channel", "client", "unknown", "")
+
+ worker := exec.Command("sh", "-c", "exit 0")
+ if err := worker.Start(); err != nil {
+ t.Fatal(err)
+ }
+ if err := superviseWait(context.Background(), worker); err != nil {
+ t.Fatal(err)
+ }
+ cancelled, cancel := context.WithCancel(context.Background())
+ worker = exec.Command("sh", "-c", "sleep 10")
+ if err := worker.Start(); err != nil {
+ t.Fatal(err)
+ }
+ cancel()
+ _ = superviseWait(cancelled, worker)
+
+ runner := connectResponseRunner{response: map[string]any{"items": []any{map[string]any{"unifiedAppId": "app", "name": "Name"}, "bad"}, "hasMore": false}}
+ reports := []connectHealthReport{{UnifiedAppID: "app"}, {ClientID: "client"}}
+ resolveAppNames(cmd, runner, reports)
+ resolveAppNames(cmd, runner, []connectHealthReport{{ClientID: "client"}})
+ _, _ = devAppNameMap(cmd, connectResponseRunner{err: errors.New("failed")})
+ _ = devAppConnectList(nil)
+ _ = devAppConnectList(map[string]any{"items": "bad"})
+
+ gate := newApprovalGate("")
+ req := gate.Submit(ApprovalRequest{Requester: "user", State: approvalApproved, Summary: "summary"})
+ gate.markFailed(req.ID, strings.Repeat("failure", 100))
+ gate.markFailed("missing", "failure")
+ sink := &sheetAuditSink{}
+ sink.record(context.Background(), req)
+ sink = &sheetAuditSink{runner: runner, nodeID: "node", sheetID: "sheet"}
+ sink.record(context.Background(), req)
+ orchestrator := &approvalOrchestrator{}
+ _ = orchestrator.decorateForActionDetection("prompt")
+ orchestrator = &approvalOrchestrator{ownerUserID: "owner", gate: gate, textMode: true}
+ _ = orchestrator.decorateForActionDetection("prompt")
+}
+
+func TestAtPollTextAndCancellationCoverage(t *testing.T) {
+ for _, tc := range []struct{ content, kind string }{
+ {"", "text"},
+ {`{"text":" hello "}`, "text"},
+ {`{"content":" body "}`, "rich"},
+ {`{"other":1}`, "rich"},
+ {"plain", "text"},
+ } {
+ _ = extractAtPollText(tc.content, tc.kind)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ poller := &atMentionPoller{}
+ poller.start(ctx)
+ poller.poll(ctx)
+ poller.handleMessage(ctx, atMentionMessage{})
+ time.Sleep(5 * time.Millisecond)
+
+ var payload map[string]any
+ _ = json.Unmarshal([]byte(`{"ok":true}`), &payload)
+}
+
+func TestConnectStreamRemainingPureEdges(t *testing.T) {
+ if convSessionKey(" ") != "_default" || convSessionKey(" conv ") != "conv" {
+ t.Fatal("conversation key normalization failed")
+ }
+ if execErrorMessage(nil) != "" {
+ t.Fatal("nil execution error should be empty")
+ }
+ cmd := exec.Command("sh", "-c", "printf detail >&2; exit 7")
+ if _, err := cmd.Output(); err != nil {
+ if got := execErrorMessage(err); got != "detail" {
+ t.Fatalf("exit stderr = %q, want detail", got)
+ }
+ } else {
+ t.Fatal("failing command unexpectedly succeeded")
+ }
+
+ workDir := t.TempDir()
+ fwd := &execForwarder{workDir: workDir, env: []string{"DWS_TEST_CONNECT_ENV=1"}}
+ execCmd := exec.Command("sh")
+ fwd.configureCommand(execCmd)
+ if execCmd.Dir != workDir || !containsString(execCmd.Env, "DWS_TEST_CONNECT_ENV=1") {
+ t.Fatalf("configured command = dir %q env %#v", execCmd.Dir, execCmd.Env)
+ }
+
+ if got := envOr("DWS_TEST_ENV_OR_MISSING", "fallback"); got != "fallback" {
+ t.Fatalf("envOr missing = %q", got)
+ }
+ t.Setenv("DWS_TEST_ENV_OR", " value ")
+ if got := envOr("DWS_TEST_ENV_OR", "fallback"); got != "value" {
+ t.Fatalf("envOr value = %q", got)
+ }
+ ctx := context.Background()
+ plain, cancel := applyTimeout(ctx, 0)
+ cancel()
+ if plain != ctx {
+ t.Fatal("zero timeout should keep original context")
+ }
+ bounded, cancel := applyTimeout(ctx, time.Millisecond)
+ cancel()
+ if bounded == ctx {
+ t.Fatal("positive timeout should derive a context")
+ }
+ if truncateRunes("中文abc", 2) != "中文…" || truncateRunes("短", 2) != "短" {
+ t.Fatal("rune truncation mismatch")
+ }
+
+ turns := []connectQueuedTurn{
+ {},
+ {picCode: "picture-code"},
+ {fileInfo: fileInboundInfo{FileName: " named.txt ", DownloadCode: "download"}},
+ {fileInfo: fileInboundInfo{DownloadCode: "download"}},
+ }
+ wants := []string{"[空消息]", "[图片]", "[文件: named.txt]", "[文件]"}
+ for i, turn := range turns {
+ if got := connectTurnSummary(turn); got != wants[i] {
+ t.Errorf("summary %d = %q, want %q", i, got, wants[i])
+ }
+ }
+ if got := mergeConnectQueuedTurns(nil); got.text != "" || got.msgID != "" {
+ t.Fatalf("empty merge = %#v", got)
+ }
+ one := connectQueuedTurn{text: "one", msgID: "m1"}
+ if got := mergeConnectQueuedTurns([]connectQueuedTurn{one}); got.text != one.text || got.msgID != one.msgID {
+ t.Fatalf("single merge = %#v", got)
+ }
+ mergedPicture := mergeConnectQueuedTurns([]connectQueuedTurn{
+ {picCode: "old-picture", msgID: "m1"},
+ {text: "latest", msgID: "m2"},
+ })
+ if mergedPicture.picCode != "old-picture" {
+ t.Fatalf("merged picture = %#v", mergedPicture)
+ }
+ mergedFile := mergeConnectQueuedTurns([]connectQueuedTurn{
+ {fileInfo: fileInboundInfo{FileName: "old", DownloadCode: "code"}, msgID: "m1"},
+ {text: "latest", msgID: "m2"},
+ })
+ if !mergedFile.fileInfo.hasActionable() {
+ t.Fatalf("merged file = %#v", mergedFile)
+ }
+}
+
+func TestConnectCLIResolutionEdges(t *testing.T) {
+ clearChannelEnv(t)
+ t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
+ for _, channel := range []string{"openclaw", "hermes", "custom", "unknown"} {
+ _ = connectCliStatus(channel)
+ }
+ t.Setenv("DWS_AGENT_CMD", "custom-agent --flag")
+ custom := connectCliStatus("custom")
+ if custom["installed"] != true || custom["command"] != "custom-agent --flag" {
+ t.Fatalf("custom status = %#v", custom)
+ }
+
+ binDir := t.TempDir()
+ writeShellExecutable(t, binDir, "edge-agent", "exit 0\n")
+ t.Setenv("PATH", binDir)
+ original, hadOriginal := agentSpecs["edge"]
+ agentSpecs["edge"] = agentSpec{
+ app: "Edge Agent",
+ bins: []string{"edge-agent"},
+ argvTail: []string{"--headless"},
+ envFn: func() []string { return []string{"EDGE_ENV=1"} },
+ hint: "install edge",
+ }
+ t.Cleanup(func() {
+ if hadOriginal {
+ agentSpecs["edge"] = original
+ } else {
+ delete(agentSpecs, "edge")
+ }
+ })
+ t.Setenv("DWS_AGENT_CMD", "")
+ argv, env, err := resolveExecAgent("edge")
+ if err != nil || len(argv) != 2 || argv[1] != "--headless" || !reflect.DeepEqual(env, []string{"EDGE_ENV=1"}) {
+ t.Fatalf("resolved edge agent = %#v %#v %v", argv, env, err)
+ }
+ if _, _, err := resolveExecAgent("missing-channel"); err == nil {
+ t.Fatal("unknown exec channel should fail")
+ }
+ status := connectCliStatus("edge")
+ if status["installed"] != true || status["path"] == "" {
+ t.Fatalf("edge status = %#v", status)
+ }
+
+ t.Setenv("HOME", "")
+ t.Setenv("CLAUDE_CONFIG_DIR", "")
+ _ = homeDir()
+ _ = claudeUserSettingsEnv()
+}
+
+func containsString(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/helpers/connect_daemon.go b/internal/helpers/connect_daemon.go
index b097364f..d12ca214 100644
--- a/internal/helpers/connect_daemon.go
+++ b/internal/helpers/connect_daemon.go
@@ -82,6 +82,26 @@ type daemonState struct {
// away from the real ~/.dws tree. Empty means use config.DefaultConfigDir.
var connectDaemonDirOverride string
+// Process and clock hooks keep daemon lifecycle tests deterministic while the
+// production defaults remain the operating-system implementations.
+var (
+ daemonDetachEnabled = daemonDetachSupported
+ daemonExecutable = os.Executable
+ daemonCommand = exec.Command
+ daemonNow = time.Now
+ daemonCreateTemp = os.CreateTemp
+ daemonFileChmod = func(file *os.File, mode os.FileMode) error { return file.Chmod(mode) }
+ daemonCopy = io.Copy
+ daemonFileSync = func(file *os.File) error { return file.Sync() }
+ daemonFileClose = func(file *os.File) error { return file.Close() }
+ daemonRename = os.Rename
+ daemonFindProcess = os.FindProcess
+ daemonProcessAlive = processAlive
+ daemonSignalContext = func() (context.Context, context.CancelFunc) {
+ return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ }
+)
+
// daemonDirKey derives a filesystem-safe directory key identifying a connector.
// Priority: clientId (the robot's AppKey, the natural identity) > unifiedAppID.
// Reuses sanitizeLockID (connect_lock.go) so the key matches the lock naming
@@ -128,31 +148,31 @@ func stageDaemonExecutable(src, dir string) (string, error) {
}
defer in.Close()
- tmp, err := os.CreateTemp(dir, ".dws-daemon-*")
+ tmp, err := daemonCreateTemp(dir, ".dws-daemon-*")
if err != nil {
return "", err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
- if err := tmp.Chmod(daemonExecutablePerm); err != nil {
+ if err := daemonFileChmod(tmp, daemonExecutablePerm); err != nil {
tmp.Close()
return "", err
}
- if _, err := io.Copy(tmp, in); err != nil {
+ if _, err := daemonCopy(tmp, in); err != nil {
tmp.Close()
return "", err
}
- if err := tmp.Sync(); err != nil {
+ if err := daemonFileSync(tmp); err != nil {
tmp.Close()
return "", err
}
- if err := tmp.Close(); err != nil {
+ if err := daemonFileClose(tmp); err != nil {
return "", err
}
dst := daemonExecutablePath(dir)
- if err := os.Rename(tmpPath, dst); err != nil {
+ if err := daemonRename(tmpPath, dst); err != nil {
return "", err
}
return dst, nil
@@ -161,15 +181,12 @@ func stageDaemonExecutable(src, dir string) (string, error) {
// writeDaemonState atomically persists the daemon state to daemon-state.json
// (persistent, survives supervisor exit) so restart/list can recover config.
func writeDaemonState(dir string, st daemonState) error {
- data, err := json.MarshalIndent(st, "", " ")
- if err != nil {
- return err
- }
+ data, _ := json.MarshalIndent(st, "", " ")
tmp := daemonStatePath(dir) + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return err
}
- return os.Rename(tmp, daemonStatePath(dir))
+ return daemonRename(tmp, daemonStatePath(dir))
}
// readDaemonState loads the daemon state. Reads daemon-state.json (persistent)
@@ -253,7 +270,7 @@ func buildWorkerArgs(args []string) []string {
// detached from the terminal, writes nothing itself to the worker log (the
// supervisor does), prints the pid + log path, and returns so the parent exits.
func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, notifyStaffID, profile string, alwaysOn bool) error {
- if !daemonDetachSupported {
+ if !daemonDetachEnabled {
return apperrors.NewValidation("--daemon is not supported on this OS; run the foreground connector under a service manager instead")
}
dir, err := connectDaemonDir(dirKey)
@@ -263,11 +280,11 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
// Refuse to start a second supervisor for the same connector. The Stream
// single-instance lock would also catch this at the worker layer, but a
// pre-flight check gives a clearer message and avoids an orphaned supervisor.
- if st, _ := readDaemonState(dir); st != nil && st.Pid > 0 && processAlive(st.Pid) {
+ if st, _ := readDaemonState(dir); st != nil && st.Pid > 0 && daemonProcessAlive(st.Pid) {
return apperrors.NewValidation(fmt.Sprintf("a connect daemon is already running for %s (pid %d); use `robot connect status`/`stop`", dirKey, st.Pid))
}
- exe, err := os.Executable()
+ exe, err := daemonExecutable()
if err != nil {
return apperrors.NewInternal("resolve executable: " + err.Error())
}
@@ -284,7 +301,7 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
}
defer logFile.Close()
- child := exec.Command(exe, superviseArgs...)
+ child := daemonCommand(exe, superviseArgs...)
child.Stdout = logFile
child.Stderr = logFile
child.Stdin = nil
@@ -375,7 +392,7 @@ func runSupervisor(cmd *cobra.Command) error {
}
st := daemonState{
Pid: os.Getpid(),
- StartUnix: time.Now().Unix(),
+ StartUnix: daemonNow().Unix(),
LogPath: daemonLogPath(dir),
DirKey: dirKey,
ClientID: clientID,
@@ -404,11 +421,11 @@ func runSupervisor(cmd *cobra.Command) error {
// The supervisor reacts to SIGTERM/SIGINT itself (cmd.Context from root is
// already wired to these). We use an independent NotifyContext so cancelling
// it stops the loop and lets us forward the signal to the worker explicitly.
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ ctx, stop := daemonSignalContext()
defer stop()
workerArgs := buildWorkerArgs(os.Args[1:])
- exe, err := os.Executable()
+ exe, err := daemonExecutable()
if err != nil {
return apperrors.NewInternal("resolve executable: " + err.Error())
}
@@ -422,7 +439,7 @@ func runSupervisor(cmd *cobra.Command) error {
case <-ctx.Done():
daemonNotifyStateChange(notifyStaffID, channel, clientID, "stopped", "")
return nil
- case <-time.After(delay):
+ case <-helperAfter(delay):
}
}
if ctx.Err() != nil {
@@ -430,8 +447,8 @@ func runSupervisor(cmd *cobra.Command) error {
return nil
}
- started := time.Now()
- worker := exec.Command(exe, workerArgs...)
+ started := daemonNow()
+ worker := daemonCommand(exe, workerArgs...)
worker.Stdout = out
worker.Stderr = out
worker.Env = os.Environ()
@@ -449,7 +466,7 @@ func runSupervisor(cmd *cobra.Command) error {
waitErr := superviseWait(ctx, worker)
cleanupWorkerProcessGroup(worker.Process.Pid)
- ran := time.Since(started)
+ ran := daemonNow().Sub(started)
if ctx.Err() != nil {
// We were asked to stop; the worker has been (or is being) signalled.
@@ -492,7 +509,7 @@ func superviseWait(ctx context.Context, worker *exec.Cmd) error {
select {
case err := <-done:
return err
- case <-time.After(daemonStopTimeout):
+ case <-helperAfter(daemonStopTimeout):
_ = worker.Process.Kill()
return <-done
}
@@ -513,19 +530,16 @@ func daemonStatus(w io.Writer, dirKey string, jsonOut bool) error {
if err != nil {
return apperrors.NewInternal(err.Error())
}
- supervised := st != nil && st.Pid > 0 && processAlive(st.Pid)
+ supervised := st != nil && st.Pid > 0 && daemonProcessAlive(st.Pid)
hb, err := readConnectHeartbeat(dir)
if err != nil {
return apperrors.NewInternal("read connector heartbeat: " + err.Error())
}
- report := deriveConnectHealth(hb, supervised, time.Now())
+ report := deriveConnectHealth(hb, supervised, daemonNow())
if jsonOut {
- data, merr := json.MarshalIndent(report, "", " ")
- if merr != nil {
- return apperrors.NewInternal(merr.Error())
- }
+ data, _ := json.MarshalIndent(report, "", " ")
fmt.Fprintln(w, string(data))
return nil
}
@@ -585,25 +599,25 @@ func daemonStop(w io.Writer, dirKey string) error {
fmt.Fprintf(w, "connect daemon: not running (nothing to stop)\n")
return nil
}
- if !processAlive(st.Pid) {
+ if !daemonProcessAlive(st.Pid) {
_ = os.Remove(daemonPidPath(dir))
// The supervisor is dead, but its worker may still be alive (e.g. the
// supervisor was kill -9'd). Check the heartbeat for the worker pid and
// stop it so we don't leave an orphan.
- if hb, _ := readConnectHeartbeat(dir); hb != nil && hb.Pid > 0 && processAlive(hb.Pid) {
+ if hb, _ := readConnectHeartbeat(dir); hb != nil && hb.Pid > 0 && daemonProcessAlive(hb.Pid) {
fmt.Fprintf(w, "connect daemon: supervisor (pid %d) was dead, stopping orphan worker (pid %d)...\n", st.Pid, hb.Pid)
- if proc, perr := os.FindProcess(hb.Pid); perr == nil {
+ if proc, perr := daemonFindProcess(hb.Pid); perr == nil {
_ = proc.Signal(syscall.SIGTERM)
- deadline := time.Now().Add(daemonStopTimeout)
- for time.Now().Before(deadline) {
- if !processAlive(hb.Pid) {
+ deadline := daemonNow().Add(daemonStopTimeout)
+ for daemonNow().Before(deadline) {
+ if !daemonProcessAlive(hb.Pid) {
break
}
- time.Sleep(200 * time.Millisecond)
+ helperSleep(200 * time.Millisecond)
}
- if processAlive(hb.Pid) {
+ if daemonProcessAlive(hb.Pid) {
_ = proc.Signal(syscall.SIGKILL)
- time.Sleep(200 * time.Millisecond)
+ helperSleep(200 * time.Millisecond)
}
}
fmt.Fprintf(w, "connect daemon: orphan worker stopped (pid %d)\n", hb.Pid)
@@ -612,7 +626,7 @@ func daemonStop(w io.Writer, dirKey string) error {
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
return nil
}
- proc, err := os.FindProcess(st.Pid)
+ proc, err := daemonFindProcess(st.Pid)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
}
@@ -621,18 +635,18 @@ func daemonStop(w io.Writer, dirKey string) error {
}
fmt.Fprintf(w, "sent SIGTERM to connect daemon (pid %d), waiting for graceful stop...\n", st.Pid)
- deadline := time.Now().Add(daemonStopTimeout)
- for time.Now().Before(deadline) {
- if !processAlive(st.Pid) {
+ deadline := daemonNow().Add(daemonStopTimeout)
+ for daemonNow().Before(deadline) {
+ if !daemonProcessAlive(st.Pid) {
_ = os.Remove(daemonPidPath(dir))
fmt.Fprintf(w, "connect daemon stopped (pid %d)\n", st.Pid)
return nil
}
- time.Sleep(200 * time.Millisecond)
+ helperSleep(200 * time.Millisecond)
}
// Graceful window elapsed; force kill.
_ = proc.Signal(syscall.SIGKILL)
- time.Sleep(200 * time.Millisecond)
+ helperSleep(200 * time.Millisecond)
_ = os.Remove(daemonPidPath(dir))
fmt.Fprintf(w, "connect daemon did not stop in %s; sent SIGKILL (pid %d)\n", daemonStopTimeout, st.Pid)
return nil
@@ -723,7 +737,7 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
}
// Re-exec dws dev connect --daemon with the stored flags. An explicit
// --profile on this invocation overrides the persisted one.
- exe, err := os.Executable()
+ exe, err := daemonExecutable()
if err != nil {
return apperrors.NewInternal("resolve executable: " + err.Error())
}
@@ -749,7 +763,7 @@ func newDevAppRobotConnectRestartCommand() *cobra.Command {
// returns quickly, so waiting here costs nothing and lets a failed
// relaunch (e.g. credential fetch error) surface as a non-zero exit
// instead of a silent success.
- restartCmd := exec.Command(exe, args...)
+ restartCmd := daemonCommand(exe, args...)
restartCmd.Stdout = cmd.OutOrStdout()
restartCmd.Stderr = cmd.OutOrStderr()
restartCmd.Stdin = nil
@@ -776,17 +790,14 @@ func newDevAppRobotConnectListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
- reports, err := listConnectors(time.Now())
+ reports, err := listConnectors(daemonNow())
if err != nil {
return apperrors.NewInternal(err.Error())
}
resolveAppNames(cmd, runner, reports)
w := cmd.OutOrStdout()
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
- data, merr := json.MarshalIndent(reports, "", " ")
- if merr != nil {
- return apperrors.NewInternal(merr.Error())
- }
+ data, _ := json.MarshalIndent(reports, "", " ")
fmt.Fprintln(w, string(data))
return nil
}
@@ -1030,12 +1041,12 @@ func daemonNotifyStateChange(notifyStaffID, channel, clientID, event, detail str
default:
return
}
- exe, err := os.Executable()
+ exe, err := daemonExecutable()
if err != nil {
return
}
go func() {
- cmd := exec.Command(exe, "chat", "message", "send",
+ cmd := daemonCommand(exe, "chat", "message", "send",
"--staff-id", notifyStaffID,
"--text", msg,
"--ai-tag=false",
diff --git a/internal/helpers/connect_daemon_coverage_more_test.go b/internal/helpers/connect_daemon_coverage_more_test.go
new file mode 100644
index 00000000..db7a48d3
--- /dev/null
+++ b/internal/helpers/connect_daemon_coverage_more_test.go
@@ -0,0 +1,914 @@
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/spf13/cobra"
+)
+
+func preserveDaemonHooks(t *testing.T) {
+ t.Helper()
+ oldDetach := daemonDetachEnabled
+ oldExecutable := daemonExecutable
+ oldCommand := daemonCommand
+ oldNow := daemonNow
+ oldCreateTemp := daemonCreateTemp
+ oldFileChmod := daemonFileChmod
+ oldCopy := daemonCopy
+ oldFileSync := daemonFileSync
+ oldFileClose := daemonFileClose
+ oldRename := daemonRename
+ oldFindProcess := daemonFindProcess
+ oldProcessAlive := daemonProcessAlive
+ oldSignalContext := daemonSignalContext
+ oldDir := connectDaemonDirOverride
+ oldAfter := helperAfter
+ oldSleep := helperSleep
+ t.Cleanup(func() {
+ daemonDetachEnabled = oldDetach
+ daemonExecutable = oldExecutable
+ daemonCommand = oldCommand
+ daemonNow = oldNow
+ daemonCreateTemp = oldCreateTemp
+ daemonFileChmod = oldFileChmod
+ daemonCopy = oldCopy
+ daemonFileSync = oldFileSync
+ daemonFileClose = oldFileClose
+ daemonRename = oldRename
+ daemonFindProcess = oldFindProcess
+ daemonProcessAlive = oldProcessAlive
+ daemonSignalContext = oldSignalContext
+ connectDaemonDirOverride = oldDir
+ helperAfter = oldAfter
+ helperSleep = oldSleep
+ })
+}
+
+func daemonTestCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "connect"}
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ return cmd
+}
+
+func instantAfter(time.Duration) <-chan time.Time {
+ ch := make(chan time.Time, 1)
+ ch <- time.Now()
+ return ch
+}
+
+func TestStartDaemonLifecycleEdges(t *testing.T) {
+ t.Run("unsupported", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ daemonDetachEnabled = false
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("unsupported daemon start succeeded")
+ }
+ })
+
+ t.Run("directory error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ connectDaemonDirOverride = blocked
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("daemon start with blocked directory succeeded")
+ }
+ })
+
+ t.Run("already running", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ dir, err := connectDaemonDir("key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := writeDaemonState(dir, daemonState{Pid: os.Getpid(), DirKey: "key"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("duplicate daemon start succeeded")
+ }
+ })
+
+ t.Run("executable error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ daemonExecutable = func() (string, error) { return "", errors.New("executable") }
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("daemon start without executable succeeded")
+ }
+ })
+
+ t.Run("stage error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ daemonExecutable = func() (string, error) { return filepath.Join(t.TempDir(), "missing"), nil }
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("daemon start with missing source succeeded")
+ }
+ })
+
+ t.Run("log error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ dir, err := connectDaemonDir("key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(daemonLogPath(dir), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ daemonExecutable = func() (string, error) { return "/bin/sh", nil }
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("daemon start with directory log succeeded")
+ }
+ })
+
+ t.Run("child start error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ daemonExecutable = func() (string, error) { return "/bin/sh", nil }
+ daemonCommand = func(string, ...string) *exec.Cmd {
+ return exec.Command(filepath.Join(t.TempDir(), "missing"))
+ }
+ if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
+ t.Fatal("daemon start with invalid command succeeded")
+ }
+ })
+
+ t.Run("success", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ daemonExecutable = func() (string, error) { return "/bin/sh", nil }
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
+ cmd := daemonTestCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ if err := startDaemon(cmd, "key", "client", "app", "custom", "staff", "profile", true); err != nil {
+ t.Fatalf("startDaemon() error = %v", err)
+ }
+ if !strings.Contains(out.String(), "daemon started") {
+ t.Fatalf("start output = %q", out.String())
+ }
+ })
+}
+
+func TestDaemonFileOperationEdges(t *testing.T) {
+ t.Run("default config directory", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ t.Setenv("HOME", t.TempDir())
+ connectDaemonDirOverride = ""
+ if _, err := connectDaemonDir("default"); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ configure func()
+ }{
+ {"create temp", func() {
+ daemonCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") }
+ }},
+ {"chmod", func() {
+ daemonFileChmod = func(*os.File, os.FileMode) error { return errors.New("chmod") }
+ }},
+ {"copy", func() {
+ daemonCopy = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }
+ }},
+ {"sync", func() {
+ daemonFileSync = func(*os.File) error { return errors.New("sync") }
+ }},
+ {"close", func() {
+ daemonFileClose = func(*os.File) error { return errors.New("close") }
+ }},
+ {"rename", func() {
+ daemonRename = func(string, string) error { return errors.New("rename") }
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ preserveDaemonHooks(t)
+ dir := t.TempDir()
+ src := filepath.Join(dir, "source")
+ if err := os.WriteFile(src, []byte("binary"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ tc.configure()
+ if _, err := stageDaemonExecutable(src, dir); err == nil {
+ t.Fatalf("stage with %s failure succeeded", tc.name)
+ }
+ })
+ }
+
+ t.Run("state write and rename errors", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ if err := writeDaemonState(filepath.Join(t.TempDir(), "missing"), daemonState{}); err == nil {
+ t.Fatal("state write to missing directory succeeded")
+ }
+ dir := t.TempDir()
+ daemonRename = func(string, string) error { return errors.New("rename") }
+ if err := writeDaemonState(dir, daemonState{}); err == nil {
+ t.Fatal("state rename failure succeeded")
+ }
+ })
+
+ t.Run("state read non-not-exist error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ dir := t.TempDir()
+ if err := os.Mkdir(daemonStatePath(dir), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readDaemonState(dir); err == nil {
+ t.Fatal("reading directory as state succeeded")
+ }
+ })
+
+ if backoffDelay(1, 2*time.Second, time.Second) != time.Second {
+ t.Fatal("backoff base larger than cap was not capped")
+ }
+ if statusHintArgs("client", "app-id") != " --robot-client-id client" ||
+ statusHintArgs("", "app-id") != " --unified-app-id id" || statusHintArgs("", "plain") != "" {
+ t.Fatal("status hint variants mismatch")
+ }
+ workerArgs := buildWorkerArgs([]string{"keep", "--daemon=true", "--daemon-supervise=false", "--daemon-worker=true"})
+ if strings.Contains(strings.Join(workerArgs, " "), "=true") || workerArgs[0] != "keep" {
+ t.Fatalf("worker args with assigned daemon flags = %#v", workerArgs)
+ }
+}
+
+func TestRunSupervisorLifecycleEdges(t *testing.T) {
+ t.Run("missing key", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "")
+ if err := runSupervisor(daemonTestCommand()); err == nil {
+ t.Fatal("supervisor without key succeeded")
+ }
+ })
+
+ t.Run("directory error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", blocked)
+ if err := runSupervisor(daemonTestCommand()); err == nil {
+ t.Fatal("supervisor with blocked directory succeeded")
+ }
+ })
+
+ t.Run("state write error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ connectDaemonDirOverride = base
+ dir, err := connectDaemonDir("key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(daemonStatePath(dir), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ if err := runSupervisor(daemonTestCommand()); err == nil {
+ t.Fatal("supervisor with unwritable state succeeded")
+ }
+ })
+
+ t.Run("executable error", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ daemonExecutable = func() (string, error) { return "", errors.New("executable") }
+ if err := runSupervisor(daemonTestCommand()); err == nil {
+ t.Fatal("supervisor without executable succeeded")
+ }
+ })
+
+ t.Run("cancel before worker", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ daemonSignalContext = func() (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ return ctx, func() {}
+ }
+ if err := runSupervisor(daemonTestCommand()); err != nil {
+ t.Fatalf("cancelled supervisor = %v", err)
+ }
+ })
+
+ t.Run("single worker without always-on", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ t.Setenv("DWS_CONNECT_DAEMON_CLIENTID", "client")
+ t.Setenv("DWS_CONNECT_DAEMON_UNIFIEDAPPID", "app")
+ t.Setenv("DWS_CONNECT_DAEMON_CHANNEL", "custom")
+ t.Setenv("DWS_CONNECT_DAEMON_PROFILE", "profile")
+ t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "")
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
+ if err := runSupervisor(daemonTestCommand()); err != nil {
+ t.Fatalf("single-worker supervisor = %v", err)
+ }
+ })
+
+ t.Run("repeated start failures", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command(filepath.Join(base, "missing")) }
+ helperAfter = instantAfter
+ if err := runSupervisor(daemonTestCommand()); err == nil {
+ t.Fatal("supervisor did not give up after start failures")
+ }
+ })
+
+ t.Run("repeated fast crashes", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 1") }
+ helperAfter = instantAfter
+ if err := runSupervisor(daemonTestCommand()); err == nil {
+ t.Fatal("supervisor did not give up after fast crashes")
+ }
+ })
+
+ t.Run("healthy crash resets failures then cancellation", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
+ secondWorker := make(chan struct{})
+ var workers int
+ daemonCommand = func(string, ...string) *exec.Cmd {
+ workers++
+ if workers == 1 {
+ return exec.Command("sh", "-c", "exit 0")
+ }
+ select {
+ case <-secondWorker:
+ default:
+ close(secondWorker)
+ }
+ return exec.Command("sh", "-c", "sleep 5")
+ }
+ baseTime := time.Now()
+ var tick int
+ daemonNow = func() time.Time {
+ tick++
+ if tick >= 3 {
+ return baseTime.Add(daemonHealthyAfter)
+ }
+ return baseTime
+ }
+ daemonSignalContext = func() (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ <-secondWorker
+ cancel()
+ }()
+ return ctx, cancel
+ }
+ if err := runSupervisor(daemonTestCommand()); err != nil {
+ t.Fatalf("healthy cancellation supervisor = %v", err)
+ }
+ })
+
+ t.Run("cancel during backoff", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ base := t.TempDir()
+ t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
+ t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
+ t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 1") }
+ never := make(chan time.Time)
+ enteredBackoff := make(chan struct{})
+ helperAfter = func(time.Duration) <-chan time.Time {
+ select {
+ case <-enteredBackoff:
+ default:
+ close(enteredBackoff)
+ }
+ return never
+ }
+ daemonSignalContext = func() (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ <-enteredBackoff
+ cancel()
+ }()
+ return ctx, cancel
+ }
+ if err := runSupervisor(daemonTestCommand()); err != nil {
+ t.Fatalf("backoff cancellation supervisor = %v", err)
+ }
+ })
+}
+
+func TestSuperviseWaitForcedKill(t *testing.T) {
+ preserveDaemonHooks(t)
+ worker := exec.Command("sh", "-c", "trap '' TERM; sleep 5")
+ if err := worker.Start(); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(30 * time.Millisecond)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ helperAfter = instantAfter
+ _ = superviseWait(ctx, worker)
+}
+
+func TestDaemonNotifyStateChangeEdges(t *testing.T) {
+ preserveDaemonHooks(t)
+ daemonExecutable = func() (string, error) { return "", errors.New("missing") }
+ daemonNotifyStateChange("staff", "custom", "client", "started", "")
+
+ daemonExecutable = func() (string, error) { return "/bin/sh", nil }
+ done := make(chan struct{}, 4)
+ daemonCommand = func(string, ...string) *exec.Cmd {
+ done <- struct{}{}
+ return exec.Command("sh", "-c", "exit 0")
+ }
+ for _, event := range []string{"started", "stopped", "crashed", "gave_up"} {
+ daemonNotifyStateChange("staff", "custom", "client", event, "detail")
+ }
+ for range 4 {
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("notification subprocess was not created")
+ }
+ }
+ time.Sleep(50 * time.Millisecond)
+}
+
+func TestDaemonStatusAndStopEdges(t *testing.T) {
+ t.Run("status directory and corrupt files", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ connectDaemonDirOverride = blocked
+ if err := daemonStatus(&bytes.Buffer{}, "key", false); err == nil {
+ t.Fatal("status with blocked directory succeeded")
+ }
+
+ connectDaemonDirOverride = t.TempDir()
+ dir, _ := connectDaemonDir("corrupt-state")
+ if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := daemonStatus(&bytes.Buffer{}, "corrupt-state", false); err == nil {
+ t.Fatal("status with corrupt state succeeded")
+ }
+
+ dir, _ = connectDaemonDir("corrupt-heartbeat")
+ if err := os.WriteFile(connectHeartbeatPath(dir), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := daemonStatus(&bytes.Buffer{}, "corrupt-heartbeat", false); err == nil {
+ t.Fatal("status with corrupt heartbeat succeeded")
+ }
+ })
+
+ t.Run("status detailed plain and json", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ base := time.Now()
+ daemonNow = func() time.Time { return base }
+ dir, _ := connectDaemonDir("detailed")
+ if err := writeDaemonState(dir, daemonState{Pid: os.Getpid(), DirKey: "detailed"}); err != nil {
+ t.Fatal(err)
+ }
+ seedHeartbeat(t, "detailed", connectHeartbeat{
+ Pid: os.Getpid(), ClientID: "client", Channel: "custom",
+ StartUnix: base.Add(-time.Minute).Unix(), ConnectedUnix: base.Add(-time.Minute).Unix(),
+ LastPushUnix: base.Add(-time.Second).Unix(), LastError: "last error",
+ })
+ var plain bytes.Buffer
+ if err := daemonStatus(&plain, "detailed", false); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"state", "detail", "pid", "channel", "client", "uptime", "recv", "error", "logs"} {
+ if !strings.Contains(plain.String(), want) {
+ t.Errorf("plain status missing %q: %s", want, plain.String())
+ }
+ }
+ var jsonOut bytes.Buffer
+ if err := daemonStatus(&jsonOut, "detailed", true); err != nil || !strings.Contains(jsonOut.String(), `"state"`) {
+ t.Fatalf("json status = %q, %v", jsonOut.String(), err)
+ }
+ })
+
+ t.Run("stop directory and corrupt state", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ connectDaemonDirOverride = blocked
+ if err := daemonStop(&bytes.Buffer{}, "key"); err == nil {
+ t.Fatal("stop with blocked directory succeeded")
+ }
+ connectDaemonDirOverride = t.TempDir()
+ dir, _ := connectDaemonDir("corrupt")
+ if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := daemonStop(&bytes.Buffer{}, "corrupt"); err == nil {
+ t.Fatal("stop with corrupt state succeeded")
+ }
+ })
+
+ t.Run("stale supervisor stops orphan", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ helperSleep = func(time.Duration) {}
+ worker := exec.Command("sh", "-c", "sleep 5")
+ if err := worker.Start(); err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan struct{})
+ go func() { _ = worker.Wait(); close(done) }()
+ dir, _ := connectDaemonDir("orphan")
+ deadSupervisorPID := deadPid(t)
+ if err := writeDaemonState(dir, daemonState{Pid: deadSupervisorPID, DirKey: "orphan"}); err != nil {
+ t.Fatal(err)
+ }
+ seedHeartbeat(t, "orphan", connectHeartbeat{Pid: worker.Process.Pid})
+ aliveChecks := 0
+ daemonProcessAlive = func(pid int) bool {
+ if pid == deadSupervisorPID {
+ return false
+ }
+ aliveChecks++
+ return aliveChecks < 3
+ }
+ var out bytes.Buffer
+ if err := daemonStop(&out, "orphan"); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ _ = worker.Process.Kill()
+ t.Fatal("orphan worker did not stop")
+ }
+ })
+
+ t.Run("stale supervisor force kills orphan", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ dir, _ := connectDaemonDir("orphan-force")
+ if err := writeDaemonState(dir, daemonState{Pid: 999, DirKey: "orphan-force"}); err != nil {
+ t.Fatal(err)
+ }
+ seedHeartbeat(t, "orphan-force", connectHeartbeat{Pid: 123})
+ daemonProcessAlive = func(pid int) bool { return pid == 123 }
+ daemonFindProcess = func(int) (*os.Process, error) { return os.FindProcess(-1) }
+ base := time.Now()
+ var calls int
+ daemonNow = func() time.Time {
+ calls++
+ return base.Add(time.Duration(calls) * (daemonStopTimeout + time.Second))
+ }
+ helperSleep = func(time.Duration) {}
+ if err := daemonStop(&bytes.Buffer{}, "orphan-force"); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("live graceful stop", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ worker := exec.Command("sh", "-c", "sleep 5")
+ if err := worker.Start(); err != nil {
+ t.Fatal(err)
+ }
+ go func() { _ = worker.Wait() }()
+ dir, _ := connectDaemonDir("live")
+ if err := writeDaemonState(dir, daemonState{Pid: worker.Process.Pid, DirKey: "live"}); err != nil {
+ t.Fatal(err)
+ }
+ var aliveCalls int
+ daemonProcessAlive = func(int) bool {
+ aliveCalls++
+ return aliveCalls == 1
+ }
+ if err := daemonStop(&bytes.Buffer{}, "live"); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("find and signal errors", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ dir, _ := connectDaemonDir("find-error")
+ if err := writeDaemonState(dir, daemonState{Pid: 123, DirKey: "find-error"}); err != nil {
+ t.Fatal(err)
+ }
+ daemonProcessAlive = func(int) bool { return true }
+ daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
+ if err := daemonStop(&bytes.Buffer{}, "find-error"); err == nil {
+ t.Fatal("find process error was ignored")
+ }
+
+ dir, _ = connectDaemonDir("signal-error")
+ if err := writeDaemonState(dir, daemonState{Pid: 456, DirKey: "signal-error"}); err != nil {
+ t.Fatal(err)
+ }
+ daemonFindProcess = func(int) (*os.Process, error) { return os.FindProcess(-1) }
+ if err := daemonStop(&bytes.Buffer{}, "signal-error"); err == nil {
+ t.Fatal("signal process error was ignored")
+ }
+ })
+
+ t.Run("live force kill", func(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ worker := exec.Command("sh", "-c", "trap '' TERM; sleep 5")
+ if err := worker.Start(); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(30 * time.Millisecond)
+ done := make(chan struct{})
+ go func() { _ = worker.Wait(); close(done) }()
+ dir, _ := connectDaemonDir("force")
+ if err := writeDaemonState(dir, daemonState{Pid: worker.Process.Pid, DirKey: "force"}); err != nil {
+ t.Fatal(err)
+ }
+ base := time.Now()
+ advanced := false
+ daemonNow = func() time.Time {
+ if advanced {
+ return base.Add(daemonStopTimeout + time.Second)
+ }
+ return base
+ }
+ helperSleep = func(time.Duration) { advanced = true }
+ var out bytes.Buffer
+ if err := daemonStop(&out, "force"); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "SIGKILL") {
+ t.Fatalf("force stop output = %q", out.String())
+ }
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ _ = worker.Process.Kill()
+ t.Fatal("forced worker did not stop")
+ }
+ })
+}
+
+type daemonSequenceRunner struct {
+ responses []map[string]any
+ calls int
+}
+
+func (r *daemonSequenceRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
+ index := r.calls
+ r.calls++
+ response := map[string]any{}
+ if index < len(r.responses) {
+ response = r.responses[index]
+ }
+ return executor.Result{Invocation: invocation, Response: response}, nil
+}
+
+func TestDaemonListAndNamePaginationEdges(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ cmd := &cobra.Command{Use: "list"}
+
+ runner := &daemonSequenceRunner{responses: []map[string]any{
+ {"items": []any{map[string]any{"id": "u-1", "appName": "App One"}}, "hasMore": true, "nextCursor": "next"},
+ {"items": []any{map[string]any{"unifiedAppId": "u-2", "name": "App Two"}}, "hasMore": false},
+ }}
+ names, err := devAppNameMap(cmd, runner)
+ if err != nil || names["u-1"] != "App One" || names["u-2"] != "App Two" || runner.calls != 2 {
+ t.Fatalf("paginated names = %#v calls=%d err=%v", names, runner.calls, err)
+ }
+
+ runner = &daemonSequenceRunner{responses: []map[string]any{{"hasMore": true}}}
+ if _, err := devAppNameMap(cmd, runner); err != nil || runner.calls != 1 {
+ t.Fatalf("empty cursor pagination calls=%d err=%v", runner.calls, err)
+ }
+
+ reports := []connectHealthReport{{UnifiedAppID: "u-1"}, {UnifiedAppID: "missing"}, {ClientID: "client"}}
+ runner = &daemonSequenceRunner{responses: []map[string]any{{
+ "items": []any{map[string]any{"unifiedAppId": "u-1", "name": "Resolved"}}, "hasMore": false,
+ }}}
+ resolveAppNames(cmd, runner, reports)
+ if reports[0].AppName != "Resolved" || reports[1].AppName != "" {
+ t.Fatalf("resolved reports = %#v", reports)
+ }
+ resolveAppNames(cmd, connectResponseRunner{err: errors.New("offline")}, []connectHealthReport{{UnifiedAppID: "u-1"}})
+
+ list := newDevAppRobotConnectListCommand(runner)
+ var out bytes.Buffer
+ list.SetOut(&out)
+ if err := list.Execute(); err != nil || !strings.Contains(out.String(), "no connectors") {
+ t.Fatalf("empty list = %q, %v", out.String(), err)
+ }
+ list = newDevAppRobotConnectListCommand(runner)
+ out.Reset()
+ list.SetOut(&out)
+ list.SetArgs([]string{"--json"})
+ if err := list.Execute(); err != nil || !strings.Contains(out.String(), "null") {
+ t.Fatalf("json list = %q, %v", out.String(), err)
+ }
+
+ seedHeartbeat(t, "listed", connectHeartbeat{
+ Pid: os.Getpid(), ClientID: strings.Repeat("c", 80), Channel: strings.Repeat("x", 80),
+ StartUnix: time.Now().Add(-time.Minute).Unix(), ConnectedUnix: time.Now().Add(-time.Minute).Unix(),
+ })
+ list = newDevAppRobotConnectListCommand(runner)
+ out.Reset()
+ list.SetOut(&out)
+ if err := list.Execute(); err != nil || !strings.Contains(out.String(), "STATE") {
+ t.Fatalf("table list = %q, %v", out.String(), err)
+ }
+
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ connectDaemonDirOverride = blocked
+ list = newDevAppRobotConnectListCommand(runner)
+ list.SetOut(&bytes.Buffer{})
+ if err := list.Execute(); err == nil {
+ t.Fatal("list with blocked directory succeeded")
+ }
+}
+
+func TestDaemonControlCommandEdges(t *testing.T) {
+ preserveDaemonHooks(t)
+ connectDaemonDirOverride = t.TempDir()
+ baseDir := connectDaemonDirOverride
+ defaultProcessAlive := daemonProcessAlive
+ defaultFindProcess := daemonFindProcess
+
+ for _, command := range []*cobra.Command{newDevAppRobotConnectStatusCommand(), newDevAppRobotConnectStopCommand(), newDevAppRobotConnectRestartCommand()} {
+ command.SetArgs(nil)
+ command.SetOut(&bytes.Buffer{})
+ command.SetErr(&bytes.Buffer{})
+ if err := command.Execute(); err == nil {
+ t.Errorf("%s without identity succeeded", command.Name())
+ }
+ }
+
+ status := newDevAppRobotConnectStatusCommand()
+ status.SetArgs([]string{"--robot-client-id", "missing", "--json"})
+ status.SetOut(&bytes.Buffer{})
+ if err := status.Execute(); err != nil {
+ t.Fatalf("status command = %v", err)
+ }
+ stop := newDevAppRobotConnectStopCommand()
+ stop.SetArgs([]string{"--unified-app-id", "missing"})
+ stop.SetOut(&bytes.Buffer{})
+ if err := stop.Execute(); err != nil {
+ t.Fatalf("stop command = %v", err)
+ }
+
+ restart := newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "missing"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err == nil {
+ t.Fatal("restart without state succeeded")
+ }
+
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ connectDaemonDirOverride = blocked
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "blocked"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err == nil {
+ t.Fatal("restart with blocked directory succeeded")
+ }
+ connectDaemonDirOverride = baseDir
+ corruptDir, _ := connectDaemonDir("corrupt-restart")
+ if err := os.WriteFile(daemonStatePath(corruptDir), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "corrupt-restart"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err == nil {
+ t.Fatal("restart with corrupt state succeeded")
+ }
+
+ dir, _ := connectDaemonDir("no-unified")
+ if err := writeDaemonState(dir, daemonState{Pid: deadPid(t), DirKey: "no-unified"}); err != nil {
+ t.Fatal(err)
+ }
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "no-unified"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err == nil {
+ t.Fatal("restart without unified app ID succeeded")
+ }
+
+ dir, _ = connectDaemonDir("restart")
+ state := daemonState{Pid: deadPid(t), DirKey: "restart", UnifiedAppID: "app", Channel: "custom", NotifyStaffID: "staff", Profile: "saved", AlwaysOn: true}
+ if err := writeDaemonState(dir, state); err != nil {
+ t.Fatal(err)
+ }
+ daemonExecutable = func() (string, error) { return "/bin/sh", nil }
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "restart"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err != nil {
+ t.Fatalf("restart command = %v", err)
+ }
+
+ if err := writeDaemonState(dir, state); err != nil {
+ t.Fatal(err)
+ }
+ daemonProcessAlive = func(int) bool { return true }
+ daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "restart"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err != nil {
+ t.Fatalf("restart after stop warning = %v", err)
+ }
+ daemonProcessAlive = defaultProcessAlive
+ daemonFindProcess = defaultFindProcess
+
+ if err := writeDaemonState(dir, state); err != nil {
+ t.Fatal(err)
+ }
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().String("profile", "", "")
+ restart = newDevAppRobotConnectRestartCommand()
+ root.AddCommand(restart)
+ root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override"})
+ root.SetOut(&bytes.Buffer{})
+ root.SetErr(&bytes.Buffer{})
+ if err := root.Execute(); err != nil {
+ t.Fatalf("restart with profile override = %v", err)
+ }
+
+ if err := writeDaemonState(dir, state); err != nil {
+ t.Fatal(err)
+ }
+ daemonExecutable = func() (string, error) { return "", errors.New("missing") }
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "restart"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err == nil {
+ t.Fatal("restart without executable succeeded")
+ }
+
+ daemonExecutable = func() (string, error) { return "/bin/sh", nil }
+ daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 1") }
+ if err := writeDaemonState(dir, state); err != nil {
+ t.Fatal(err)
+ }
+ restart = newDevAppRobotConnectRestartCommand()
+ restart.SetArgs([]string{"--robot-client-id", "restart"})
+ restart.SetOut(&bytes.Buffer{})
+ restart.SetErr(&bytes.Buffer{})
+ if err := restart.Execute(); err == nil {
+ t.Fatal("failing restart subprocess succeeded")
+ }
+}
diff --git a/internal/helpers/connect_knowledge_wiki_test.go b/internal/helpers/connect_knowledge_wiki_test.go
index db185889..9b2b04cb 100644
--- a/internal/helpers/connect_knowledge_wiki_test.go
+++ b/internal/helpers/connect_knowledge_wiki_test.go
@@ -14,11 +14,16 @@
package helpers
import (
+ "bytes"
"context"
"errors"
"io"
+ "os"
+ "path/filepath"
"strings"
"testing"
+
+ "github.com/spf13/cobra"
)
// fakeWikiFetcher is an in-test wikiFetcher: no DingTalk calls, fully scripted.
@@ -216,3 +221,105 @@ func TestSanitizeWikiStem(t *testing.T) {
}
}
}
+
+func TestRunnerWikiFetcherCoverage(t *testing.T) {
+ runner := &onboardSeqRunner{responses: []map[string]any{
+ {
+ "result": map[string]any{"nodes": []any{
+ map[string]any{"nodeId": "n1", "name": "One"},
+ map[string]any{"nodeId": "", "name": "empty"},
+ }},
+ "nextPageToken": "next",
+ },
+ {"data": []any{
+ map[string]any{"nodeId": "n1", "name": "duplicate"},
+ map[string]any{"id": "n2", "title": "Two"},
+ }},
+ }}
+ fetcher := runnerWikiFetcher{runner: runner}
+ nodes, err := fetcher.listNodes(context.Background(), "space")
+ if err != nil || len(nodes) != 2 || nodes[1].id != "n2" {
+ t.Fatalf("listNodes() = %#v, %v", nodes, err)
+ }
+ reader := runnerWikiFetcher{runner: connectResponseRunner{response: map[string]any{"result": map[string]any{"markdown": "# body"}}}}
+ if text, err := reader.readContent(context.Background(), "n1"); err != nil || text != "# body" {
+ t.Fatalf("readContent() = %q, %v", text, err)
+ }
+
+ failed := runnerWikiFetcher{runner: connectResponseRunner{err: errors.New("runner failed")}}
+ if _, err := failed.listNodes(context.Background(), "space"); err == nil {
+ t.Fatal("listNodes runner error was ignored")
+ }
+ if _, err := failed.readContent(context.Background(), "node"); err == nil {
+ t.Fatal("readContent runner error was ignored")
+ }
+}
+
+func TestWikiKnowledgeUtilityCoverage(t *testing.T) {
+ kb := buildKnowledgeBaseFromDocs([]wikiDoc{{stem: "one", text: "# title\nbody"}, {stem: "empty", text: " "}})
+ if len(kb.chunks) == 0 {
+ t.Fatal("in-memory knowledge base is empty")
+ }
+
+ values := []any{
+ map[string]any{"ignored": map[string]any{"content": "nested"}},
+ []any{map[string]any{"text": "array"}},
+ map[string]any{"content": " direct "},
+ map[string]any{"content": "", "other": "ignored"},
+ }
+ wants := []string{"nested", "array", " direct ", ""}
+ for i, value := range values {
+ if got := firstStringForKeys(value, "content", "text"); got != wants[i] {
+ t.Fatalf("firstStringForKeys case %d = %q, want %q", i, got, wants[i])
+ }
+ }
+
+ cache := filepath.Join(t.TempDir(), "cache")
+ if err := writeWikiCache(cache, []wikiDoc{
+ {stem: "same", text: "one"},
+ {stem: "same", text: "two"},
+ {stem: "", text: "three"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"same.md", "same-2.md", "doc.md"} {
+ if _, err := os.Stat(filepath.Join(cache, name)); err != nil {
+ t.Fatalf("missing cached file %s: %v", name, err)
+ }
+ }
+ if leaf := wikiCacheLeaf(knowledgeSource{kind: knowledgeSourceDoc, ref: "a/b"}); leaf != "doc-a_b" {
+ t.Fatalf("wikiCacheLeaf() = %q", leaf)
+ }
+ if got := sanitizeWikiStem(strings.Repeat("a", 100)); len(got) != 80 {
+ t.Fatalf("long stem length = %d", len(got))
+ }
+}
+
+func TestLoadConnectKnowledgeSourceCoverage(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "knowledge.md"), []byte("# Local\nlocal body"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := &cobra.Command{Use: "connect"}
+ var stderr bytes.Buffer
+ cmd.SetErr(&stderr)
+ if kb, err := loadConnectKnowledgeSource(cmd, connectResponseRunner{}, "client", dir); err != nil || len(kb.chunks) == 0 {
+ t.Fatalf("local source = %#v, %v", kb, err)
+ }
+ if _, err := loadConnectKnowledgeSource(cmd, connectResponseRunner{}, "client", t.TempDir()); err == nil {
+ t.Fatal("empty local source succeeded")
+ }
+ if _, err := loadConnectKnowledgeSource(cmd, connectResponseRunner{}, "client", ""); err == nil {
+ t.Fatal("empty source succeeded")
+ }
+
+ t.Setenv("HOME", t.TempDir())
+ runner := connectResponseRunner{response: map[string]any{"markdown": "# Remote\nremote body"}}
+ if kb, err := loadConnectKnowledgeSource(cmd, runner, "../client", "doc:node"); err != nil || len(kb.chunks) == 0 {
+ t.Fatalf("remote source = %#v, %v", kb, err)
+ }
+ root := connectKnowledgeCacheRoot("../client")
+ if !strings.Contains(root, filepath.Join("connect", "client", "knowledge")) {
+ t.Fatalf("cache root = %q", root)
+ }
+}
diff --git a/internal/helpers/connect_onboarding_test.go b/internal/helpers/connect_onboarding_test.go
index e77bb9fd..ec7709b7 100644
--- a/internal/helpers/connect_onboarding_test.go
+++ b/internal/helpers/connect_onboarding_test.go
@@ -122,3 +122,17 @@ func TestRunConnectOnboardingNewAppMissingField(t *testing.T) {
t.Fatalf("should not submit with missing field, got %d calls", len(runner.calls))
}
}
+
+func TestConnectOnboardingSmallHelpers(t *testing.T) {
+ _ = connectStdinInteractive()
+ for _, status := range []string{"fail", " FAILED ", "Error"} {
+ if !isRobotCreateFailed(status) {
+ t.Fatalf("isRobotCreateFailed(%q) = false", status)
+ }
+ }
+ for _, status := range []string{"", "pending", "success"} {
+ if isRobotCreateFailed(status) {
+ t.Fatalf("isRobotCreateFailed(%q) = true", status)
+ }
+ }
+}
diff --git a/internal/helpers/connect_opencode.go b/internal/helpers/connect_opencode.go
index 45927a89..0cde9c66 100644
--- a/internal/helpers/connect_opencode.go
+++ b/internal/helpers/connect_opencode.go
@@ -387,7 +387,7 @@ func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPCl
} else {
lastErr = err
}
- time.Sleep(opencodeServerPollInterval)
+ helperSleep(opencodeServerPollInterval)
}
if lastErr != nil {
return fmt.Errorf("等待 opencode serve 就绪超时:%w", lastErr)
diff --git a/internal/helpers/connect_qoder_stream.go b/internal/helpers/connect_qoder_stream.go
index d31d3689..4851614b 100644
--- a/internal/helpers/connect_qoder_stream.go
+++ b/internal/helpers/connect_qoder_stream.go
@@ -415,7 +415,7 @@ func (f *qoderStreamForwarder) closeLocked() error {
if f.done != nil {
select {
case <-f.done:
- case <-time.After(2 * time.Second):
+ case <-helperAfter(2 * time.Second):
}
}
f.clearProcessLocked()
diff --git a/internal/helpers/connect_stream.go b/internal/helpers/connect_stream.go
index 0421c864..3b52e31b 100644
--- a/internal/helpers/connect_stream.go
+++ b/internal/helpers/connect_stream.go
@@ -42,7 +42,10 @@ import (
// silent (they dump every ping/pong).
type streamSDKLogger struct{}
-func (streamSDKLogger) Debugf(format string, args ...interface{}) {}
+func (streamSDKLogger) Debugf(format string, args ...interface{}) {
+ _ = format
+ _ = args
+}
func (streamSDKLogger) Infof(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "[stream] "+format+"\n", args...)
}
@@ -68,6 +71,38 @@ type forwarder interface {
label() string
}
+type connectStreamClient interface {
+ RegisterChatBotCallbackRouter(chatbot.IChatBotMessageHandler)
+ RegisterCardCallbackRouter(card.ICardCallbackHandler)
+ Start(context.Context) error
+ Close()
+}
+
+type connectChatReplier interface {
+ SimpleReplyMarkdown(context.Context, string, []byte, []byte) error
+ SimpleReplyText(context.Context, string, []byte) error
+}
+
+type connectMediaClient interface {
+ downloadMessageFile(context.Context, string, string) (string, error)
+ getUserUnionID(context.Context, string) (string, error)
+ downloadDentryFile(context.Context, int64, int64, string, string) (string, error)
+}
+
+var (
+ newConnectStreamClient = func(clientID, clientSecret string, keepAlive time.Duration) connectStreamClient {
+ return client.NewStreamClient(
+ client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
+ client.WithAutoReconnect(true),
+ client.WithKeepAlive(keepAlive),
+ )
+ }
+ newConnectChatReplier = func() connectChatReplier { return chatbot.NewChatbotReplier() }
+ newConnectMediaClient = func(clientID, clientSecret string) connectMediaClient {
+ return newAICardClient(clientID, clientSecret, "")
+ }
+)
+
type streamingForwarder interface {
forwarder
canStream() bool
@@ -1145,6 +1180,15 @@ func connectTurnSummary(turn connectQueuedTurn) string {
return "[空消息]"
}
+func connectApprovalGroupReply(replier connectChatReplier, webhook, channel string) approvalReplier {
+ return func(ctx context.Context, _ string, text string) error {
+ if len([]rune(text)) > 200 {
+ return replier.SimpleReplyMarkdown(ctx, webhook, []byte(channel), []byte(text))
+ }
+ return replier.SimpleReplyText(ctx, webhook, []byte(text))
+ }
+}
+
func runStreamConnector(ctx context.Context, channel, clientID, clientSecret string, fwd forwarder, cardCli *aiCardClient, extras *connectExtras) error {
if extras == nil {
extras = &connectExtras{}
@@ -1173,23 +1217,21 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
health.start(ctx)
streamLoggerOnce.Do(func() { sdklogger.SetLogger(streamSDKLogger{}) })
- replier := chatbot.NewChatbotReplier()
+ replier := newConnectChatReplier()
dedup := newMsgDedup(10000)
queue := newConvQueue()
// Media downloads need an authenticated API client even when cards are
// off (aiCardClient with an empty template is creds+HTTP only).
- mediaCli := cardCli
- if mediaCli == nil {
- mediaCli = newAICardClient(clientID, clientSecret, "")
+ var mediaCli connectMediaClient
+ if cardCli != nil {
+ mediaCli = cardCli
+ } else {
+ mediaCli = newConnectMediaClient(clientID, clientSecret)
}
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second)
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
- cli := client.NewStreamClient(
- client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
- client.WithAutoReconnect(true),
- client.WithKeepAlive(keepAlive),
- )
+ cli := newConnectStreamClient(clientID, clientSecret, keepAlive)
cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
text := strings.TrimSpace(data.Text.Content)
msgtype := strings.TrimSpace(data.Msgtype)
@@ -1598,12 +1640,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
// posts the outcome into the group itself. handled==true means the
// gate owns the reply and the normal delivery below is skipped.
if err == nil && gateOn {
- groupReply := func(ctx context.Context, _, text string) error {
- if len([]rune(text)) > 200 {
- return replier.SimpleReplyMarkdown(ctx, webhook, []byte(channel), []byte(text))
- }
- return replier.SimpleReplyText(ctx, webhook, []byte(text))
- }
+ groupReply := connectApprovalGroupReply(replier, webhook, channel)
out, handled := extras.approval.handleReply(context.Background(), strings.TrimSpace(callbackData.SenderStaffId), convID, reply, groupReply)
if handled {
if thinking {
@@ -1659,7 +1696,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
if attempt < len(backoffs) {
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败 (%s, attempt %d/%d, msgId=%s): %v,%v 后重试\n",
channel, attempt+1, len(backoffs)+1, msgID, sendErr, backoffs[attempt])
- time.Sleep(backoffs[attempt])
+ helperSleep(backoffs[attempt])
}
}
if sendErr != nil {
diff --git a/internal/helpers/connect_stream_connector_coverage_test.go b/internal/helpers/connect_stream_connector_coverage_test.go
new file mode 100644
index 00000000..2fdcd37d
--- /dev/null
+++ b/internal/helpers/connect_stream_connector_coverage_test.go
@@ -0,0 +1,694 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
+)
+
+type fakeConnectStreamClient struct {
+ chatHandler chatbot.IChatBotMessageHandler
+ cardHandler card.ICardCallbackHandler
+ start func(context.Context, chatbot.IChatBotMessageHandler) error
+ closed bool
+}
+
+func (c *fakeConnectStreamClient) RegisterChatBotCallbackRouter(handler chatbot.IChatBotMessageHandler) {
+ c.chatHandler = handler
+}
+
+func (c *fakeConnectStreamClient) RegisterCardCallbackRouter(handler card.ICardCallbackHandler) {
+ c.cardHandler = handler
+}
+
+func (c *fakeConnectStreamClient) Start(ctx context.Context) error {
+ if c.start != nil {
+ return c.start(ctx, c.chatHandler)
+ }
+ return nil
+}
+
+func (c *fakeConnectStreamClient) Close() { c.closed = true }
+
+type fakeConnectReplier struct {
+ mu sync.Mutex
+ errors []error
+ calls int
+ kinds []string
+ payloads []string
+ events chan struct{}
+}
+
+func (r *fakeConnectReplier) next(kind, payload string) error {
+ r.mu.Lock()
+ index := r.calls
+ r.calls++
+ r.kinds = append(r.kinds, kind)
+ r.payloads = append(r.payloads, payload)
+ var err error
+ if index < len(r.errors) {
+ err = r.errors[index]
+ }
+ r.mu.Unlock()
+ select {
+ case r.events <- struct{}{}:
+ default:
+ }
+ return err
+}
+
+func (r *fakeConnectReplier) SimpleReplyMarkdown(_ context.Context, _ string, _, content []byte) error {
+ return r.next("markdown", string(content))
+}
+
+func (r *fakeConnectReplier) SimpleReplyText(_ context.Context, _ string, content []byte) error {
+ return r.next("text", string(content))
+}
+
+type fakeConnectMedia struct {
+ messagePath string
+ messageErr error
+ unionID string
+ unionErr error
+ dentryPath string
+ dentryErr error
+}
+
+func (m *fakeConnectMedia) downloadMessageFile(context.Context, string, string) (string, error) {
+ return m.messagePath, m.messageErr
+}
+
+func (m *fakeConnectMedia) getUserUnionID(context.Context, string) (string, error) {
+ return m.unionID, m.unionErr
+}
+
+func (m *fakeConnectMedia) downloadDentryFile(context.Context, int64, int64, string, string) (string, error) {
+ return m.dentryPath, m.dentryErr
+}
+
+type basicConnectorForwarder struct {
+ mu sync.Mutex
+ reply string
+ err error
+ prompts []string
+}
+
+func (f *basicConnectorForwarder) forward(_ context.Context, _ string, text string) (string, error) {
+ f.mu.Lock()
+ f.prompts = append(f.prompts, text)
+ f.mu.Unlock()
+ return f.reply, f.err
+}
+
+func (*basicConnectorForwarder) label() string { return "fake" }
+
+type resetConnectorForwarder struct {
+ *basicConnectorForwarder
+ resets []string
+ clears []string
+ clearErr error
+}
+
+func (f *resetConnectorForwarder) resetSession(convID string) { f.resets = append(f.resets, convID) }
+
+func (f *resetConnectorForwarder) clearSession(_ context.Context, convID string) error {
+ f.clears = append(f.clears, convID)
+ return f.clearErr
+}
+
+type streamingConnectorForwarder struct {
+ *basicConnectorForwarder
+ streamed bool
+}
+
+func (*streamingConnectorForwarder) canStream() bool { return true }
+
+func (f *streamingConnectorForwarder) forwardStream(_ context.Context, _ string, text string, onDelta func(string)) (string, error) {
+ f.streamed = true
+ f.prompts = append(f.prompts, text)
+ if onDelta != nil {
+ onDelta("partial")
+ }
+ return f.reply, f.err
+}
+
+type closingConnectorForwarder struct {
+ *basicConnectorForwarder
+ closed bool
+ closeErr error
+}
+
+type slowConnectorForwarder struct {
+ *basicConnectorForwarder
+ delay time.Duration
+}
+
+func (f *slowConnectorForwarder) forward(ctx context.Context, convID, text string) (string, error) {
+ time.Sleep(f.delay)
+ return f.basicConnectorForwarder.forward(ctx, convID, text)
+}
+
+func (f *closingConnectorForwarder) close() error {
+ f.closed = true
+ return f.closeErr
+}
+
+type fakeOwnerNotifier struct{}
+
+func (fakeOwnerNotifier) sendOTOText(context.Context, []string, string) error { return nil }
+
+func preserveConnectStreamHooks(t *testing.T) {
+ t.Helper()
+ oldStream := newConnectStreamClient
+ oldReplier := newConnectChatReplier
+ oldMedia := newConnectMediaClient
+ oldLockDir := connectLockDir
+ oldDaemonDir := connectDaemonDirOverride
+ oldSleep := helperSleep
+ t.Cleanup(func() {
+ newConnectStreamClient = oldStream
+ newConnectChatReplier = oldReplier
+ newConnectMediaClient = oldMedia
+ connectLockDir = oldLockDir
+ connectDaemonDirOverride = oldDaemonDir
+ helperSleep = oldSleep
+ })
+}
+
+func connectorMessage(id, text string) *chatbot.BotCallbackDataModel {
+ return &chatbot.BotCallbackDataModel{
+ ConversationId: "conv-" + id,
+ MsgId: id,
+ SenderNick: "Sender",
+ SenderStaffId: "staff-" + id,
+ ConversationType: "2",
+ SessionWebhook: "https://webhook.test/" + id,
+ Text: chatbot.BotCallbackDataTextModel{Content: text},
+ Msgtype: "text",
+ }
+}
+
+func runConnectorScenario(t *testing.T, messages []*chatbot.BotCallbackDataModel, fwd forwarder, media connectMediaClient, extras *connectExtras, replyErrors []error, wantReplyCalls int) (*fakeConnectReplier, *fakeConnectStreamClient) {
+ return runConnectorScenarioWithCard(t, messages, fwd, media, nil, extras, replyErrors, wantReplyCalls)
+}
+
+func runConnectorScenarioWithCard(t *testing.T, messages []*chatbot.BotCallbackDataModel, fwd forwarder, media connectMediaClient, cardCli *aiCardClient, extras *connectExtras, replyErrors []error, wantReplyCalls int) (*fakeConnectReplier, *fakeConnectStreamClient) {
+ t.Helper()
+ preserveConnectStreamHooks(t)
+ connectLockDir = t.TempDir()
+ connectDaemonDirOverride = t.TempDir()
+ helperSleep = func(time.Duration) {}
+ replier := &fakeConnectReplier{errors: replyErrors, events: make(chan struct{}, 16)}
+ stream := &fakeConnectStreamClient{}
+ ctx, cancel := context.WithCancel(context.Background())
+ stream.start = func(_ context.Context, handler chatbot.IChatBotMessageHandler) error {
+ if handler == nil {
+ return errors.New("chat handler was not registered")
+ }
+ for _, message := range messages {
+ if _, err := handler(context.Background(), message); err != nil {
+ return err
+ }
+ }
+ for range wantReplyCalls {
+ select {
+ case <-replier.events:
+ case <-time.After(2 * time.Second):
+ return errors.New("timed out waiting for reply")
+ }
+ }
+ if cardCli != nil && wantReplyCalls == 0 {
+ time.Sleep(120 * time.Millisecond)
+ }
+ cancel()
+ return nil
+ }
+ newConnectStreamClient = func(string, string, time.Duration) connectStreamClient { return stream }
+ newConnectChatReplier = func() connectChatReplier { return replier }
+ if media == nil {
+ media = &fakeConnectMedia{}
+ }
+ newConnectMediaClient = func(string, string) connectMediaClient { return media }
+ if err := runStreamConnector(ctx, "custom", "client-"+strings.ReplaceAll(t.Name(), "/", "-"), "secret", fwd, cardCli, extras); err != nil {
+ t.Fatalf("runStreamConnector() error = %v", err)
+ }
+ return replier, stream
+}
+
+func TestRunStreamConnectorBasicMessageEdges(t *testing.T) {
+ t.Run("blank and missing webhook drop", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "unused"}
+ blank := connectorMessage("blank", "")
+ noWebhook := connectorMessage("webhook", "hello")
+ noWebhook.SessionWebhook = ""
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{blank, noWebhook}, fwd, nil, nil, nil, 0)
+ if len(fwd.prompts) != 0 {
+ t.Fatalf("dropped messages forwarded: %#v", fwd.prompts)
+ }
+ })
+
+ t.Run("text reply and duplicate", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "answer"}
+ message := connectorMessage("duplicate", "question")
+ replier, stream := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{message, message}, fwd, nil, nil, nil, 1)
+ if replier.calls != 1 || replier.kinds[0] != "text" || !stream.closed {
+ t.Fatalf("reply calls=%d kinds=%#v closed=%v", replier.calls, replier.kinds, stream.closed)
+ }
+ })
+
+ t.Run("structured fallbacks and sender fallback", func(t *testing.T) {
+ interactive := connectorMessage("interactive", "")
+ interactive.Msgtype = "interactiveCard"
+ interactive.SenderNick = ""
+ interactive.Content = map[string]any{"cardContent": []any{map[string]any{"children": []any{
+ map[string]any{"elementType": "TEXT", "value": "@bot"},
+ map[string]any{"elementType": "TEXT", "value": " instruction"},
+ }}}}
+ markdown := connectorMessage("markdown", "")
+ markdown.Msgtype = "markdown"
+ markdown.Content = map[string]any{"text": " markdown body "}
+ fwd := &basicConnectorForwarder{reply: "ok"}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{interactive, markdown}, fwd, nil, nil, nil, 2)
+ if len(fwd.prompts) != 2 {
+ t.Fatalf("structured prompts = %#v", fwd.prompts)
+ }
+ })
+
+ t.Run("conversation id fallback and queued merge", func(t *testing.T) {
+ fallback := connectorMessage("fallback", "question")
+ fallback.ConversationId = ""
+ first := connectorMessage("merge-1", "first")
+ second := connectorMessage("merge-2", "second")
+ third := connectorMessage("merge-3", "third")
+ second.ConversationId = first.ConversationId
+ third.ConversationId = first.ConversationId
+ fwd := &slowConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{reply: "ok"}, delay: 30 * time.Millisecond}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{fallback, first, second, third}, fwd, nil, nil, nil, 3)
+ foundMerged := false
+ for _, prompt := range fwd.prompts {
+ if strings.Contains(prompt, "连续发送") {
+ foundMerged = true
+ }
+ }
+ if !foundMerged {
+ t.Fatalf("queued prompts were not merged: %#v", fwd.prompts)
+ }
+ })
+
+ t.Run("access gate denial", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "unused"}
+ extras := &connectExtras{gate: newConnectGate([]string{"allowed"}, nil, 0)}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("denied", "question")}, fwd, nil, extras, nil, 0)
+ if len(fwd.prompts) != 0 {
+ t.Fatal("denied message reached forwarder")
+ }
+ })
+}
+
+func TestRunStreamConnectorForwardAndReplyEdges(t *testing.T) {
+ t.Run("long markdown and retry success", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: strings.Repeat("long", 80)}
+ replier, _ := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("long", "question")}, fwd, nil, &connectExtras{persona: "Persona"}, []error{errors.New("one"), errors.New("two"), nil}, 3)
+ if replier.calls != 3 || replier.kinds[0] != "markdown" || !strings.Contains(fwd.prompts[0], "Persona") {
+ t.Fatalf("markdown retry = calls %d kinds %#v prompts %#v", replier.calls, replier.kinds, fwd.prompts)
+ }
+ })
+
+ t.Run("reply retry exhaustion", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "answer"}
+ errs := []error{errors.New("1"), errors.New("2"), errors.New("3"), errors.New("4")}
+ replier, _ := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("exhaust", "question")}, fwd, nil, nil, errs, 4)
+ if replier.calls != 4 {
+ t.Fatalf("exhausted reply calls = %d", replier.calls)
+ }
+ })
+
+ t.Run("ordinary forward error", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{err: errors.New("backend")}
+ replier, _ := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("error", "question")}, fwd, nil, nil, nil, 1)
+ if !strings.Contains(replier.payloads[0], "调用失败") {
+ t.Fatalf("error reply = %q", replier.payloads[0])
+ }
+ })
+
+ t.Run("deadline resets session", func(t *testing.T) {
+ fwd := &resetConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{err: context.DeadlineExceeded}}
+ replier, _ := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("deadline", "question")}, fwd, nil, nil, nil, 1)
+ if len(fwd.resets) != 1 || !strings.Contains(replier.payloads[0], "自动重置") {
+ t.Fatalf("deadline resets=%#v reply=%#v", fwd.resets, replier.payloads)
+ }
+ })
+
+ t.Run("streaming forwarder", func(t *testing.T) {
+ fwd := &streamingConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{reply: "stream answer"}}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("stream", "question")}, fwd, nil, nil, nil, 1)
+ if !fwd.streamed {
+ t.Fatal("streaming forwarder was not used")
+ }
+ })
+
+ t.Run("knowledge augmentation", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "answer"}
+ kb := &knowledgeBase{chunks: []knowledgeChunk{{source: "guide.md", text: "alpha guidance", terms: knowledgeTerms("alpha guidance")}}}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("knowledge", "alpha question")}, fwd, nil, &connectExtras{kb: kb}, nil, 1)
+ if !strings.Contains(fwd.prompts[0], "guide.md") {
+ t.Fatalf("knowledge prompt = %q", fwd.prompts[0])
+ }
+ })
+}
+
+func TestRunStreamConnectorControlAndLifecycleEdges(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ text string
+ clearErr error
+ }{
+ {"new", "/new", nil},
+ {"clear", "/clear", nil},
+ {"clear error", "/clear", errors.New("clear")},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fwd := &resetConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{reply: "unused"}, clearErr: tc.clearErr}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("control", tc.text)}, fwd, nil, nil, nil, 1)
+ if tc.text == "/new" && len(fwd.resets) != 1 {
+ t.Fatalf("new resets = %#v", fwd.resets)
+ }
+ if tc.text == "/clear" && len(fwd.clears) != 1 {
+ t.Fatalf("clear calls = %#v", fwd.clears)
+ }
+ })
+ }
+
+ t.Run("unsupported control", func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "unused"}
+ replier, _ := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("unsupported", "/new")}, fwd, nil, nil, nil, 1)
+ if !strings.Contains(replier.payloads[0], "暂不支持") {
+ t.Fatalf("unsupported control reply = %q", replier.payloads[0])
+ }
+ })
+
+ t.Run("control reply error", func(t *testing.T) {
+ fwd := &resetConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{}}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("control-error", "/new")}, fwd, nil, nil, []error{errors.New("send")}, 1)
+ })
+
+ for _, closeErr := range []error{nil, errors.New("close")} {
+ name := "close success"
+ if closeErr != nil {
+ name = "close error"
+ }
+ t.Run(name, func(t *testing.T) {
+ fwd := &closingConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{}, closeErr: closeErr}
+ runConnectorScenario(t, nil, fwd, nil, nil, nil, 0)
+ if !fwd.closed {
+ t.Fatal("forwarder was not closed")
+ }
+ })
+ }
+
+ t.Run("stream start error", func(t *testing.T) {
+ preserveConnectStreamHooks(t)
+ connectLockDir = t.TempDir()
+ connectDaemonDirOverride = t.TempDir()
+ stream := &fakeConnectStreamClient{start: func(context.Context, chatbot.IChatBotMessageHandler) error { return errors.New("start") }}
+ newConnectStreamClient = func(string, string, time.Duration) connectStreamClient { return stream }
+ newConnectChatReplier = func() connectChatReplier { return &fakeConnectReplier{events: make(chan struct{}, 1)} }
+ newConnectMediaClient = func(string, string) connectMediaClient { return &fakeConnectMedia{} }
+ if err := runStreamConnector(context.Background(), "custom", "start-error", "secret", &basicConnectorForwarder{}, nil, nil); err == nil {
+ t.Fatal("stream start error was ignored")
+ }
+ })
+
+ t.Run("duplicate connector lock", func(t *testing.T) {
+ preserveConnectStreamHooks(t)
+ connectLockDir = t.TempDir()
+ release, err := acquireConnectLock("duplicate-client")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if err := runStreamConnector(context.Background(), "custom", "duplicate-client", "secret", &basicConnectorForwarder{}, nil, nil); err == nil {
+ t.Fatal("duplicate connector lock was ignored")
+ }
+ })
+
+ t.Run("approval registration and ordinary response", func(t *testing.T) {
+ gate := newApprovalGate("")
+ approval := newTextApprovalOrchestrator(gate, nil, "owner", fakeOwnerNotifier{})
+ extras := &connectExtras{approval: approval}
+ fwd := &basicConnectorForwarder{reply: "ordinary answer"}
+ _, stream := runConnectorScenario(t, []*chatbot.BotCallbackDataModel{connectorMessage("approval", "question")}, fwd, nil, extras, nil, 1)
+ if stream.cardHandler == nil || !strings.Contains(fwd.prompts[0], "ACTION") {
+ t.Fatalf("approval registration=%v prompt=%q", stream.cardHandler != nil, fwd.prompts[0])
+ }
+ _, _ = stream.cardHandler(context.Background(), &card.CardRequest{})
+ })
+
+ t.Run("owner decision consumed", func(t *testing.T) {
+ gate := newApprovalGate("")
+ _ = gate.Submit(ApprovalRequest{Requester: "requester", Summary: "pending"})
+ approval := newTextApprovalOrchestrator(gate, nil, "owner", fakeOwnerNotifier{})
+ message := connectorMessage("decision", "拒绝")
+ message.SenderStaffId = "owner"
+ fwd := &basicConnectorForwarder{reply: "unused"}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{message}, fwd, nil, &connectExtras{approval: approval}, nil, 0)
+ if len(fwd.prompts) != 0 {
+ t.Fatal("owner decision reached forwarder")
+ }
+ })
+
+ t.Run("approval group reply helper", func(t *testing.T) {
+ replier := &fakeConnectReplier{events: make(chan struct{}, 2)}
+ groupReply := connectApprovalGroupReply(replier, "webhook", "custom")
+ if err := groupReply(context.Background(), "conv", "short"); err != nil {
+ t.Fatal(err)
+ }
+ if err := groupReply(context.Background(), "conv", strings.Repeat("x", 201)); err != nil {
+ t.Fatal(err)
+ }
+ if len(replier.kinds) != 2 || replier.kinds[0] != "text" || replier.kinds[1] != "markdown" {
+ t.Fatalf("group reply kinds = %#v", replier.kinds)
+ }
+ })
+}
+
+func TestRunStreamConnectorMediaEdges(t *testing.T) {
+ cases := []struct {
+ name string
+ message *chatbot.BotCallbackDataModel
+ media *fakeConnectMedia
+ want string
+ }{
+ {
+ name: "picture success",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("pic-ok", "")
+ m.Msgtype = "picture"
+ m.Content = map[string]any{"downloadCode": "code"}
+ return m
+ }(),
+ media: &fakeConnectMedia{messagePath: "/tmp/picture.png"}, want: "/tmp/picture.png",
+ },
+ {
+ name: "picture failure",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("pic-fail", "")
+ m.Msgtype = "picture"
+ m.Content = map[string]any{"downloadCode": "code"}
+ return m
+ }(),
+ media: &fakeConnectMedia{messageErr: errors.New("download")}, want: "图片下载失败",
+ },
+ {
+ name: "picture success with text",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("pic-text", "also text")
+ m.Msgtype = "picture"
+ m.Content = map[string]any{"downloadCode": "code"}
+ return m
+ }(),
+ media: &fakeConnectMedia{messagePath: "/tmp/picture.png"}, want: "同时附了一张图片",
+ },
+ {
+ name: "client file success with text",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("file-ok", "please inspect")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
+ return m
+ }(),
+ media: &fakeConnectMedia{messagePath: "/tmp/report.txt"}, want: "/tmp/report.txt",
+ },
+ {
+ name: "client file success without text",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("file-empty", "")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
+ return m
+ }(),
+ media: &fakeConnectMedia{messagePath: "/tmp/report.txt"}, want: "用户发来一个文件",
+ },
+ {
+ name: "client file failure",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("file-fail", "")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
+ return m
+ }(),
+ media: &fakeConnectMedia{messageErr: errors.New("download")}, want: "文件下载失败",
+ },
+ {
+ name: "dentry success",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("dentry-ok", "")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"dentryId": float64(1), "spaceId": float64(2), "fileName": "data.csv"}
+ return m
+ }(),
+ media: &fakeConnectMedia{unionID: "union", dentryPath: "/tmp/data.csv"}, want: "/tmp/data.csv",
+ },
+ {
+ name: "dentry download failure",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("dentry-download-fail", "")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"dentryId": float64(1), "spaceId": float64(2), "fileName": "data.csv"}
+ return m
+ }(),
+ media: &fakeConnectMedia{unionID: "union", dentryErr: errors.New("download")}, want: "dentryId=1",
+ },
+ {
+ name: "dentry metadata fallback",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("dentry-fail", "")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"dentryId": float64(1), "spaceId": float64(2), "fileName": "data.csv", "fileType": "csv", "fileSize": float64(42)}
+ return m
+ }(),
+ media: &fakeConnectMedia{unionErr: errors.New("union")}, want: "dentryId=1",
+ },
+ {
+ name: "dentry metadata fallback with text",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("dentry-text", "use context")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"dentryId": float64(1), "spaceId": float64(2), "fileName": "data.csv"}
+ return m
+ }(),
+ media: &fakeConnectMedia{unionErr: errors.New("union")}, want: "用户同时附了一个文件",
+ },
+ {
+ name: "dentry success with text",
+ message: func() *chatbot.BotCallbackDataModel {
+ m := connectorMessage("dentry-success-text", "use context")
+ m.Msgtype = "file"
+ m.Content = map[string]any{"dentryId": float64(1), "spaceId": float64(2), "fileName": "data.csv"}
+ return m
+ }(),
+ media: &fakeConnectMedia{unionID: "union", dentryPath: "/tmp/data.csv"}, want: "用户同时附了一个文件",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ fwd := &basicConnectorForwarder{reply: "done"}
+ runConnectorScenario(t, []*chatbot.BotCallbackDataModel{tc.message}, fwd, tc.media, nil, nil, 1)
+ if len(fwd.prompts) != 1 || !strings.Contains(fwd.prompts[0], tc.want) {
+ t.Fatalf("media prompt = %#v, want %q", fwd.prompts, tc.want)
+ }
+ })
+ }
+}
+
+func TestRunStreamConnectorCardEdges(t *testing.T) {
+ t.Run("card client without template", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ cardCli := newAICardClient("client", "secret", "")
+ fwd := &basicConnectorForwarder{reply: "plain"}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("no-template", "question")}, fwd, nil, cardCli, nil, nil, 1)
+ if len(rec.calls) == 0 {
+ t.Fatal("card client did not mark thinking")
+ }
+ })
+
+ t.Run("reaction and card creation failures fall back", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ rec.fail["POST /v1.0/robot/emotion/reply"] = 500
+ rec.fail["POST /v1.0/card/instances"] = 500
+ cardCli := newAICardClient("client", "secret", defaultAICardTemplateID)
+ fwd := &basicConnectorForwarder{reply: "plain"}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("card-create-fail", "question")}, fwd, nil, cardCli, nil, nil, 1)
+ })
+
+ t.Run("stream card frame and finish failures", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ rec.fail["PUT /v1.0/card/streaming"] = 500
+ cardCli := newAICardClient("client", "secret", defaultAICardTemplateID)
+ fwd := &streamingConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{reply: "streamed"}}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("card-stream-fail", "question")}, fwd, nil, cardCli, nil, nil, 1)
+ if !fwd.streamed {
+ t.Fatal("streaming card forwarder was not called")
+ }
+ })
+
+ t.Run("stream card precreate failure", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ rec.fail["POST /v1.0/card/instances"] = 500
+ cardCli := newAICardClient("client", "secret", defaultAICardTemplateID)
+ fwd := &streamingConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{reply: "streamed"}}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("card-precreate-fail", "question")}, fwd, nil, cardCli, nil, nil, 1)
+ })
+
+ t.Run("stream delta status failure", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ rec.fail["PUT /v1.0/card/instances"] = 500
+ cardCli := newAICardClient("client", "secret", defaultAICardTemplateID)
+ fwd := &streamingConnectorForwarder{basicConnectorForwarder: &basicConnectorForwarder{reply: "streamed"}}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("card-delta-fail", "question")}, fwd, nil, cardCli, nil, nil, 1)
+ })
+
+ t.Run("successful one-shot card", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ cardCli := newAICardClient("client", "secret", defaultAICardTemplateID)
+ fwd := &basicConnectorForwarder{reply: "card answer"}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("card-ok", "question")}, fwd, nil, cardCli, nil, nil, 0)
+ rec.mu.Lock()
+ calls := strings.Join(rec.calls, ",")
+ rec.mu.Unlock()
+ if !strings.Contains(calls, "PUT /v1.0/card/streaming") {
+ t.Fatalf("card calls = %s", calls)
+ }
+ })
+
+ t.Run("thinking remains when every fallback reply fails", func(t *testing.T) {
+ rec, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ rec.fail["POST /v1.0/card/instances"] = 500
+ cardCli := newAICardClient("client", "secret", defaultAICardTemplateID)
+ fwd := &basicConnectorForwarder{reply: "plain"}
+ errs := []error{errors.New("1"), errors.New("2"), errors.New("3"), errors.New("4")}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("card-send-fail", "question")}, fwd, nil, cardCli, nil, errs, 4)
+ })
+
+ t.Run("handled approval swaps thinking", func(t *testing.T) {
+ _, srv := newCardAPIServer(t)
+ withCardAPIBase(t, srv.URL)
+ cardCli := newAICardClient("client", "secret", "")
+ approval := newTextApprovalOrchestrator(newApprovalGate(""), nil, "owner", fakeOwnerNotifier{})
+ fwd := &basicConnectorForwarder{reply: `准备创建 [[ACTION:doc.create name="Plan"]]`}
+ runConnectorScenarioWithCard(t, []*chatbot.BotCallbackDataModel{connectorMessage("approval-handled", "create doc")}, fwd, nil, cardCli, &connectExtras{approval: approval}, nil, 0)
+ })
+}
diff --git a/internal/helpers/connect_stream_edges_coverage_test.go b/internal/helpers/connect_stream_edges_coverage_test.go
new file mode 100644
index 00000000..748c6e0f
--- /dev/null
+++ b/internal/helpers/connect_stream_edges_coverage_test.go
@@ -0,0 +1,101 @@
+package helpers
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestConnectStreamFinalStatementEdges(t *testing.T) {
+ if got := newConnectMediaClient("client", "secret"); got == nil {
+ t.Fatal("default media client is nil")
+ }
+
+ t.Run("backend error clears an addressable session", func(t *testing.T) {
+ sessions := newConvSessions("")
+ oldID := sessions.id("conversation")
+ bin := writeShellExecutable(t, t.TempDir(), "backend-agent", "printf 'API Error: backend rejected request'\n")
+ fwd := &execForwarder{
+ name: "test",
+ argv: []string{bin},
+ sessions: sessions,
+ }
+ if reply, err := fwd.forward(context.Background(), "conversation", "question"); err != nil || reply == "" {
+ t.Fatalf("backend reply = %q, %v", reply, err)
+ }
+ if newID := sessions.id("conversation"); newID == oldID {
+ t.Fatal("backend error did not clear the session")
+ }
+ })
+
+ t.Run("successful command with empty stdout", func(t *testing.T) {
+ bin := writeShellExecutable(t, t.TempDir(), "silent-agent", "exit 0\n")
+ fwd := &execForwarder{name: "test", argv: []string{bin}}
+ if reply, err := fwd.forward(context.Background(), "", "question"); err != nil || reply != "(本地 agent 无文本输出)" {
+ t.Fatalf("empty stdout reply = %q, %v", reply, err)
+ }
+ })
+
+ t.Run("blank Claude settings key is ignored", func(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CLAUDE_CONFIG_DIR", dir)
+ if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(`{"env":{" ":"ignored"}}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := claudeUserSettingsEnv(); len(got) != 0 {
+ t.Fatalf("blank settings key produced env: %v", got)
+ }
+ })
+}
+
+func TestResolveExecAgentInstallAndForwarderErrors(t *testing.T) {
+ clearChannelEnv(t)
+ binDir := t.TempDir()
+ originalPath := os.Getenv("PATH")
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath)
+
+ restoreSpec := func(channel string, spec agentSpec) {
+ t.Helper()
+ original, existed := agentSpecs[channel]
+ agentSpecs[channel] = spec
+ t.Cleanup(func() {
+ if existed {
+ agentSpecs[channel] = original
+ } else {
+ delete(agentSpecs, channel)
+ }
+ })
+ }
+
+ source := writeShellExecutable(t, t.TempDir(), "source-agent", "exit 0\n")
+ target := filepath.Join(binDir, "installed-agent")
+ restoreSpec("install-success", agentSpec{
+ app: "Install Success",
+ bins: []string{"installed-agent"},
+ install: []string{"cp", source, target},
+ })
+ argv, _, err := resolveExecAgent("install-success")
+ if err != nil || len(argv) != 1 || argv[0] != target {
+ t.Fatalf("installed agent = %v, %v", argv, err)
+ }
+
+ restoreSpec("install-failure", agentSpec{
+ app: "Install Failure",
+ bins: []string{"never-installed-agent"},
+ install: []string{"sh", "-c", "exit 1"},
+ hint: "install it",
+ })
+ if _, _, err := resolveExecAgent("install-failure"); err == nil {
+ t.Fatal("failed installation unexpectedly resolved an agent")
+ }
+ if _, err := forwarderForChannel("install-failure", "", connectAgentOptions{}); err == nil {
+ t.Fatal("forwarder unexpectedly accepted a missing binary")
+ }
+
+ writeShellExecutable(t, binDir, "model-less-agent", "exit 0\n")
+ restoreSpec("model-less", agentSpec{app: "Model-less", bins: []string{"model-less-agent"}})
+ if _, err := forwarderForChannel("model-less", "", connectAgentOptions{Model: "unsupported"}); err == nil {
+ t.Fatal("model override unexpectedly succeeded without a model flag")
+ }
+}
diff --git a/internal/helpers/content_splitter.go b/internal/helpers/content_splitter.go
index e29e73b7..614f1a13 100644
--- a/internal/helpers/content_splitter.go
+++ b/internal/helpers/content_splitter.go
@@ -216,35 +216,27 @@ func mergeBlocksIntoChunks(blocks []markdownBlock, limit int) []string {
// hardSplitBlock splits a single oversized block at paragraph boundaries,
// falling back to rune-level splitting.
func hardSplitBlock(text string, limit int) []string {
- paragraphs := strings.Split(text, "\n\n")
+ // SplitAfter keeps the paragraph separator attached to the preceding
+ // paragraph. The previous Split implementation rebuilt separators while
+ // merging, but dropped them whenever a chunk boundary fell between two
+ // paragraphs, violating the no-content-loss invariant.
+ paragraphs := strings.SplitAfter(text, "\n\n")
var chunks []string
var current strings.Builder
currentRunes := 0
- for i, para := range paragraphs {
- separator := ""
- if i > 0 {
- separator = "\n\n"
- }
- paraWithSep := separator + para
- paraRunes := utf8.RuneCountInString(paraWithSep)
+ for _, para := range paragraphs {
+ paraRunes := utf8.RuneCountInString(para)
if currentRunes+paraRunes > limit && currentRunes > 0 {
chunks = append(chunks, current.String())
current.Reset()
currentRunes = 0
- paraWithSep = para
- paraRunes = utf8.RuneCountInString(paraWithSep)
}
// If single paragraph exceeds limit, split by runes
if paraRunes > limit {
- if currentRunes > 0 {
- chunks = append(chunks, current.String())
- current.Reset()
- currentRunes = 0
- }
- runes := []rune(paraWithSep)
+ runes := []rune(para)
for start := 0; start < len(runes); start += limit {
end := start + limit
if end > len(runes) {
@@ -255,7 +247,7 @@ func hardSplitBlock(text string, limit int) []string {
continue
}
- current.WriteString(paraWithSep)
+ current.WriteString(para)
currentRunes += paraRunes
}
if currentRunes > 0 {
diff --git a/internal/helpers/content_splitter_test.go b/internal/helpers/content_splitter_test.go
new file mode 100644
index 00000000..bbf5c547
--- /dev/null
+++ b/internal/helpers/content_splitter_test.go
@@ -0,0 +1,125 @@
+package helpers
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+func TestSplitMarkdownSafePreservesContentAndLimitsChunks(t *testing.T) {
+ short := "short 文本"
+ if got := splitMarkdownSafe(short, 100); len(got) != 1 || got[0] != short {
+ t.Fatalf("short split = %#v", got)
+ }
+
+ content := "# Heading\r\nparagraph one\r\n\r\n## Two\r\n| a | b |\r\n|---|---|\r\n| 1 | 2 |\r\nnormal\r\n### Three\r\n```go\r\nfmt.Println(\"hello\")\r\n```\r\ntail"
+ normalized := strings.ReplaceAll(content, "\r\n", "\n")
+ chunks := splitMarkdownSafe(content, 35)
+ if strings.Join(chunks, "") != normalized {
+ t.Fatalf("split content was not preserved:\nwant %q\n got %q", normalized, strings.Join(chunks, ""))
+ }
+ for _, chunk := range chunks {
+ if utf8.RuneCountInString(chunk) > 35 {
+ t.Errorf("chunk exceeds limit: %d %q", utf8.RuneCountInString(chunk), chunk)
+ }
+ }
+
+ blocks := parseMarkdownBlocks("before\n```\nunclosed")
+ if len(blocks) < 2 || blocks[len(blocks)-1].blockType != blockCodeBlock {
+ t.Fatalf("unclosed code blocks = %#v", blocks)
+ }
+}
+
+func TestMergeBlocksUsesHeadingsAndHardSplits(t *testing.T) {
+ blocks := []markdownBlock{
+ {text: "aaaa", blockType: blockNormal},
+ {text: "# h\n", blockType: blockH1},
+ {text: "bbbb", blockType: blockNormal},
+ {text: "cccc", blockType: blockNormal},
+ }
+ chunks := mergeBlocksIntoChunks(blocks, 9)
+ if strings.Join(chunks, "") != "aaaa# h\nbbbbcccc" || len(chunks) < 2 {
+ t.Fatalf("heading merge = %#v", chunks)
+ }
+
+ chunks = mergeBlocksIntoChunks([]markdownBlock{{text: "aaaa"}, {text: "bbbb"}, {text: "cccc"}}, 8)
+ if strings.Join(chunks, "") != "aaaabbbbcccc" {
+ t.Fatalf("greedy merge = %#v", chunks)
+ }
+
+ oversized := "one\n\ntwo\n\n" + strings.Repeat("界", 11)
+ chunks = mergeBlocksIntoChunks([]markdownBlock{{text: oversized}}, 5)
+ if strings.Join(chunks, "") != oversized {
+ t.Fatalf("oversized merge = %#v", chunks)
+ }
+ for _, chunk := range chunks {
+ if utf8.RuneCountInString(chunk) > 5 {
+ t.Errorf("hard-split chunk exceeds limit: %q", chunk)
+ }
+ }
+
+ if got := mergeBlocksIntoChunks(nil, 5); len(got) != 0 {
+ t.Fatalf("empty merge = %#v", got)
+ }
+}
+
+func TestHardSplitBlockCoversParagraphAndRuneBoundaries(t *testing.T) {
+ for _, text := range []string{
+ "aa\n\nbb\n\ncc",
+ "aa\n\n" + strings.Repeat("x", 12),
+ strings.Repeat("界", 13),
+ "",
+ } {
+ chunks := hardSplitBlock(text, 5)
+ if strings.Join(chunks, "") != text {
+ t.Errorf("hardSplitBlock(%q) = %#v", text, chunks)
+ }
+ for _, chunk := range chunks {
+ if utf8.RuneCountInString(chunk) > 5 {
+ t.Errorf("chunk exceeds limit: %q", chunk)
+ }
+ }
+ }
+}
+
+func TestRuntimeDefaultsRegistryValidationAndSnapshot(t *testing.T) {
+ runtimeDefaultsMu.Lock()
+ previous := runtimeDefaults
+ runtimeDefaults = make(map[string]edition.RuntimeDefaultFn)
+ runtimeDefaultsMu.Unlock()
+ t.Cleanup(func() {
+ runtimeDefaultsMu.Lock()
+ runtimeDefaults = previous
+ runtimeDefaultsMu.Unlock()
+ })
+
+ resolver := func(context.Context) (string, bool) { return "value", true }
+ RegisterRuntimeDefault("$value", resolver)
+ snapshot := RuntimeDefaultsSnapshot()
+ if len(snapshot) != 1 || snapshot["$value"] == nil {
+ t.Fatalf("RuntimeDefaultsSnapshot() = %#v", snapshot)
+ }
+ delete(snapshot, "$value")
+ if len(RuntimeDefaultsSnapshot()) != 1 {
+ t.Fatal("snapshot mutated the runtime registry")
+ }
+
+ for _, tc := range []struct {
+ name string
+ fn edition.RuntimeDefaultFn
+ }{
+ {"", resolver}, {"$nil", nil}, {"$value", resolver},
+ } {
+ func() {
+ defer func() {
+ if recover() == nil {
+ t.Errorf("RegisterRuntimeDefault(%q) did not panic", tc.name)
+ }
+ }()
+ RegisterRuntimeDefault(tc.name, tc.fn)
+ }()
+ }
+}
diff --git a/internal/helpers/coverage_pure_more_test.go b/internal/helpers/coverage_pure_more_test.go
new file mode 100644
index 00000000..67de81bd
--- /dev/null
+++ b/internal/helpers/coverage_pure_more_test.go
@@ -0,0 +1,295 @@
+package helpers
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+func TestPureScalarAndCommandHelpersCoverage(t *testing.T) {
+ previousDeps := deps
+ InitDeps(&productExampleCaller{})
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previousDeps })
+
+ for _, value := range []any{" text ", float64(12), int64(13), 14, json.Number("15"), true, nil} {
+ _ = toFlatString(value)
+ }
+ for _, value := range []any{"2026-01-02", "2026-01-02 03:04:05", "bad", float64(1), int64(2), 3, true} {
+ _, _ = normalizeWorkDate(value)
+ }
+ for _, value := range []string{"", "12", " 34 ", "1a"} {
+ _ = isNumericUserID(value)
+ }
+
+ root := &cobra.Command{Use: "calendar"}
+ known := &cobra.Command{Use: "event", Aliases: []string{"e"}}
+ hidden := &cobra.Command{Use: "hidden", Hidden: true}
+ root.AddCommand(known, hidden)
+ for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} {
+ _ = findUnknownVerb(root, args)
+ }
+ printUnknownSubcmdError(root, "evnt")
+ for _, depth := range []int{0, 1, 3} {
+ _ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth)
+ }
+ oldArgs := os.Args
+ t.Cleanup(func() { os.Args = oldArgs })
+ root.Flags().StringP("known", "k", "", "")
+ for _, args := range [][]string{
+ {"dws", "calendar", "--known=x"},
+ {"dws", "calendar", "--unknown"},
+ {"dws", "calendar", "-z"},
+ {"dws", "calendar", "--", "--ignored"},
+ } {
+ os.Args = args
+ _ = findUnknownFlag(root)
+ }
+
+ for _, event := range []any{
+ nil,
+ map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}},
+ map[string]any{"start": map[string]any{"dateTime": "bad"}, "created": float64(2)},
+ map[string]any{"updated": float64(3)},
+ map[string]any{},
+ } {
+ _ = calendarEventSortKey(event)
+ }
+ _ = buildReminders("5,bad,0,10")
+
+ params := map[string]string{"existing": "old"}
+ putChatChmodParam(params, "blank", "")
+ putChatChmodParam(params, "existing", "new")
+ putChatChmodParam(params, "new", " value ")
+ meta := conversationLocalFileMeta{FileName: "x.txt", FileType: "text/plain", ContentPath: "/tmp/x", FileSize: 3}
+ if _, err := buildConversationFileContent(1, 2, meta); err != nil {
+ t.Fatal(err)
+ }
+ for _, value := range []any{json.Number("1"), json.Number("bad"), float64(2), float64(-1), int64(3), 4, "5", "bad", true} {
+ _, _ = int64FromJSONScalar(value)
+ }
+ if err := printMCPText(`{"ok":true}`); err != nil {
+ t.Fatal(err)
+ }
+ if err := printMCPText("not-json"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestChartMailAndSheetPureHelpersCoverage(t *testing.T) {
+ valid := map[string]any{
+ "position": map[string]any{"row": float64(1), "col": float64(1)},
+ "dimensions": map[string]any{"width": float64(10), "height": float64(10)},
+ "chart": map[string]any{
+ "type": "line",
+ "series": []any{map[string]any{"value": []any{"A1:A2"}}},
+ },
+ }
+ chartCases := []map[string]any{
+ {},
+ {"position": "bad"},
+ {"position": map[string]any{"col": 1}},
+ {"position": map[string]any{"row": 1}},
+ {"position": map[string]any{"row": 1, "col": 1}},
+ }
+ for _, props := range chartCases {
+ _ = validateChartProperties(props)
+ }
+ mutations := []func(map[string]any){
+ func(m map[string]any) { m["dimensions"] = "bad" },
+ func(m map[string]any) { m["dimensions"] = map[string]any{"height": 1} },
+ func(m map[string]any) { m["dimensions"] = map[string]any{"width": float64(-1), "height": 1} },
+ func(m map[string]any) { m["dimensions"] = map[string]any{"width": 1} },
+ func(m map[string]any) { m["dimensions"] = map[string]any{"width": 1, "height": float64(-1)} },
+ func(m map[string]any) { delete(m, "chart") },
+ func(m map[string]any) { m["chart"] = "bad" },
+ func(m map[string]any) { m["chart"] = map[string]any{} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": 1} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": "bad"} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": "line"} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": "bad"} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": []any{}} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": []any{"bad"}} },
+ func(m map[string]any) { m["chart"] = map[string]any{"type": "line", "series": []any{map[string]any{}}} },
+ func(m map[string]any) {
+ m["chart"] = map[string]any{"type": "line", "series": []any{map[string]any{"value": "bad"}}}
+ },
+ func(m map[string]any) {
+ m["chart"] = map[string]any{"type": "line", "series": []any{map[string]any{"value": []any{}}}}
+ },
+ }
+ for _, mutate := range mutations {
+ var clone map[string]any
+ raw, _ := json.Marshal(valid)
+ _ = json.Unmarshal(raw, &clone)
+ mutate(clone)
+ _ = validateChartProperties(clone)
+ }
+ if err := validateChartProperties(valid); err != nil {
+ t.Fatal(err)
+ }
+
+ mailCases := []string{
+ `{`,
+ `[1]`,
+ `[{"object":"bad","or":[]}]`,
+ `[{"object":"from","or":"bad"}]`,
+ `[{"object":"from","or":[1]}]`,
+ `[{"object":"from","or":[{"and":"bad"}]}]`,
+ `[{"object":"from","or":[{"and":[1]}]}]`,
+ `[{"object":"from","or":[{"and":[{"operation":"bad"}]}]}]`,
+ `[{"object":"from","or":[{"and":[{"operation":"include"}]}]}]`,
+ }
+ for _, raw := range mailCases {
+ _, _ = parseMailRuleConditions(raw)
+ }
+ _ = generateContentId("My Image.PNG", 2)
+ _ = validateInlineAttachmentType("x.png")
+ _ = validateInlineAttachmentType("x.pdf")
+ _ = inlineHtmlTag("cid", "x.png")
+ _ = injectInlineCids("hello\n[inline:x.png]", []inlineAttachInfo{{name: "x.png", contentId: "cid"}, {name: "y.png", contentId: "cid2"}})
+ for _, raw := range []string{`{`, `{}`, `{"result":{"message":{"id":"m"}}}`, `{"result":{"messageId":"m2"}}`} {
+ _, _ = parseMailDraftId(raw)
+ }
+ for _, raw := range []string{`{`, `{}`, `{"result":{"uploadUrl":"/u"}}`} {
+ _, _ = parseMailUploadSession(raw)
+ }
+ for _, kind := range []string{"personal", "enterprise", ""} {
+ _ = mailUploadBaseURL(kind)
+ }
+
+ for _, text := range []string{`{`, `{}`, `{"fileName":"../x\\y.txt"}`, `{"result":{"fileName":"x.txt"}}`} {
+ _ = extractFileNameFromResponse(text)
+ }
+ for _, index := range []int{-1, 0, 25, 26, 701} {
+ _ = sheetColumnLetterFromZeroBased(index)
+ }
+ for _, value := range []any{nil, "bad", map[string]any{}, map[string]any{"x": nil}, map[string]any{"x": true}} {
+ _ = isEmptyDataValidation(value)
+ _ = isEmptyHyperlink(value)
+ }
+ _ = fillIntMatrix(2, 3, 4)
+ _ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
+ _ = maxColLen2D([][]int{{1}, {2, 3}})
+ _ = checkMatrixShape(1, 2, 1, 2, "x")
+ _ = checkMatrixShape(1, 1, 1, 2, "x")
+ for _, value := range []any{1, -1, int64(2), int64(-2), float64(3), float64(3.5), float64(-3), json.Number("4"), json.Number("bad"), "bad"} {
+ _, _ = nonNegativeJSONInt(value)
+ }
+ _ = buildNonEmptyRangeFromLegacy(map[string]any{})
+ _ = buildNonEmptyRangeFromLegacy(map[string]any{"lastNonEmptyRow": float64(1), "lastNonEmptyColumn": float64(2)})
+ _ = normalizeNonEmptyRangeObject(nil)
+ _ = normalizeNonEmptyRangeObject(map[string]any{"range": "A1:B2"})
+ _ = normalizeNonEmptyRangeObject(map[string]any{"range": "A1:B2", "lastCell": "B2", "lastRow": float64(2), "lastColumn": "B"})
+ for _, sheet := range []map[string]any{
+ {"nonEmptyRange": map[string]any{"range": "A1:B2", "lastCell": "B2", "lastRow": float64(2), "lastColumn": "B"}},
+ {"lastNonEmptyRow": float64(1), "lastNonEmptyColumn": float64(2)},
+ {},
+ } {
+ normalizeSheetInfoCoordinatesForAgent(sheet)
+ }
+
+ styles := []struct {
+ spec styleSpec
+ rows, cols int
+ }{
+ {styleSpec{}, 0, 1},
+ {styleSpec{}, 1001, 1},
+ {styleSpec{}, 1000, 31},
+ {styleSpec{BgColor: "red", BgColorsJSON: `[["red"]]`}, 1, 1},
+ {styleSpec{FontSize: 1, FontSizesJSON: `[[1]]`}, 1, 1},
+ {styleSpec{FontSize: -1}, 1, 1},
+ {styleSpec{FontSizesJSON: `{`}, 1, 1},
+ {styleSpec{FontSizesJSON: `[[1,2]]`}, 1, 1},
+ {styleSpec{FontSizesJSON: `[[1]]`}, 1, 1},
+ {styleSpec{HAlign: "bad"}, 1, 1},
+ {styleSpec{VAlign: "bad"}, 1, 1},
+ {styleSpec{FontWeight: "bad"}, 1, 1},
+ {styleSpec{WordWrap: "bad"}, 1, 1},
+ {styleSpec{WordWrap: "clip"}, 1, 1},
+ {styleSpec{NumberFormat: "0.00"}, 1, 1},
+ {styleSpec{}, 1, 1},
+ }
+ for _, style := range styles {
+ _ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
+ }
+ for _, tc := range []struct {
+ scalar, raw string
+ enum map[string]bool
+ }{
+ {"x", `[["x"]]`, nil},
+ {"bad", "", hAlignEnum},
+ {"", "{", nil},
+ {"", `[["x","y"]]`, nil},
+ {"", `[["bad"]]`, hAlignEnum},
+ {"", `[[""]]`, hAlignEnum},
+ {"", `[["left"]]`, hAlignEnum},
+ } {
+ _ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
+ }
+ views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
+ _, _ = findFilterViewByID(views, "one")
+ _, _ = findFilterViewByID(views, "two")
+ _, _ = findFilterViewByID(views, "missing")
+}
+
+func TestSheetResponseCleanupCoverage(t *testing.T) {
+ previous := deps
+ caller := &helpersCoreCaller{format: "json"}
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previous })
+
+ for _, text := range []string{
+ "",
+ "not-json",
+ `{"cells":"bad"}`,
+ `{"cells":["bad",["bad",{"value":1},{"dataValidation":null,"hyperlink":null},{"dataValidation":{"x":null},"hyperlink":{"x":null}},{"dataValidation":{"x":true},"hyperlink":{"x":true}}]]}`,
+ } {
+ caller.result = textToolResult(text)
+ _ = callMCPToolCellInfos(nil)
+ }
+ for _, text := range []string{"", "not-json", `{}`, `{"lastNonEmptyRow":1,"lastNonEmptyColumn":2}`} {
+ caller.result = textToolResult(text)
+ _ = callMCPToolSheetInfo(nil)
+ }
+ caller.err = fmt.Errorf("failed")
+ _ = callMCPToolCellInfos(nil)
+ _ = callMCPToolSheetInfo(nil)
+}
+
+func TestSmallHandlerAndFormatterCoverage(t *testing.T) {
+ if (Manifest{Vendor: " vendor ", Name: " name "}).FullName() != "vendor/name" {
+ t.Fatal("manifest full name")
+ }
+ _ = (openCompatHandler{name: "conference", buildFn: newConferenceCommand}).Name()
+ _ = (wukongHandler{name: "doc", buildFn: newDocCommand}).Name()
+ _ = (devHandler{}).Name()
+ _ = newDocCommentCommand()
+ _ = newHrmregisterCommand()
+ _ = newPatCommand()
+
+ var out bytes.Buffer
+ f := &Formatter{w: &out, errW: io.Discard}
+ f.PrintSuccess("ok")
+ f.PrintError("bad")
+ if out.Len() == 0 {
+ t.Fatal("formatter output is empty")
+ }
+
+ parent := &cobra.Command{Use: "sheet"}
+ group := &cobra.Command{Use: "range"}
+ group.AddCommand(&cobra.Command{Use: "read"})
+ parent.AddCommand(group)
+ _ = deepSuggestSubcommand(parent, "read")
+ _ = deepSuggestSubcommand(parent, "missing")
+ _ = time.Now()
+}
diff --git a/internal/helpers/devapp_coverage_edges_test.go b/internal/helpers/devapp_coverage_edges_test.go
new file mode 100644
index 00000000..dec1b7bd
--- /dev/null
+++ b/internal/helpers/devapp_coverage_edges_test.go
@@ -0,0 +1,247 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/spf13/cobra"
+)
+
+type devAppErrorRunner struct{ err error }
+
+func (r devAppErrorRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
+ return executor.Result{}, r.err
+}
+
+func TestDevAppNormalizationEdges(t *testing.T) {
+ for _, response := range []map[string]any{
+ {},
+ {"content": "plain"},
+ {"content": map[string]any{}},
+ {"content": map[string]any{"success": false}},
+ {"content": map[string]any{"success": true}},
+ {"content": map[string]any{"success": true, "result": nil}},
+ } {
+ result := executor.Result{Response: response}
+ _ = normalizeDevAppServiceResult(result)
+ }
+ result := executor.Result{Response: map[string]any{
+ "content": map[string]any{"success": true, "result": "unwrapped"},
+ }}
+ if got := normalizeDevAppServiceResult(result).Response["content"]; got != "unwrapped" {
+ t.Fatalf("content = %#v, want unwrapped", got)
+ }
+
+ plain := executor.Result{Response: map[string]any{"content": "plain"}}
+ if got := normalizeDevAppToolResult(devAppDisableTool, plain).Response["content"]; got != "plain" {
+ t.Fatalf("non-map content = %#v, want unchanged", got)
+ }
+ for tool, key := range map[string]string{devAppDisableTool: "disabled", devAppEnableTool: "enabled"} {
+ content := map[string]any{key: false}
+ normalizeDevAppToolResult(tool, executor.Result{Response: map[string]any{"content": content}})
+ if content[key] != false {
+ t.Fatalf("%s existing value = %#v, want false", key, content[key])
+ }
+ }
+}
+
+func TestDevAppApprovalNormalizationEdges(t *testing.T) {
+ for _, content := range []map[string]any{
+ {},
+ {"approvalCandidates": "invalid"},
+ {"approvalCandidates": []any{}},
+ {"approvalCandidates": []any{"invalid"}},
+ } {
+ normalizeDevAppVersionApproval(content)
+ }
+
+ nonSelect := map[string]any{
+ "approvalMode": "automatic",
+ "approvalCandidates": []any{
+ map[string]any{},
+ },
+ }
+ normalizeDevAppVersionApproval(nonSelect)
+ options, ok := nonSelect["approvalOptions"].([]map[string]any)
+ if !ok || len(options) != 1 || options[0]["label"] != "候选审批人 1" {
+ t.Fatalf("approvalOptions = %#v, want fallback candidate", nonSelect["approvalOptions"])
+ }
+
+ selectMode := map[string]any{
+ "approvalMode": " select_approver ",
+ "approvalCandidates": []any{
+ map[string]any{"staffId": "u-1", "nickName": " User ", "mainAdmin": "TRUE"},
+ },
+ }
+ normalizeDevAppVersionApproval(selectMode)
+ steps, ok := selectMode["nextSteps"].([]map[string]any)
+ if !ok || len(steps) != 2 {
+ t.Fatalf("nextSteps = %#v, want selection and publish", selectMode["nextSteps"])
+ }
+ command, _ := steps[1]["command"].(string)
+ if !strings.Contains(command, "--unified-app-id ") || !strings.Contains(command, "--version-id ") {
+ t.Fatalf("publish command = %q, want locator placeholders", command)
+ }
+
+ labels := []struct {
+ name, userID string
+ admin bool
+ want string
+ }{
+ {" Name ", "", false, "Name"},
+ {"", "u-1", false, "userId: u-1"},
+ {"Name", "u-1", true, "Name(userId: u-1)(主管理员)"},
+ {" ", "", true, ""},
+ }
+ for _, tc := range labels {
+ if got := devAppApprovalCandidateLabel(tc.name, tc.userID, tc.admin); got != tc.want {
+ t.Errorf("candidate label = %q, want %q", got, tc.want)
+ }
+ }
+ if devAppOptionKey(-1) != "0" || devAppOptionKey(0) != "A" || devAppOptionKey(26) != "27" {
+ t.Fatalf("unexpected option keys: %q %q %q", devAppOptionKey(-1), devAppOptionKey(0), devAppOptionKey(26))
+ }
+}
+
+func TestDevAppRobotAndStepEdges(t *testing.T) {
+ empty := map[string]any{}
+ normalizeDevAppRobotResult(empty)
+ if _, ok := empty["lifecycle"]; ok {
+ t.Fatal("blank status should not create lifecycle")
+ }
+
+ unknown := map[string]any{"status": " future "}
+ normalizeDevAppRobotResult(unknown)
+ lifecycle := unknown["lifecycle"].(map[string]any)
+ if lifecycle["phase"] != "unknown" {
+ t.Fatalf("unknown lifecycle = %#v", lifecycle)
+ }
+ if _, ok := unknown["nextSteps"]; ok {
+ t.Fatal("unknown status should not create next steps")
+ }
+
+ waiting := map[string]any{"status": "WAITING"}
+ normalizeDevAppRobotResult(waiting)
+ waitSteps := waiting["nextSteps"].([]map[string]any)
+ if command := waitSteps[0]["command"].(string); !strings.Contains(command, "--task-id ") {
+ t.Fatalf("poll command = %q, want task placeholder", command)
+ }
+
+ failed := map[string]any{"status": "FAIL"}
+ normalizeDevAppRobotResult(failed)
+ failSteps := failed["nextSteps"].([]map[string]any)
+ if command := failSteps[0]["command"].(string); !strings.Contains(command, "--task-id ") {
+ t.Fatalf("retry command = %q, want task placeholder", command)
+ }
+
+ success := map[string]any{"status": "SUCCESS", "unifiedAppId": "u-1", "clientId": "client-only"}
+ normalizeDevAppRobotResult(success)
+ if got := success["nextSteps"].([]map[string]any); len(got) != 4 {
+ t.Fatalf("steps without complete credentials = %d, want 4", len(got))
+ }
+
+ connect := devAppRobotConnectStep("", "")
+ if command := connect["command"].(string); !strings.Contains(command, "--robot-client-id ") {
+ t.Fatalf("connect command = %q, want client placeholder", command)
+ }
+
+ minimal := devAppNextStep(devAppStep{ID: "minimal"})
+ if _, ok := minimal["command"]; ok {
+ t.Fatalf("minimal step unexpectedly contains command: %#v", minimal)
+ }
+ if _, ok := minimal["dryRunCommand"]; ok {
+ t.Fatalf("minimal step unexpectedly contains dry run command: %#v", minimal)
+ }
+}
+
+func TestDevAppScalarAndScopeEdges(t *testing.T) {
+ content := map[string]any{
+ "nil": nil,
+ "text": " value ",
+ "number": 42,
+ "yes": true,
+ "no": false,
+ "truth": " TRUE ",
+ "other": 1,
+ }
+ if devAppContentString(content, "missing") != "" || devAppContentString(content, "nil") != "" {
+ t.Fatal("missing and nil strings should be empty")
+ }
+ if devAppContentString(content, "text") != "value" || devAppContentString(content, "number") != "42" {
+ t.Fatal("string conversion did not trim or stringify")
+ }
+ if !devAppContentBool(content, "yes") || devAppContentBool(content, "no") || !devAppContentBool(content, "truth") || devAppContentBool(content, "other") {
+ t.Fatal("boolean conversion returned unexpected values")
+ }
+ if devAppContentBool(content, "missing") || devAppContentBool(content, "nil") {
+ t.Fatal("missing and nil booleans should be false")
+ }
+
+ valid := map[string]any{"scopes": []any{"scope-a", map[string]any{"scopeValue": "scope-b"}}}
+ normalizeDevAppScopeValueArray(valid, "scopes")
+ if want := []string{"scope-a", "scope-b"}; !reflect.DeepEqual(valid["scopes"], want) {
+ t.Fatalf("scopes = %#v, want %#v", valid["scopes"], want)
+ }
+ empty := map[string]any{"scopes": []any{}}
+ normalizeDevAppScopeValueArray(empty, "scopes")
+ if got, ok := empty["scopes"].([]string); !ok || len(got) != 0 {
+ t.Fatalf("empty scopes = %#v, want []string", empty["scopes"])
+ }
+ invalidValues := []any{"", map[string]any{}, 42}
+ invalid := map[string]any{"scopes": invalidValues, "other": "unchanged"}
+ normalizeDevAppScopeValueArray(invalid, "missing")
+ normalizeDevAppScopeValueArray(invalid, "scopes")
+ if !reflect.DeepEqual(invalid["scopes"], invalidValues) {
+ t.Fatalf("invalid scopes = %#v, want unchanged", invalid["scopes"])
+ }
+}
+
+func TestDevAppCommandUtilityEdges(t *testing.T) {
+ annotated := annotateDevAppTool(&cobra.Command{Use: "leaf"}, "tool")
+ if annotated.Annotations["mcp-tool"] != "tool" || annotated.Annotations["mcp-source"] != "op-app" {
+ t.Fatalf("annotations = %#v", annotated.Annotations)
+ }
+
+ cmd := &cobra.Command{Use: "leaf"}
+ cmd.Flags().String("primary", "", "")
+ cmd.Flags().String("fallback", "fallback-value", "")
+ cmd.Flags().Int("count", 7, "")
+ if got := devAppFlagOrFallback(cmd, "primary", "fallback"); got != "fallback-value" {
+ t.Fatalf("fallback = %q", got)
+ }
+ if err := cmd.Flags().Set("primary", "primary-value"); err != nil {
+ t.Fatal(err)
+ }
+ if got := devAppFlagOrFallback(cmd, "primary", "fallback"); got != "primary-value" {
+ t.Fatalf("primary = %q", got)
+ }
+ if devAppIntFlag(cmd, "count") != 7 {
+ t.Fatal("int flag not read")
+ }
+ params := map[string]any{}
+ devAppPutString(params, "empty", "")
+ devAppPutInt(params, "zero", 0)
+ devAppPutInt(params, "count", 7)
+ if len(params) != 1 || params["count"] != 7 {
+ t.Fatalf("params = %#v", params)
+ }
+
+ wantErr := errors.New("runner failure")
+ if err := runDevAppTool(devAppErrorRunner{err: wantErr}, cmd, "tool", nil); !errors.Is(err, wantErr) {
+ t.Fatalf("runDevAppTool error = %v, want %v", err, wantErr)
+ }
+
+ for _, response := range []map[string]any{
+ {"name": "top"},
+ {"content": map[string]any{"name": "content"}},
+ {"content": map[string]any{"result": map[string]any{"name": "result"}}},
+ {"content": "invalid"},
+ {"content": map[string]any{"result": "invalid"}},
+ } {
+ _ = devAppExtractString(response, "name")
+ }
+}
diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go
index fdd49381..a09d28c7 100644
--- a/internal/helpers/doc.go
+++ b/internal/helpers/doc.go
@@ -282,7 +282,11 @@ func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]stri
}
// httpGetFile downloads file content via HTTP GET. Package-level for test injection.
-var httpGetFile = defaultHTTPGetFile
+var (
+ httpGetFile = defaultHTTPGetFile
+ docCreateDestination = os.Create
+ docCopyContent = io.Copy
+)
// SetHTTPGetFile overrides the HTTP GET function (for testing). Pass nil to restore default.
func SetHTTPGetFile(fn func(ctx context.Context, url string, headers map[string]string, destPath string) error) {
@@ -432,13 +436,13 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
- outFile, err := os.Create(destPath)
+ outFile, err := docCreateDestination(destPath)
if err != nil {
return err
}
defer outFile.Close()
- if _, err := io.Copy(outFile, resp.Body); err != nil {
+ if _, err := docCopyContent(outFile, resp.Body); err != nil {
return err
}
@@ -730,9 +734,6 @@ func previewDocOverwriteDiff(ctx context.Context, cmd *cobra.Command, nodeID, ne
}
current := extractMarkdownField(text)
out := cmd.OutOrStdout()
- if out == nil {
- out = os.Stdout
- }
fmt.Fprint(out, renderDocOverwriteDiff(nodeID, current, newMarkdown))
return nil
}
@@ -1132,10 +1133,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
if idx, _ := cmd.Flags().GetInt("index"); cmd.Flags().Lookup("index").Changed {
toolArgs["index"] = idx
}
- if md != "" {
- return docWritePipeline(cmd, "update_document", toolArgs, md, "doc update")
- }
- return callMCPTool("update_document", toolArgs)
+ return docWritePipeline(cmd, "update_document", toolArgs, md, "doc update")
},
}
@@ -1237,7 +1235,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
流程:
1. 获取下载 URL 和签名请求头 (download_file)
2. HTTP GET 下载文件二进制内容到本地`,
- Example: ` dws doc download --node NODE_ID
+ Example: ` dws doc download --node NODE_ID --output ./download.bin
dws doc download --node NODE_ID --output ./report.pdf
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/" --output ~/downloads/`,
RunE: runDocDownload,
@@ -3200,7 +3198,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
select {
case <-ctx.Done():
return "", fmt.Errorf("导出轮询被取消 (jobId=%s): %w", jobID, ctx.Err())
- case <-time.After(interval):
+ case <-helperAfter(interval):
}
text, queryErr := callMCPToolReturnText(ctx, "query_export_job", map[string]any{"jobId": jobID})
@@ -3261,7 +3259,7 @@ func pollDocImportTask(ctx context.Context, taskID string) (map[string]any, erro
select {
case <-ctx.Done():
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, ctx.Err())
- case <-time.After(interval):
+ case <-helperAfter(interval):
}
text, queryErr := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
diff --git a/internal/helpers/doc/doc_jsonml_validate_v2_test.go b/internal/helpers/doc/doc_jsonml_validate_v2_test.go
new file mode 100644
index 00000000..fd4832e8
--- /dev/null
+++ b/internal/helpers/doc/doc_jsonml_validate_v2_test.go
@@ -0,0 +1,170 @@
+package doc
+
+import (
+ "strings"
+ "testing"
+)
+
+func floatPtr(value float64) *float64 { return &value }
+
+func TestSchemaLoadingAndLookup(t *testing.T) {
+ if !schemaV2.IsKnownTag("root") || schemaV2.IsKnownTag("definitely-unknown") {
+ t.Fatal("schema known-tag lookup is incorrect")
+ }
+ root := schemaV2.TagSchemaFor("root")
+ if root == nil || !root.IsAllowedChild("p") || root.IsAllowedChild("span") {
+ t.Fatal("schema child lookup is incorrect")
+ }
+ if schemaV2.TagSchemaFor("definitely-unknown") != nil {
+ t.Fatal("unknown tag returned a schema")
+ }
+ loaded := mustLoadSchemaV2([]byte(`{"tags":{"x":{"allowed_children":["#text"],"attrs":{}}}}`))
+ if !loaded.IsKnownTag("x") || !loaded.TagSchemaFor("x").IsAllowedChild("#text") {
+ t.Fatalf("mustLoadSchemaV2() = %#v", loaded)
+ }
+ for _, raw := range [][]byte{[]byte(`{`), []byte(`{"tags":{}}`)} {
+ func() {
+ defer func() {
+ if recover() == nil {
+ t.Errorf("mustLoadSchemaV2(%q) did not panic", raw)
+ }
+ }()
+ _ = mustLoadSchemaV2(raw)
+ }()
+ }
+}
+
+func TestValidateJSONMLSourceAndShapes(t *testing.T) {
+ if result := ValidateJsonMLBodyV2(nil); result.HasErrors() || result.Summary() != "" {
+ t.Fatalf("empty body result = %#v", result)
+ }
+ if result := ValidateJsonMLSource(nil); result.HasErrors() {
+ t.Fatalf("empty source result = %#v", result)
+ }
+ if result := ValidateJsonMLSource([]byte(`{`)); !result.HasErrors() {
+ t.Fatal("invalid JSON source produced no error")
+ }
+ if result := ValidateJsonMLSource([]byte(`{"value":1}`)); !result.HasErrors() {
+ t.Fatal("non-JSONML object produced no error")
+ }
+ if result := ValidateJsonMLSource([]byte(`{"jsonml":"bad"}`)); !result.HasErrors() {
+ t.Fatal("invalid JSONML wrapper produced no error")
+ }
+ if result := ValidateJsonMLSource([]byte(`{"jsonml":["root",{},["p",{},["span",{},"ok"]]]}`)); result.HasErrors() {
+ t.Fatalf("valid wrapped JSONML errors = %v", result.Errors)
+ }
+ if result := ValidateJsonMLSource([]byte(`["root",{},["p",{},["span",{},"ok"]]]`)); result.HasErrors() {
+ t.Fatalf("valid JSONML errors = %v", result.Errors)
+ }
+
+ tests := []any{
+ "not-an-array",
+ []any{},
+ []any{1},
+ []any{"unknown", map[string]any{}},
+ []any{"root", map[string]any{}, "bare text", nil, 3},
+ []any{"root", map[string]any{}, []any{"span", map[string]any{}, "bad child"}},
+ []any{"p", map[string]any{"unknown": true, "jc": 1}, []any{"span", map[string]any{}, "ok"}},
+ }
+ for _, node := range tests {
+ result := ValidateJsonMLNodeV2(node)
+ if len(result.Errors)+len(result.Warnings) == 0 {
+ t.Errorf("ValidateJsonMLNodeV2(%#v) returned no diagnostics", node)
+ }
+ }
+ result := ValidateJsonMLBodyV2([]any{[]any{"p", map[string]any{}}, "bad", []any{1}})
+ if len(result.Errors)+len(result.Warnings) == 0 {
+ t.Fatal("array-of-blocks validation returned no diagnostics")
+ }
+ if DiagnoseJSONError([]byte(`{`)) != "" {
+ t.Fatal("fallback DiagnoseJSONError() should be empty")
+ }
+}
+
+func TestTypeValidationCoversEverySchemaType(t *testing.T) {
+ result := &JsonMLValidationResult{}
+ cases := []struct {
+ value any
+ spec TypeSpec
+ }{
+ {"anything", TypeSpec{Type: "any"}},
+ {"ok", TypeSpec{Type: "string"}},
+ {1, TypeSpec{Type: "string"}},
+ {"bad", TypeSpec{Type: "number"}},
+ {float64(-1), TypeSpec{Type: "number", Min: floatPtr(0)}},
+ {float64(11), TypeSpec{Type: "number", Max: floatPtr(10)}},
+ {float64(5), TypeSpec{Type: "number", Min: floatPtr(0), Max: floatPtr(10)}},
+ {true, TypeSpec{Type: "boolean"}},
+ {"bad", TypeSpec{Type: "boolean"}},
+ {[]any{}, TypeSpec{Type: "array"}},
+ {"bad", TypeSpec{Type: "array"}},
+ {"bad", TypeSpec{Type: "object"}},
+ {map[string]any{"known": "ok", "extra": true}, TypeSpec{Type: "object", Fields: map[string]TypeSpec{"known": {Type: "string"}}}},
+ {1, TypeSpec{Type: "enum", Values: []string{"one"}}},
+ {"one", TypeSpec{Type: "enum", Values: []string{"one"}}},
+ {"two", TypeSpec{Type: "enum", Values: []string{"one"}}},
+ {"ok", TypeSpec{Type: "union", Types: []string{"string"}}},
+ {true, TypeSpec{Type: "union", Types: []string{"string"}, WarnTypes: []string{"boolean"}}},
+ {map[string]any{}, TypeSpec{Type: "union", Types: []string{"string"}, WarnTypes: []string{"number"}}},
+ }
+ for index, tc := range cases {
+ checkTypeV2(tc.value, &tc.spec, "$.value", result)
+ _ = index
+ }
+ if len(result.Errors) == 0 || len(result.Warnings) == 0 || !strings.Contains(result.Summary(), "JSONML") {
+ t.Fatalf("type validation result = %#v", result)
+ }
+
+ for _, value := range []any{float64(1), int(1), int64(1), "bad"} {
+ _, _ = toFloat64(value)
+ }
+ for _, tc := range []struct {
+ value any
+ types []string
+ }{
+ {"x", []string{"string"}}, {1, []string{"number"}}, {true, []string{"boolean"}},
+ {[]any{}, []string{"array"}}, {map[string]any{}, []string{"object"}}, {nil, []string{"null"}},
+ {struct{}{}, []string{"any"}}, {struct{}{}, []string{"string", "unknown"}},
+ } {
+ _ = matchesUnion(tc.value, tc.types)
+ }
+}
+
+func TestDiagnosticsFormattingAndAggregation(t *testing.T) {
+ diagnostics := DiagnosticList{
+ {Range: Range{Start: Position{Line: 2, Col: 3}}, Severity: SeverityError, Code: "E", Message: "bad", Suggestion: "fix"},
+ {Severity: SeverityWarning, Code: "W", Message: "warn", Source: SourceJSONMLSchema},
+ {Severity: SeverityInformation, Code: "I", Message: "info"},
+ {Severity: SeverityHint, Code: "H", Message: "hint"},
+ }
+ if !diagnostics.HasErrors() || diagnostics.ErrorCount() != 1 || diagnostics.WarningCount() != 1 {
+ t.Fatalf("diagnostic counts = %#v", diagnostics)
+ }
+ if len(diagnostics.Filter(SourceJSONMLSchema)) != 1 || !strings.Contains(diagnostics.Summary(), "fix") {
+ t.Fatalf("diagnostic output = %q", diagnostics.Summary())
+ }
+ legacy := diagnostics.ToLegacyResult()
+ if len(legacy.Errors) != 1 || len(legacy.Warnings) != 3 || !legacy.HasErrors() {
+ t.Fatalf("legacy result = %#v", legacy)
+ }
+ if DiagnosticList(nil).Summary() != "" || (&JsonMLValidationResult{}).Summary() != "" {
+ t.Fatal("empty diagnostics produced output")
+ }
+ for severity := SeverityError; severity <= SeverityHint; severity++ {
+ _ = severity.String()
+ }
+ if Severity(99).String() != "severity(99)" {
+ t.Fatal("unknown severity formatting changed")
+ }
+
+ warnings := DiagnosticList{{Severity: SeverityWarning, Message: "warn"}}
+ if warnings.HasErrors() || !strings.Contains(warnings.Summary(), "警告") {
+ t.Fatalf("warning summary = %q", warnings.Summary())
+ }
+ result := &JsonMLValidationResult{}
+ result.addError("$", "bad.", "")
+ result.addWarn("$", "warn", "try this")
+ if !strings.Contains(result.Summary(), "try this") {
+ t.Fatalf("legacy summary = %q", result.Summary())
+ }
+}
diff --git a/internal/helpers/doc_command_edges_test.go b/internal/helpers/doc_command_edges_test.go
new file mode 100644
index 00000000..db424eec
--- /dev/null
+++ b/internal/helpers/doc_command_edges_test.go
@@ -0,0 +1,483 @@
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+func runDocCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
+ t.Helper()
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ root := newDocCommand()
+ installExampleGlobalFlags(root)
+ root.SilenceErrors = true
+ root.SilenceUsage = true
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.SetArgs(args)
+ return root.ExecuteContext(context.Background())
+}
+
+func TestDocTransferAndDiffEdges(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "doc"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"markdown":"before"}`}}}
+ installScriptedCaller(t, caller)
+
+ root := newDocCommand()
+ copyCmd, _, _ := root.Find([]string{"copy"})
+ _ = copyCmd.Flags().Set("node", "node")
+ _ = copyCmd.Flags().Set("folder", "12345")
+ if err := copyCmd.RunE(copyCmd, nil); err == nil {
+ t.Fatal("numeric document folder unexpectedly accepted")
+ }
+
+ previewCmd := root
+ var out bytes.Buffer
+ previewCmd.SetOut(&out)
+ if err := previewDocOverwriteDiff(context.Background(), previewCmd, "node", "after"); err != nil {
+ t.Fatal(err)
+ }
+ caller.steps = []scriptedToolStep{{err: errors.New("read")}}
+ caller.index = 0
+ if err := previewDocOverwriteDiff(context.Background(), previewCmd, "node", "after"); err == nil {
+ t.Fatal("preview read failure returned nil")
+ }
+ if got := extractMarkdownField(`{"markdown":"body"}`); got != "body" {
+ t.Fatalf("markdown extraction = %q", got)
+ }
+ long := strings.TrimSuffix(strings.Repeat("line\n", 25), "\n")
+ diff := renderDocOverwriteDiff("node", long, long)
+ if !strings.Contains(diff, "5 more lines") {
+ t.Fatalf("long diff was not truncated: %s", diff)
+ }
+}
+
+func TestDocUploadAndMediaErrorEdges(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "doc"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ oldPut, oldGet := httpPutFile, httpGetFile
+ t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
+ file := filepath.Join(t.TempDir(), "file.txt")
+ if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("upload input validation", func(t *testing.T) {
+ installScriptedCaller(t, &scriptedToolCaller{})
+ root := newDocCommand()
+ upload, _, _ := root.Find([]string{"upload"})
+ _ = upload.Flags().Set("file", t.TempDir())
+ if err := upload.RunE(upload, nil); err == nil {
+ t.Fatal("directory upload returned nil")
+ }
+ root = newDocCommand()
+ upload, _, _ = root.Find([]string{"upload"})
+ _ = upload.Flags().Set("file", filepath.Join(t.TempDir(), "missing"))
+ if err := upload.RunE(upload, nil); err == nil {
+ t.Fatal("missing upload returned nil")
+ }
+ root = newDocCommand()
+ upload, _, _ = root.Find([]string{"upload"})
+ _ = upload.Flags().Set("file", file)
+ _ = upload.Flags().Set("folder", "123")
+ if err := upload.RunE(upload, nil); err == nil {
+ t.Fatal("numeric folder returned nil")
+ }
+ })
+
+ t.Run("download failure", func(t *testing.T) {
+ installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"resourceUrl":"https://download/file"}`}}})
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return errors.New("get") }
+ root := newDocCommand()
+ download, _, _ := root.Find([]string{"download"})
+ _ = download.Flags().Set("node", "node")
+ _ = download.Flags().Set("output", filepath.Join(t.TempDir(), "out"))
+ if err := download.RunE(download, nil); err == nil {
+ t.Fatal("download failure returned nil")
+ }
+ })
+
+ t.Run("upload put failure", func(t *testing.T) {
+ installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}}})
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return errors.New("put") }
+ root := newDocCommand()
+ upload, _, _ := root.Find([]string{"upload"})
+ _ = upload.Flags().Set("file", file)
+ if err := upload.RunE(upload, nil); err == nil {
+ t.Fatal("put failure returned nil")
+ }
+ })
+ for _, tc := range []struct {
+ name string
+ step scriptedToolStep
+ }{
+ {"upload credential failure", scriptedToolStep{err: errors.New("credential")}},
+ {"upload credential parse failure", scriptedToolStep{text: `{}`}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{tc.step}})
+ root := newDocCommand()
+ upload, _, _ := root.Find([]string{"upload"})
+ _ = upload.Flags().Set("file", file)
+ if err := upload.RunE(upload, nil); err == nil {
+ t.Fatal("upload failure returned nil")
+ }
+ })
+ }
+
+ mediaCommand := func(t *testing.T, caller *scriptedToolCaller, path, mime string) error {
+ t.Helper()
+ installScriptedCaller(t, caller)
+ root := newDocCommand()
+ media, _, _ := root.Find([]string{"media", "insert"})
+ _ = media.Flags().Set("node", "node")
+ _ = media.Flags().Set("file", path)
+ if mime != "" {
+ _ = media.Flags().Set("mime-type", mime)
+ }
+ return media.RunE(media, nil)
+ }
+
+ t.Run("media directory", func(t *testing.T) {
+ if err := mediaCommand(t, &scriptedToolCaller{}, t.TempDir(), ""); err == nil {
+ t.Fatal("directory media returned nil")
+ }
+ })
+ t.Run("media missing file", func(t *testing.T) {
+ if err := mediaCommand(t, &scriptedToolCaller{}, filepath.Join(t.TempDir(), "missing"), ""); err == nil {
+ t.Fatal("missing media returned nil")
+ }
+ })
+ t.Run("media dry run", func(t *testing.T) {
+ if err := mediaCommand(t, &scriptedToolCaller{dry: true}, file, ""); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("media credential failure", func(t *testing.T) {
+ if err := mediaCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("credential")}}}, file, ""); err == nil {
+ t.Fatal("credential failure returned nil")
+ }
+ })
+ t.Run("media parse failure", func(t *testing.T) {
+ if err := mediaCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, file, ""); err == nil {
+ t.Fatal("parse failure returned nil")
+ }
+ })
+ t.Run("media put failure", func(t *testing.T) {
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return errors.New("put") }
+ if err := mediaCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`}}}, file, ""); err == nil {
+ t.Fatal("media put failure returned nil")
+ }
+ })
+ t.Run("media insert failure", func(t *testing.T) {
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`}, {err: errors.New("insert")}}}
+ if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
+ t.Fatal("media insert failure returned nil")
+ }
+ })
+ t.Run("large image becomes attachment", func(t *testing.T) {
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ large := filepath.Join(t.TempDir(), "large.png")
+ if err := os.WriteFile(large, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Truncate(large, 21*1024*1024); err != nil {
+ t.Fatal(err)
+ }
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
+ if err := mediaCommand(t, caller, large, "image/png"); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
+
+func TestDefaultDocHTTPTransportEdges(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "body")
+ if err := os.WriteFile(file, []byte("body"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := defaultHTTPPutFile(cancelled, "http://127.0.0.1:1", nil, file, 4); err == nil {
+ t.Fatal("cancelled upload returned nil")
+ }
+ if err := defaultHTTPGetFile(cancelled, "http://127.0.0.1:1", nil, filepath.Join(t.TempDir(), "out")); err == nil {
+ t.Fatal("cancelled download returned nil")
+ }
+
+ oldCreate, oldCopy := docCreateDestination, docCopyContent
+ t.Cleanup(func() { docCreateDestination, docCopyContent = oldCreate, oldCopy })
+ docCreateDestination = func(string) (*os.File, error) { return nil, errors.New("create") }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "body")
+ }))
+ t.Cleanup(server.Close)
+ if err := defaultHTTPGetFile(context.Background(), server.URL, nil, filepath.Join(t.TempDir(), "out")); err == nil {
+ t.Fatal("destination creation failure returned nil")
+ }
+ docCreateDestination = os.Create
+ docCopyContent = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }
+ if err := defaultHTTPGetFile(context.Background(), server.URL, nil, filepath.Join(t.TempDir(), "out")); err == nil {
+ t.Fatal("copy failure returned nil")
+ }
+}
+
+func TestDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
+ oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
+ t.Cleanup(func() {
+ deps, os.Args, httpPutFile, httpGetFile = oldDeps, oldArgs, oldPut, oldGet
+ })
+ os.Args = []string{"dws", "doc", "--yes"}
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return nil }
+
+ type commandCase struct {
+ name string
+ steps []scriptedToolStep
+ args []string
+ }
+ docJSONML := `["root",{},["p",{},"hello"]]`
+ nodeJSONML := `["p",{},"hello"]`
+ cases := []commandCase{
+ {"create numeric folder", nil, []string{"create", "--name=n", "--folder=123"}},
+ {"create missing content file", nil, []string{"create", "--name=n", "--content-file=missing"}},
+ {"create markdown that resembles jsonml", []scriptedToolStep{{text: `{}`}}, []string{"create", "--name=n", `--content=["p",{},"hello"]`}},
+ {"create invalid jsonml", nil, []string{"create", "--name=n", "--content-format=jsonml", "--content={"}},
+ {"create jsonml call failure", []scriptedToolStep{{err: errors.New("create")}}, []string{"create", "--name=n", "--content-format=jsonml", "--content=" + docJSONML}},
+ {"create jsonml missing node", []scriptedToolStep{{text: `{}`}}, []string{"create", "--name=n", "--content-format=jsonml", "--content=" + docJSONML}},
+ {"create jsonml success", []scriptedToolStep{{text: `{"nodeId":"node"}`}, {text: `{}`}}, []string{"create", "--name=n", "--folder=folder", "--workspace=workspace", "--content-format=jsonml", "--content=" + docJSONML}},
+ {"update missing content file", nil, []string{"update", "--node=node", "--content-file=missing", "--mode=append"}},
+ {"update empty content", nil, []string{"update", "--node=node", "--content="}},
+ {"update missing mode", nil, []string{"update", "--node=node", "--content=body", "--mode="}},
+ {"update overwrite preview", []scriptedToolStep{{text: `{"markdown":"old"}`}}, []string{"update", "--node=node", "--content=new", "--mode=overwrite", "--dry-run"}},
+ {"update overwrite preview failure", []scriptedToolStep{{err: errors.New("read")}}, []string{"update", "--node=node", "--content=new", "--mode=overwrite", "--dry-run"}},
+ {"update overwrite needs yes", nil, []string{"update", "--node=node", "--content=new", "--mode=overwrite"}},
+ {"update markdown resembles jsonml", []scriptedToolStep{{text: `{}`}}, []string{"update", "--node=node", `--content=["p",{},"hello"]`, "--mode=append"}},
+ {"update jsonml append", nil, []string{"update", "--node=node", "--content-format=jsonml", "--content=" + docJSONML, "--mode=append"}},
+ {"update invalid jsonml", nil, []string{"update", "--node=node", "--content-format=jsonml", "--content={", "--mode=overwrite", "--yes"}},
+ {"update jsonml revision", []scriptedToolStep{{text: `{}`}}, []string{"update", "--node=node", "--content-format=jsonml", "--content=" + docJSONML, "--mode=overwrite", "--revision=2", "--yes"}},
+ {"update markdown index", []scriptedToolStep{{text: `{}`}}, []string{"update", "--node=node", "--content=body", "--mode=append", "--index=2"}},
+ {"file create numeric folder", nil, []string{"file", "create", "--name=n", "--type=adoc", "--folder=123"}},
+ {"folder create numeric folder", nil, []string{"folder", "create", "--name=n", "--folder=123"}},
+ {"block insert sniff", []scriptedToolStep{{text: `{}`}}, []string{"block", "insert", "--node=node", `--element=["p",{},"hello"]`}},
+ {"block insert invalid jsonml", nil, []string{"block", "insert", "--node=node", "--content-format=jsonml", "--element={"}},
+ {"block insert jsonml options", []scriptedToolStep{{text: `{}`}}, []string{"block", "insert", "--node=node", "--content-format=jsonml", "--element=" + nodeJSONML, "--ref-block=ref", "--parent-block=parent", "--index=2"}},
+ {"block insert normal options", []scriptedToolStep{{text: `{}`}}, []string{"block", "insert", "--node=node", "--text=body", "--index=2", "--where=before", "--ref-block=ref"}},
+ {"block update sniff", []scriptedToolStep{{text: `{}`}}, []string{"block", "update", "--node=node", "--block-id=block", `--element=["p",{},"hello"]`}},
+ {"block update invalid jsonml", nil, []string{"block", "update", "--node=node", "--block-id=block", "--content-format=jsonml", "--element={"}},
+ {"block update jsonml", []scriptedToolStep{{text: `{}`}}, []string{"block", "update", "--node=node", "--block-id=block", "--content-format=jsonml", "--element=" + nodeJSONML}},
+ {"block update normal", []scriptedToolStep{{text: `{}`}}, []string{"block", "update", "--node=node", "--block-id=block", "--heading=Title", "--level=2"}},
+ {"inline comment missing offsets", nil, []string{"comment", "create-inline", "--node=node", "--block-id=block", "--content=note"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := runDocCoverageCommand(t, &scriptedToolCaller{steps: tc.steps}, tc.args...); err != nil {
+ t.Logf("command returned: %v", err)
+ }
+ })
+ }
+
+ for _, value := range []string{"plain", "# Other\nbody", "# Name", "# **Name** ###\n\nbody"} {
+ _ = stripDuplicateTitle(value, "Name")
+ }
+ for _, value := range []string{"", " Name ### ", "**Bold**", "__Under__ ~~Strike~~ `Code`"} {
+ _ = normalizeHeadingText(value)
+ }
+ for _, name := range []string{"file.pdf", "file.md", "file.unknown"} {
+ _ = inferMimeType(name)
+ }
+ _ = extractMarkdownField("not-json")
+
+ newBlockCommand := func() *cobra.Command {
+ root := newDocCommand()
+ cmd, _, _ := root.Find([]string{"block", "insert"})
+ return cmd
+ }
+ for _, tc := range []struct {
+ flag, value string
+ }{
+ {"element", "{"},
+ {"element", `{"blockType":"paragraph"}`},
+ {"heading", "Heading"},
+ {"text", "Body"},
+ } {
+ cmd := newBlockCommand()
+ _ = cmd.Flags().Set(tc.flag, tc.value)
+ _, _ = buildBlockElement(cmd)
+ }
+ cmd := newBlockCommand()
+ _ = cmd.Flags().Set("heading", "Heading")
+ _ = cmd.Flags().Set("level", "99")
+ _, _ = buildBlockElement(cmd)
+ _, _ = buildBlockElement(newBlockCommand())
+}
+
+func TestDocDestructiveCancellationEdges(t *testing.T) {
+ oldDeps, oldArgs, oldStdin := deps, os.Args, os.Stdin
+ t.Cleanup(func() { deps, os.Args, os.Stdin = oldDeps, oldArgs, oldStdin })
+ os.Args = []string{"dws", "doc"}
+ input, err := os.CreateTemp(t.TempDir(), "answers")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = input.WriteString(strings.Repeat("no\n", 4))
+ _, _ = input.Seek(0, 0)
+ os.Stdin = input
+ for _, args := range [][]string{
+ {"block", "delete", "--node=node", "--block-id=block"},
+ {"delete", "--node=node"},
+ } {
+ _ = runDocCoverageCommand(t, &scriptedToolCaller{}, args...)
+ }
+}
+
+func TestDocExportImportCommandEdges(t *testing.T) {
+ oldDeps, oldArgs, oldPut, oldGet, oldSleep, oldAfter := deps, os.Args, httpPutFile, httpGetFile, helperSleep, helperAfter
+ t.Cleanup(func() {
+ deps, os.Args, httpPutFile, httpGetFile, helperSleep, helperAfter = oldDeps, oldArgs, oldPut, oldGet, oldSleep, oldAfter
+ })
+ os.Args = []string{"dws", "doc", "--yes"}
+ helperSleep = func(time.Duration) {}
+ helperAfter = func(time.Duration) <-chan time.Time {
+ ch := make(chan time.Time, 1)
+ ch <- time.Now()
+ return ch
+ }
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return nil }
+
+ run := func(t *testing.T, steps []scriptedToolStep, dry bool, args ...string) error {
+ t.Helper()
+ return runDocCoverageCommand(t, &scriptedToolCaller{steps: steps, dry: dry}, args...)
+ }
+ outDir := t.TempDir()
+ exportCases := []struct {
+ name string
+ steps []scriptedToolStep
+ dry bool
+ args []string
+ }{
+ {"legacy md", nil, true, []string{"export", "--node=node", "--output=out", "--export-format=", "--format=md"}},
+ {"legacy global ignored", nil, true, []string{"export", "--node=node", "--output=out", "--export-format=", "--format=json"}},
+ {"invalid format", nil, false, []string{"export", "--node=node", "--output=out", "--export-format=zip"}},
+ {"dry run", nil, true, []string{"export", "--node=node", "--output=out", "--export-format=pdf"}},
+ {"submit failure", []scriptedToolStep{{err: errors.New("submit")}}, false, []string{"export", "--node=node", "--output=out"}},
+ {"submit invalid", []scriptedToolStep{{text: `{`}}, false, []string{"export", "--node=node", "--output=out"}},
+ {"submit missing job", []scriptedToolStep{{text: `{}`}}, false, []string{"export", "--node=node", "--output=out"}},
+ {"poll failure", []scriptedToolStep{{text: `{"jobId":"job"}`}, {err: errors.New("poll")}}, false, []string{"export", "--node=node", "--output=out"}},
+ {"download directory without extension", []scriptedToolStep{{text: `{"jobId":"job"}`}, {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/download"}`}}, false, []string{"export", "--node=node", "--output=" + outDir}},
+ {"download directory", []scriptedToolStep{{text: `{"jobId":"job"}`}, {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/name.txt"}`}}, false, []string{"export", "--node=node", "--output=" + outDir, "--export-format=pdf"}},
+ }
+ for _, tc := range exportCases {
+ t.Run("export "+tc.name, func(t *testing.T) { _ = run(t, tc.steps, tc.dry, tc.args...) })
+ }
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return errors.New("download") }
+ _ = run(t, []scriptedToolStep{{text: `{"jobId":"job"}`}, {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/file.docx"}`}}, false, "export", "--node=node", "--output=out")
+ httpGetFile = func(context.Context, string, map[string]string, string) error { return nil }
+
+ for _, tc := range []struct {
+ name string
+ steps []scriptedToolStep
+ dry bool
+ }{
+ {"dry", nil, true},
+ {"error", []scriptedToolStep{{err: errors.New("query")}}, false},
+ {"invalid", []scriptedToolStep{{text: `{`}}, false},
+ {"success", []scriptedToolStep{{text: `{"status":"SUCCESS"}`}}, false},
+ {"processing", []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}, false},
+ {"failed message", []scriptedToolStep{{text: `{"status":"FAILED","message":"bad"}`}}, false},
+ {"failed empty", []scriptedToolStep{{text: `{"status":"FAILED"}`}}, false},
+ } {
+ t.Run("export get "+tc.name, func(t *testing.T) {
+ _ = run(t, tc.steps, tc.dry, "export", "get", "--job-id=job")
+ })
+ }
+
+ valid := filepath.Join(t.TempDir(), "report.md")
+ empty := filepath.Join(t.TempDir(), "empty.md")
+ large := filepath.Join(t.TempDir(), "large.md")
+ unsupported := filepath.Join(t.TempDir(), "file.bin")
+ for path, data := range map[string][]byte{valid: []byte("body"), empty: nil, unsupported: []byte("body")} {
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(large, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Truncate(large, 21*1024*1024); err != nil {
+ t.Fatal(err)
+ }
+ _ = run(t, nil, false, "import", "--file="+t.TempDir())
+ _ = run(t, nil, false, "import", "--file="+large)
+ _ = run(t, nil, false, "import", "--file="+empty)
+ _ = run(t, nil, false, "import", "--file="+unsupported)
+ _ = run(t, nil, true, "import", valid, "--folder=folder", "--workspace=workspace")
+ _ = run(t, []scriptedToolStep{{err: errors.New("session")}}, false, "import", "--file="+valid)
+ _ = run(t, []scriptedToolStep{{text: `{`}}, false, "import", "--file="+valid)
+ _ = run(t, []scriptedToolStep{{text: `{}`}}, false, "import", "--file="+valid)
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return errors.New("put") }
+ _ = run(t, []scriptedToolStep{{text: `{"sessionId":"session","uploadUrl":"https://upload"}`}}, false, "import", "--file="+valid)
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ _ = run(t, []scriptedToolStep{{text: `{"sessionId":"session","uploadUrl":"https://upload"}`}, {err: errors.New("confirm")}}, false, "import", "--file="+valid)
+ _ = run(t, []scriptedToolStep{{text: `{"sessionId":"session","uploadUrl":"https://upload"}`}, {text: `{`}}, false, "import", "--file="+valid)
+ _ = run(t, []scriptedToolStep{{text: `{"sessionId":"session","uploadUrl":"https://upload"}`}, {text: `{}`}}, false, "import", "--file="+valid)
+ _ = run(t, []scriptedToolStep{{text: `{"sessionId":"session","uploadUrl":"https://upload"}`}, {text: `{"taskId":"task"}`}, {err: errors.New("poll")}}, false, "import", "--file="+valid)
+ _ = run(t, []scriptedToolStep{{text: `{"sessionId":"session","uploadUrl":"https://upload"}`}, {text: `{"taskId":"task"}`}, {text: `{"status":"completed","documentUrl":"url","documentName":"name","documentType":"doc"}`}}, false, "import", "--file="+valid)
+
+ for _, tc := range []struct {
+ name string
+ steps []scriptedToolStep
+ dry bool
+ }{
+ {"dry", nil, true},
+ {"error", []scriptedToolStep{{err: errors.New("query")}}, false},
+ {"invalid", []scriptedToolStep{{text: `{`}}, false},
+ {"completed", []scriptedToolStep{{text: `{"status":"completed"}`}}, false},
+ {"processing", []scriptedToolStep{{text: `{"status":"processing"}`}}, false},
+ {"failed message", []scriptedToolStep{{text: `{"status":"failed","message":"bad"}`}}, false},
+ {"failed empty", []scriptedToolStep{{text: `{"status":"failed"}`}}, false},
+ } {
+ t.Run("import get "+tc.name, func(t *testing.T) {
+ _ = run(t, tc.steps, tc.dry, "import", "get", "--task-id=task")
+ })
+ }
+}
+
+func TestDocVersionRevertCommandEdges(t *testing.T) {
+ oldDeps, oldArgs, oldStdin := deps, os.Args, os.Stdin
+ t.Cleanup(func() { deps, os.Args, os.Stdin = oldDeps, oldArgs, oldStdin })
+
+ os.Args = []string{"dws", "doc", "--yes"}
+ _ = runDocCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("list")}}}, "version", "revert", "--node=node", "--version=3")
+ _ = runDocCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"versions":[]}`}}}, "version", "revert", "--node=node", "--version=3")
+ _ = runDocCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"versions":[{"version":3}]}`}, {text: `{}`}}}, "version", "revert", "--node=node", "--version=3")
+
+ os.Args = []string{"dws", "doc"}
+ input, err := os.CreateTemp(t.TempDir(), "answer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = input.WriteString("no\n")
+ _, _ = input.Seek(0, 0)
+ os.Stdin = input
+ _ = runDocCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"versions":[{"version":3}]}`}}}, "version", "revert", "--node=node", "--version=3")
+}
diff --git a/internal/helpers/doc_coverage_more_test.go b/internal/helpers/doc_coverage_more_test.go
new file mode 100644
index 00000000..c7844ac3
--- /dev/null
+++ b/internal/helpers/doc_coverage_more_test.go
@@ -0,0 +1,234 @@
+package helpers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+type docFailingReader struct{}
+
+func (docFailingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") }
+
+func TestDocVersionTraversalCoverage(t *testing.T) {
+ for _, value := range []any{
+ map[string]any{"hasMore": false, "nextCursor": "ignored"},
+ map[string]any{"nextCursor": "next"}, map[string]any{"nextToken": "token"}, map[string]any{"cursor": "cursor"},
+ map[string]any{"result": map[string]any{"nextCursor": "nested"}},
+ map[string]any{"other": []any{map[string]any{"cursor": "array"}}},
+ []any{map[string]any{"nextCursor": "list"}}, "bad",
+ } {
+ _ = docVersionNextCursor(value)
+ }
+ for _, value := range []any{
+ map[string]any{"version": float64(3)}, map[string]any{"version_number": "3"},
+ map[string]any{"nested": []any{map[string]any{"revision": json.Number("3")}}},
+ []any{map[string]any{"docVersion": 3}}, "bad",
+ } {
+ _ = docVersionPayloadContains(value, 3)
+ }
+ for _, value := range []any{float64(3), float64(3.5), "3", "bad", json.Number("3"), json.Number("bad"), 3} {
+ _ = docVersionNumberMatches(value, 3)
+ }
+
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{
+ {text: `{"nextCursor":"next","versions":[]}`},
+ {text: `{"versions":[{"version":3}]}`},
+ }}
+ installScriptedCaller(t, caller)
+ found, err := docVersionExists(context.Background(), "node", 3)
+ if err != nil || !found {
+ t.Fatalf("docVersionExists() = %v, %v", found, err)
+ }
+ caller.steps = []scriptedToolStep{{text: `{`}}
+ caller.index = 0
+ _, _ = docVersionExists(context.Background(), "node", 3)
+ caller.steps = []scriptedToolStep{{err: errors.New("failed")}}
+ caller.index = 0
+ _, _ = docVersionExists(context.Background(), "node", 3)
+}
+
+func TestDocUploadDownloadParsingCoverage(t *testing.T) {
+ for _, raw := range []string{
+ `{`, `{}`,
+ `{"resourceUrl":"https://upload","uploadKey":"key","headers":{"x":"value","bad":1}}`,
+ `{"result":{"resourceUrl":"https://upload","uploadKey":"key"}}`,
+ } {
+ _, _, _, _ = parseUploadInfo(raw)
+ }
+ for _, raw := range []string{
+ `{`, `{}`,
+ `{"resourceUrl":"https://download/file","headers":{"x":"value","bad":1}}`,
+ `{"resourceUrl":["https://download/file"]}`,
+ `{"downloadUrl":"https://download/file"}`,
+ `{"result":{"resourceUrl":"https://download/file"}}`,
+ } {
+ _, _, _ = parseDownloadInfo(raw)
+ }
+ for _, raw := range []string{"", "https://example.test/", "https://example.test/file.txt?x=1", "https://example.test/a%2Fb.txt", "https://example.test/%"} {
+ _ = inferFilename(raw)
+ }
+ for _, raw := range []string{`{`, `{}`, `{"uploadUrl":"https://upload","resourceId":"resource"}`, `{"result":{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}}`} {
+ _, _, _, _ = parseAttachmentUploadInfo(raw)
+ }
+}
+
+func TestResolveDocContentCoverage(t *testing.T) {
+ previous := deps
+ InitDeps(&productExampleCaller{})
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previous })
+ newCommand := func() *cobra.Command {
+ cmd := &cobra.Command{Use: "doc"}
+ cmd.Flags().String("content-file", "", "")
+ cmd.Flags().String("content-path", "", "")
+ cmd.Flags().String("content", "", "")
+ cmd.Flags().String("markdown", "", "")
+ return cmd
+ }
+ file := filepath.Join(t.TempDir(), "content.md")
+ if err := os.WriteFile(file, []byte("file content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ flag, value string
+ stdin io.Reader
+ }{
+ {"content-file", file, nil}, {"content-file", filepath.Join(t.TempDir(), "missing"), nil},
+ {"content", "-", strings.NewReader("stdin content")}, {"content", "-", docFailingReader{}},
+ {"content", `line\nnext`, nil}, {"markdown", "-", strings.NewReader("markdown stdin")},
+ {"markdown", "-", docFailingReader{}}, {"markdown", `line\tnext`, nil}, {"content", "plain", nil},
+ } {
+ cmd := newCommand()
+ _ = cmd.Flags().Set(tc.flag, tc.value)
+ if tc.stdin != nil {
+ cmd.SetIn(tc.stdin)
+ }
+ _, _ = resolveContentFromFlags(cmd)
+ }
+ for _, value := range []string{"", "plain", `line\nnext`, `bad\q`} {
+ _ = unescapeLiteralContent(value)
+ }
+}
+
+func TestRunDocReadJSONMLCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "doc"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ cases := []struct {
+ name string
+ step scriptedToolStep
+ output string
+ wantFail bool
+ }{
+ {"call-error", scriptedToolStep{err: errors.New("failed")}, "", true},
+ {"invalid-response", scriptedToolStep{text: `{`}, "", true},
+ {"missing-jsonml", scriptedToolStep{text: `{}`}, "", true},
+ {"invalid-jsonml", scriptedToolStep{text: `{"jsonml":"{"}`}, "", true},
+ {"revision-float", scriptedToolStep{text: `{"jsonml":"{}","revision":2}`}, "", false},
+ {"revision-string", scriptedToolStep{text: `{"jsonml":"{}","revision":"3"}`}, "", false},
+ {"revision-bad-string", scriptedToolStep{text: `{"jsonml":"{}","revision":"bad"}`}, "", false},
+ {"write", scriptedToolStep{text: `{"jsonml":"{}"}`}, filepath.Join(t.TempDir(), "out.json"), false},
+ {"write-error", scriptedToolStep{text: `{"jsonml":"{}"}`}, t.TempDir(), true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
+ installScriptedCaller(t, caller)
+ err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
+ if tc.wantFail && err == nil {
+ t.Fatal("expected failure")
+ }
+ })
+ }
+}
+
+func TestDocDeprecationWrappersCoverage(t *testing.T) {
+ previous := deps
+ InitDeps(&productExampleCaller{})
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previous })
+ for _, wrap := range []func(*cobra.Command){
+ func(cmd *cobra.Command) { wrapDocDeprecated(cmd, "drive target") },
+ func(cmd *cobra.Command) { wrapDocDeprecatedToWiki(cmd, "wiki target") },
+ func(cmd *cobra.Command) { wrapDocDeprecatedToTarget(cmd, "target") },
+ } {
+ for _, mounted := range []bool{true, false} {
+ cmd := &cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}
+ wrap(cmd)
+ if mounted {
+ root := &cobra.Command{Use: "dws"}
+ doc := &cobra.Command{Use: "doc"}
+ root.AddCommand(doc)
+ doc.AddCommand(cmd)
+ }
+ _ = cmd.RunE(cmd, nil)
+ }
+ }
+}
+
+func TestRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "doc"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ oldPut, oldGet := httpPutFile, httpGetFile
+ t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
+ file := filepath.Join(t.TempDir(), "file.md")
+ if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, dry := range []bool{true, false} {
+ caller := &scriptedToolCaller{dry: dry, steps: []scriptedToolStep{
+ {text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {text: `{"ok":true}`},
+ }}
+ installScriptedCaller(t, caller)
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ root := newDocCommand()
+ cmd, _, _ := root.Find([]string{"upload"})
+ _ = cmd.Flags().Set("file", file)
+ _ = cmd.Flags().Set("workspace", "workspace")
+ _ = cmd.Flags().Set("name", "renamed")
+ _ = cmd.Flags().Set("folder", "folder-node")
+ _ = cmd.Flags().Set("convert", "true")
+ _ = cmd.RunE(cmd, nil)
+ }
+
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"resourceUrl":"https://download/file.txt"}`}}}
+ installScriptedCaller(t, caller)
+ httpGetFile = func(_ context.Context, _ string, _ map[string]string, destination string) error {
+ return os.WriteFile(destination, []byte("download"), 0o600)
+ }
+ root := newDocCommand()
+ download, _, _ := root.Find([]string{"download"})
+ _ = download.Flags().Set("node", "node")
+ _ = download.Flags().Set("output", t.TempDir())
+ _ = download.RunE(download, nil)
+
+ for _, mime := range []string{"image/png", "text/markdown", "application/pdf"} {
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{
+ {text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{"ok":true}`},
+ }}
+ installScriptedCaller(t, caller)
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ root := newDocCommand()
+ media, _, _ := root.Find([]string{"media", "insert"})
+ _ = media.Flags().Set("node", "node")
+ _ = media.Flags().Set("file", file)
+ _ = media.Flags().Set("mime-type", mime)
+ _ = media.Flags().Set("name", "renamed")
+ _ = media.Flags().Set("index", "1")
+ _ = media.Flags().Set("where", "after")
+ _ = media.Flags().Set("ref-block", "block")
+ _ = media.RunE(media, nil)
+ }
+}
diff --git a/internal/helpers/doc_jsonml_pipeline_test.go b/internal/helpers/doc_jsonml_pipeline_test.go
new file mode 100644
index 00000000..694ad62e
--- /dev/null
+++ b/internal/helpers/doc_jsonml_pipeline_test.go
@@ -0,0 +1,138 @@
+package helpers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func jsonMLTestCommand(t *testing.T, fix bool) *cobra.Command {
+ t.Helper()
+ cmd := &cobra.Command{Use: "jsonml-test"}
+ addJsonMLFlags(cmd)
+ addJsonMLFlags(cmd)
+ if err := cmd.Flags().Set("fix-jsonml", map[bool]string{true: "true", false: "false"}[fix]); err != nil {
+ t.Fatalf("set fix-jsonml: %v", err)
+ }
+ return cmd
+}
+
+func TestJSONMLInputSanitizingAndShapeCoercion(t *testing.T) {
+ for _, r := range []rune{0x200B, 0x200C, 0x200D, 0xFEFF, 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2028, 0x2029, 0x2066, 0x2067, 0x2068, 0x2069} {
+ if !isInputDangerousUnicode(r) {
+ t.Errorf("rune %U should be dangerous", r)
+ }
+ }
+ for _, r := range []rune{'a', '\t', '\n', 0x2065, 0x206A} {
+ if isInputDangerousUnicode(r) {
+ t.Errorf("rune %U should be safe", r)
+ }
+ }
+ if got := stripInputUnsafeChars("a\x00\t\n\x7fb\u200bc"); got != "a\t\nbc" {
+ t.Fatalf("stripInputUnsafeChars() = %q", got)
+ }
+
+ bodyCases := []struct {
+ raw string
+ want string
+ }{
+ {"", ""},
+ {"{", "{"},
+ {`{"jsonml":[]}`, `{"jsonml":[]}`},
+ {`"text"`, `"text"`},
+ }
+ for _, tc := range bodyCases {
+ got, notes, err := coerceJsonMLBodyShape(tc.raw)
+ if err != nil || got != tc.want || len(notes) != 0 {
+ t.Errorf("coerceJsonMLBodyShape(%q) = %q, %v, %v", tc.raw, got, notes, err)
+ }
+ }
+ if got, _, err := coerceJsonMLBodyShape(`["root",{}]`); err != nil || got != `{"jsonml":["root",{}]}` {
+ t.Fatalf("bare body coercion = %q, %v", got, err)
+ }
+
+ nodePassthrough := []string{"", "{", `["p",{}]`, `"text"`, `{}`, `{"other":[]}`, `{"jsonml":"bad"}`}
+ for _, raw := range nodePassthrough {
+ got, notes, err := coerceJsonMLNodeShape(raw)
+ if err != nil || got != raw || len(notes) != 0 {
+ t.Errorf("coerceJsonMLNodeShape(%q) = %q, %v, %v", raw, got, notes, err)
+ }
+ }
+ if _, _, err := coerceJsonMLNodeShape(`{"jsonml":[]}`); err == nil {
+ t.Fatal("empty wrapper should fail")
+ }
+ if _, _, err := coerceJsonMLNodeShape(`{"jsonml":[["p",{}],["p",{}]]}`); err == nil {
+ t.Fatal("multi-node wrapper should fail")
+ }
+ got, notes, err := coerceJsonMLNodeShape(`{"jsonml":[["p",{}]]}`)
+ if err != nil || got != `["p",{}]` || len(notes) != 1 {
+ t.Fatalf("single-node wrapper = %q, %v, %v", got, notes, err)
+ }
+ emitFixNotes(nil)
+ emitFixNotes(notes)
+}
+
+func TestPrepareJSONMLBody(t *testing.T) {
+ strict := jsonMLTestCommand(t, false)
+ valid := `{"jsonml":["root",{},["p",{},["span",{},"ok"]]]}`
+ if got, err := prepareJsonMLBody(strict, valid); err != nil || !strings.Contains(got, `"root"`) {
+ t.Fatalf("valid wrapper = %q, %v", got, err)
+ }
+ if got, err := prepareJsonMLBody(strict, `["root",{},["p",{},["span",{},"ok"]]]`); err != nil || !strings.Contains(got, `"root"`) {
+ t.Fatalf("valid bare body = %q, %v", got, err)
+ }
+ for _, raw := range []string{
+ "{",
+ `{}`,
+ `{"jsonml":"bad"}`,
+ `{"jsonml":[]}`,
+ `{"jsonml":["p",{}]}`,
+ } {
+ if _, err := prepareJsonMLBody(strict, raw); err == nil {
+ t.Errorf("prepareJsonMLBody(%q) should fail", raw)
+ }
+ }
+
+ fix := jsonMLTestCommand(t, true)
+ broken := `{"jsonml":["root",{},["p",{},["span",{},"ok"]]]`
+ if got, err := prepareJsonMLBody(fix, broken); err != nil || !strings.Contains(got, `"root"`) {
+ t.Fatalf("repaired body = %q, %v", got, err)
+ }
+ if got, err := prepareJsonMLBody(strict, `{"jsonml":["root",{},["unknown",{}]]}`); err != nil || !strings.Contains(got, "unknown") {
+ t.Fatalf("warning-only body = %q, %v", got, err)
+ }
+}
+
+func TestPrepareJSONMLNode(t *testing.T) {
+ strict := jsonMLTestCommand(t, false)
+ if _, err := prepareJsonMLNode(strict, ""); err == nil {
+ t.Fatal("empty node should fail")
+ }
+ for _, raw := range []string{
+ `["p",{},["span",{},"ok"]]`,
+ `{"jsonml":[["p",{},["span",{},"ok"]]]}`,
+ } {
+ if got, err := prepareJsonMLNode(strict, raw); err != nil || !strings.Contains(got, `"p"`) {
+ t.Errorf("prepareJsonMLNode(%q) = %q, %v", raw, got, err)
+ }
+ }
+ for _, raw := range []string{
+ "{",
+ `"text"`,
+ `{"jsonml":[]}`,
+ `{"jsonml":[["p",{}],["p",{}]]}`,
+ } {
+ if _, err := prepareJsonMLNode(strict, raw); err == nil {
+ t.Errorf("prepareJsonMLNode(%q) should fail", raw)
+ }
+ }
+
+ fix := jsonMLTestCommand(t, true)
+ if got, err := prepareJsonMLNode(fix, `["p",{},["span",{},"ok"]`); err != nil || !strings.Contains(got, `"p"`) {
+ t.Fatalf("repaired node = %q, %v", got, err)
+ }
+ if got, err := prepareJsonMLNode(strict, `["unknown",{}]`); err != nil || !strings.Contains(got, "unknown") {
+ t.Fatalf("warning-only node = %q, %v", got, err)
+ }
+}
diff --git a/internal/helpers/errors_test.go b/internal/helpers/errors_test.go
new file mode 100644
index 00000000..3ac965c7
--- /dev/null
+++ b/internal/helpers/errors_test.go
@@ -0,0 +1,237 @@
+package helpers
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestCLIErrorFormattingExitCodesAndJSON(t *testing.T) {
+ cause := errors.New("root cause")
+ err := &CLIError{Code: CodeInvalidParam, Message: "bad input", Suggestion: "fix it", Operation: "doc/read", Cause: cause}
+ if got := err.Error(); !strings.Contains(got, "doc/read") || !strings.Contains(got, "fix it") {
+ t.Fatalf("Error() = %q", got)
+ }
+ if !errors.Is(err, cause) || err.ExitCode() != ExitValidation {
+ t.Fatalf("Unwrap/ExitCode = %v/%d", err.Unwrap(), err.ExitCode())
+ }
+ encoded := err.ToJSON()["error"].(map[string]any)
+ for _, key := range []string{"code", "message", "exit_code", "operation", "suggestion", "cause"} {
+ if _, ok := encoded[key]; !ok {
+ t.Errorf("ToJSON() missing %q: %#v", key, encoded)
+ }
+ }
+ minimal := (&CLIError{Code: CodeUnclassified, Message: "bad"}).ToJSON()["error"].(map[string]any)
+ if len(minimal) != 3 {
+ t.Fatalf("minimal ToJSON() = %#v", minimal)
+ }
+
+ exitCases := map[string]int{
+ CodeAuthNotConfigured: ExitAuth, CodeAuthTokenExpired: ExitAuth,
+ CodeAuthPermission: ExitPermission, CodeMissingParam: ExitValidation,
+ CodeInvalidParam: ExitValidation, CodeInvalidJSON: ExitValidation,
+ CodeInvalidPath: ExitValidation, CodeInputTooLarge: ExitValidation,
+ CodeFileNotFound: ExitValidation, CodeContentTruncated: ExitAPI,
+ CodeMCPServerError: ExitAPI, CodeMCPToolError: ExitAPI,
+ CodeNetworkTimeout: ExitAPI, CodeNetworkUnreachable: ExitAPI,
+ CodeResourceNotFound: ExitAPI, CodeTableNotFound: ExitAPI,
+ CodeSheetNotFound: ExitAPI, CodeFieldNotFound: ExitAPI,
+ CodeRecordNotFound: ExitAPI, CodeLockTimeout: ExitInternal,
+ CodeUnclassified: ExitInternal, "unknown": ExitInternal,
+ }
+ for code, want := range exitCases {
+ if got := (&CLIError{Code: code}).ExitCode(); got != want {
+ t.Errorf("ExitCode(%s) = %d, want %d", code, got, want)
+ }
+ }
+
+ pat := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
+ if pat.Error() != pat.RawJSON || pat.RawStderr() != pat.RawJSON || pat.ExitCode() != ExitPermission {
+ t.Fatalf("PATError methods changed: %#v", pat)
+ }
+}
+
+func TestWrapErrorClassifiesEveryErrorFamily(t *testing.T) {
+ if WrapError(nil) != nil {
+ t.Fatal("WrapError(nil) != nil")
+ }
+ existing := &CLIError{Code: CodeInvalidParam}
+ if WrapError(existing) != existing {
+ t.Fatal("WrapError changed an existing CLIError")
+ }
+ pat := &PATError{RawJSON: `{}`}
+ if WrapError(pat) != pat {
+ t.Fatal("WrapError changed an existing PATError")
+ }
+
+ tests := []struct {
+ message string
+ op string
+ code string
+ hint string
+ }{
+ {"timeout acquiring data lock", "", CodeLockTimeout, "file lock"},
+ {"lookup: no such host", "", CodeNetworkUnreachable, "DNS"},
+ {"TLS handshake failed", "", CodeNetworkUnreachable, "TLS"},
+ {"connection refused", "", CodeNetworkUnreachable, "connect"},
+ {"context deadline exceeded", "", CodeNetworkTimeout, "timed out"},
+ {"USER_TOKEN_ILLEGAL", "", CodeAuthTokenExpired, "Token"},
+ {"api_key_expired", "", CodeAuthTokenExpired, "API Key"},
+ {"Missing service_id or access_key", "", CodeAuthNotConfigured, "未登录"},
+ {"403 forbidden", "", CodeAuthPermission, "Permission"},
+ {"permission denied for doc", "", CodeAuthPermission, "document owner"},
+ {"permission denied for aitable base", "", CodeAuthPermission, "base list"},
+ {"permission denied for chat group", "", CodeAuthPermission, "group member"},
+ {"server preserved detail", "sheet/batch_update", CodeMCPServerError, "preserved detail"},
+ {"resource not found", "", CodeResourceNotFound, "resource"},
+ {"table not found", "", CodeTableNotFound, "doc search"},
+ {"群不存在", "", CodeResourceNotFound, "disbanded"},
+ {"文档不存在", "", CodeResourceNotFound, "deleted"},
+ {"MCP不存在 PARAM_ERROR", "", CodeMCPServerError, "未注册"},
+ {"invalid character in JSON", "", CodeInvalidJSON, "JSON"},
+ {"add_base_record json: bad", "", CodeInvalidJSON, "--data"},
+ {"字段 fieldId json: bad", "", CodeInvalidJSON, "fieldId"},
+ {"HTTP 503", "", CodeMCPServerError, "internal"},
+ {"操作失败", "", CodeMCPToolError, "verbose"},
+ {"字段类型操作失败", "", CodeMCPToolError, "field get"},
+ {"tool 调用失败", "", CodeMCPToolError, "verbose"},
+ {"搜索内容不能为空", "", CodeMCPToolError, "doc search"},
+ {"totally unknown", "trace/op", CodeUnclassified, "totally unknown"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.message, func(t *testing.T) {
+ wrapped := WrapErrorWithOperation(errors.New(tc.message), tc.op)
+ cli, ok := wrapped.(*CLIError)
+ if !ok || cli.Code != tc.code || !strings.Contains(cli.Error(), tc.hint) {
+ t.Fatalf("WrapErrorWithOperation() = %#v (%v)", wrapped, wrapped)
+ }
+ if tc.op != "" && cli.Operation != tc.op {
+ t.Fatalf("operation = %q, want %q", cli.Operation, tc.op)
+ }
+ })
+ }
+
+ if !IsAuthError(&CLIError{Code: CodeAuthTokenExpired}) || IsAuthError(errors.New("AUTH_TOKEN_EXPIRED")) || IsAuthError(&CLIError{Code: CodeAuthPermission}) {
+ t.Fatal("IsAuthError classification changed")
+ }
+}
+
+func TestBusinessSuggestionsAndResponseClassification(t *testing.T) {
+ suggestions := map[string]string{
+ "搜索内容不能为空": "doc search",
+ "User has no permission to access this email": "mailbox list",
+ "频率超限": "rate limit",
+ "rate limit": "rate limit",
+ "未找到指定工具": "未注册",
+ "MCP不存在": "未注册",
+ "参数错误": "input parameters",
+ "param error": "input parameters",
+ }
+ for input, want := range suggestions {
+ if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
+ t.Errorf("suggestForBusinessErrorText(%q) = %q", input, got)
+ }
+ }
+ if suggestForBusinessErrorText("ok") != "" {
+ t.Fatal("unknown business text produced a suggestion")
+ }
+
+ if ClassifyToolResultContent(map[string]any{"ok": true}) != nil {
+ t.Fatal("successful tool content was classified as an error")
+ }
+ for _, key := range []string{"errorCode", "error_code", "code"} {
+ err := ClassifyToolResultContent(map[string]any{key: "USER_TOKEN_ILLEGAL"})
+ if cli, ok := err.(*CLIError); !ok || cli.Code != CodeAuthTokenExpired {
+ t.Errorf("gateway key %s classified as %#v", key, err)
+ }
+ }
+ for _, key := range []string{"code", "errorCode"} {
+ err := ClassifyToolResultContent(map[string]any{key: "PAT_NO_PERMISSION", "data": map[string]any{"class": "secret", "allowed": true}})
+ if _, ok := err.(*PATError); !ok {
+ t.Errorf("PAT key %s classified as %#v", key, err)
+ }
+ }
+
+ for _, tc := range []struct {
+ text string
+ kind string
+ }{
+ {"not-json", "nil"},
+ {`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
+ {`{"error":"Missing service_id or access_key"}`, "cli"},
+ {`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
+ {`{"success":false,"errorMsg":"rate limit"}`, "cli"},
+ {`{"success":true}`, "nil"},
+ } {
+ err := ClassifyMCPResponseText(tc.text)
+ switch tc.kind {
+ case "nil":
+ if err != nil {
+ t.Errorf("ClassifyMCPResponseText(%s) = %v", tc.text, err)
+ }
+ case "cli":
+ if _, ok := err.(*CLIError); !ok {
+ t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
+ }
+ case "pat":
+ if _, ok := err.(*PATError); !ok {
+ t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
+ }
+ }
+ }
+}
+
+func TestPATCleanupAndMatchingHelpers(t *testing.T) {
+ cleaned := cleanPATJSON(map[string]any{
+ "code": "PAT_NO_PERMISSION",
+ "data": map[string]any{"class": "secret", "items": []any{map[string]any{"class": "hidden", "keep": true}}},
+ }, "PAT_NO_PERMISSION")
+ if strings.Contains(cleaned, "class") || !strings.Contains(cleaned, "keep") {
+ t.Fatalf("cleanPATJSON(data) = %s", cleaned)
+ }
+ cleaned = cleanPATJSON(map[string]any{"code": "x", "extra": map[string]any{"class": "hidden", "value": 1}}, "x")
+ if strings.Contains(cleaned, "class") || !strings.Contains(cleaned, "value") {
+ t.Fatalf("cleanPATJSON(fallback) = %s", cleaned)
+ }
+ cleaned = cleanPATJSON(map[string]any{"code": "x"}, "x")
+ if strings.Contains(cleaned, "data") {
+ t.Fatalf("cleanPATJSON(empty fallback) = %s", cleaned)
+ }
+ cleaned = cleanPATJSON(map[string]any{"data": make(chan int)}, "x")
+ if cleaned != `{"success":false,"code":"x"}` {
+ t.Fatalf("cleanPATJSON(marshal error) = %s", cleaned)
+ }
+ if stripClassFields("value") != "value" {
+ t.Fatal("stripClassFields scalar changed")
+ }
+
+ if !errContainsAny("TLS Handshake", "tls") || errContainsAny("ok", "bad") {
+ t.Fatal("errContainsAny matching changed")
+ }
+ for _, tc := range []struct {
+ input string
+ word string
+ want bool
+ }{
+ {"HTTP 500 error", "500", true}, {"x500y", "500", false},
+ {"before 500", "500", true}, {"500after", "500", false}, {"none", "500", false},
+ {"aaa", "a", false},
+ } {
+ if got := errContainsWord(tc.input, tc.word); got != tc.want {
+ t.Errorf("errContainsWord(%q, %q) = %v", tc.input, tc.word, got)
+ }
+ }
+ for _, value := range []byte{'0', '9', 'a', 'z', 'A', 'Z'} {
+ if !isAlnum(value) {
+ t.Errorf("isAlnum(%q) = false", value)
+ }
+ }
+ if isAlnum('-') {
+ t.Fatal("isAlnum('-') = true")
+ }
+
+ if got := fmt.Sprint(stripClassFields([]any{map[string]any{"class": "x", "v": 1}})); strings.Contains(got, "class") {
+ t.Fatalf("stripClassFields slice = %s", got)
+ }
+}
diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go
index ed983253..c56fdf83 100644
--- a/internal/helpers/helpers.go
+++ b/internal/helpers/helpers.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strings"
+ "time"
"github.com/fatih/color"
"github.com/spf13/cobra"
@@ -30,6 +31,8 @@ var (
validateRequiredFlagWithAliases = cmdutil.ValidateRequiredFlagWithAliases
parseISOTimeToMillis = cmdutil.ParseISOTimeToMillis
validateTimeRange = cmdutil.ValidateTimeRange
+ helperSleep = time.Sleep
+ helperAfter = time.After
)
// Deps holds shared dependencies injected from the host application.
diff --git a/internal/helpers/helpers_core_test.go b/internal/helpers/helpers_core_test.go
new file mode 100644
index 00000000..c3842787
--- /dev/null
+++ b/internal/helpers/helpers_core_test.go
@@ -0,0 +1,293 @@
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type helpersCoreCaller struct {
+ result *edition.ToolResult
+ err error
+ format string
+ dry bool
+ calls int
+}
+
+func (c *helpersCoreCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
+ c.calls++
+ return c.result, c.err
+}
+func (c *helpersCoreCaller) Format() string { return c.format }
+func (c *helpersCoreCaller) DryRun() bool { return c.dry }
+func (*helpersCoreCaller) Fields() string { return "" }
+func (*helpersCoreCaller) JQ() string { return "" }
+
+func textToolResult(text string) *edition.ToolResult {
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
+}
+
+func installHelpersCoreDeps(t *testing.T, caller *helpersCoreCaller) (*bytes.Buffer, *bytes.Buffer) {
+ t.Helper()
+ old := deps
+ t.Cleanup(func() { deps = old })
+ InitDeps(caller)
+ out := &bytes.Buffer{}
+ errOut := &bytes.Buffer{}
+ deps.Out.w = out
+ deps.Out.errW = errOut
+ return out, errOut
+}
+
+func TestSharedDependenciesRoutingAndWrappers(t *testing.T) {
+ oldDeps := deps
+ deps = nil
+ if GetCaller() != nil || GetFormatter() == nil {
+ t.Fatal("nil dependency accessors returned unexpected values")
+ }
+ deps = oldDeps
+
+ caller := &helpersCoreCaller{format: "json", result: textToolResult(`{"ok":true}`)}
+ installHelpersCoreDeps(t, caller)
+ if GetCaller() != caller || GetFormatter() != deps.Out {
+ t.Fatal("initialized dependency accessors returned unexpected values")
+ }
+
+ oldArgs := os.Args
+ t.Cleanup(func() { os.Args = oldArgs })
+ os.Args = []string{"dws", "--format", "json", "doc", "get"}
+ if got := resolveProductID(); got != "doc" {
+ t.Fatalf("resolveProductID() = %q", got)
+ }
+ os.Args = []string{"dws", "unknown"}
+ if got := resolveProductID(); got != "" {
+ t.Fatalf("unknown resolveProductID() = %q", got)
+ }
+ if _, err := callMCPToolReturnText(context.Background(), "tool", nil); err == nil {
+ t.Fatal("unroutable return-text call should fail")
+ }
+ os.Args = []string{"dws", "doc"}
+ if got, err := callMCPToolReturnText(context.Background(), "tool", nil); err != nil || got != `{"ok":true}` {
+ t.Fatalf("callMCPToolReturnText() = %q, %v", got, err)
+ }
+ if err := CallMCPToolOnServer("doc", "tool", nil); err != nil {
+ t.Fatalf("CallMCPToolOnServer(): %v", err)
+ }
+
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("inherited", "fallback", "")
+ child := &cobra.Command{Use: "child"}
+ root.AddCommand(child)
+ if got := MustGetStringFlag(child, "inherited"); got != "fallback" {
+ t.Fatalf("MustGetStringFlag() = %q", got)
+ }
+ _ = GroupRunE(root, nil)
+}
+
+func TestMCPReturnTextClassification(t *testing.T) {
+ caller := &helpersCoreCaller{}
+ installHelpersCoreDeps(t, caller)
+ cases := []struct {
+ name string
+ text string
+ }{
+ {"gateway", `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
+ {"not-logged-in", `{"error":"Missing service_id or access_key"}`},
+ {"pat", `{"code":"PAT_NO_PERMISSION"}`},
+ {"business-bool", `{"success":false,"message":"failed"}`},
+ {"business-string", `{"success":"false","errorMsg":"failed"}`},
+ {"business-error", `{"error":"failed"}`},
+ }
+ for _, tc := range cases {
+ caller.result = textToolResult(tc.text)
+ if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil {
+ t.Errorf("%s response should be classified", tc.name)
+ }
+ }
+ caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "ignored"}, {Type: "text"}}}
+ if got, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err != nil || got != "" {
+ t.Fatalf("empty text result = %q, %v", got, err)
+ }
+ caller.err = errors.New("request failed with PAT_HIGH_RISK_NO_PERMISSION")
+ if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil {
+ t.Fatal("PAT transport error should be classified")
+ }
+ caller.err = errors.New("ordinary transport failure")
+ if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil {
+ t.Fatal("ordinary transport error should be wrapped")
+ }
+}
+
+func TestMCPOutputModesAndDevdocFormatting(t *testing.T) {
+ caller := &helpersCoreCaller{format: "json", result: textToolResult(`{"url":"https://example.test/?a=1&b=2"}`)}
+ out, _ := installHelpersCoreDeps(t, caller)
+ if err := callMCPToolInternalOpts("server", "tool", map[string]any{"x": 1}, true); err != nil {
+ t.Fatalf("unescaped JSON output: %v", err)
+ }
+ if !strings.Contains(out.String(), "&") {
+ t.Fatalf("unescaped output = %q", out.String())
+ }
+
+ out.Reset()
+ caller.format = "raw"
+ caller.result = textToolResult("plain text")
+ if err := callMCPToolInternalOpts("server", "tool", nil, false); err != nil || !strings.Contains(out.String(), "plain text") {
+ t.Fatalf("raw output = %q, %v", out.String(), err)
+ }
+
+ out.Reset()
+ caller.format = "table"
+ caller.result = textToolResult(`{"Result":{"Items":[{"Title":"Match","URL":"https://example.test"}],"currentPage":1,"totalCount":2,"hasMore":true}}`)
+ if err := callMCPToolInternalOpts("server", "search_open_platform_docs", nil, false); err != nil {
+ t.Fatalf("devdoc table output: %v", err)
+ }
+ if !strings.Contains(out.String(), "Match") || strings.Contains(out.String(), "") {
+ t.Fatalf("devdoc table = %q", out.String())
+ }
+ out.Reset()
+ if !formatDevdocSearchTable(`{"Result":{"Items":[]}}`) || !strings.Contains(out.String(), "no matching") {
+ t.Fatalf("empty devdoc table = %q", out.String())
+ }
+ if formatDevdocSearchTable("{") {
+ t.Fatal("invalid devdoc JSON should not format")
+ }
+
+ out.Reset()
+ caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "image"}}}
+ if err := callMCPToolInternalOpts("server", "tool", nil, false); err != nil || out.Len() == 0 {
+ t.Fatalf("non-text result output = %q, %v", out.String(), err)
+ }
+
+ caller.dry = true
+ before := caller.calls
+ if err := callMCPToolInternalOpts("server", "tool", map[string]any{"x": 1}, false); err != nil || caller.calls != before {
+ t.Fatalf("dry run called tool: err=%v calls=%d/%d", err, caller.calls, before)
+ }
+}
+
+func TestCurrentUserResponseShapes(t *testing.T) {
+ caller := &helpersCoreCaller{}
+ installHelpersCoreDeps(t, caller)
+ for _, response := range []string{
+ `{"result":[{"orgEmployeeModel":{"userId":"array-user"}}]}`,
+ `{"result":{"userId":"object-user"}}`,
+ } {
+ caller.result = textToolResult(response)
+ if got, err := getCurrentUserID(context.Background()); err != nil || got == "" {
+ t.Errorf("getCurrentUserID(%s) = %q, %v", response, got, err)
+ }
+ }
+ caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image"}, {Type: "text", Text: "{"}}}
+ if _, err := getCurrentUserID(context.Background()); err == nil {
+ t.Fatal("unparseable current user should fail")
+ }
+ caller.err = errors.New("offline")
+ if _, err := getCurrentUserID(context.Background()); err == nil {
+ t.Fatal("current-user transport failure should fail")
+ }
+}
+
+func TestCoreClassificationSuggestionsAndConfirmation(t *testing.T) {
+ if classifyPATError(map[string]any{"errorCode": "PAT_LOW_RISK_NO_PERMISSION"}) == nil ||
+ classifyPATError(map[string]any{"code": "other"}) != nil {
+ t.Fatal("PAT classification mismatch")
+ }
+ pat := &PATError{RawJSON: "{}"}
+ if reclassifyPATFromError(pat) != pat || reclassifyPATFromError(errors.New("plain")) != nil {
+ t.Fatal("PAT reclassification mismatch")
+ }
+ if !strings.Contains(buildMinimalPATJSON("PAT_NO_PERMISSION"), "PAT_NO_PERMISSION") {
+ t.Fatal("minimal PAT JSON omitted code")
+ }
+ if isBusinessError(map[string]any{"success": true}) || isBusinessError(map[string]any{}) {
+ t.Fatal("successful response classified as business error")
+ }
+ for _, body := range []map[string]any{{"errorMsg": "one"}, {"message": "two"}, {"error": "three"}, {}} {
+ _ = suggestForBusinessError(body)
+ }
+
+ previousEdition := edition.Get()
+ t.Cleanup(func() { edition.Override(previousEdition) })
+ edition.Override(&edition.Hooks{IsEmbedded: true})
+ if notLoggedInSuggestion() != "请先登录" || !strings.Contains(authExpiredSuggestion(), "re-run") {
+ t.Fatal("embedded auth suggestions changed")
+ }
+ edition.Override(&edition.Hooks{})
+ if !strings.Contains(notLoggedInSuggestion(), "auth login") || !strings.Contains(authExpiredSuggestion(), "auth login") {
+ t.Fatal("standalone auth suggestions changed")
+ }
+
+ caller := &helpersCoreCaller{}
+ installHelpersCoreDeps(t, caller)
+ oldArgs, oldStdin := os.Args, os.Stdin
+ t.Cleanup(func() { os.Args, os.Stdin = oldArgs, oldStdin })
+ os.Args = []string{"dws", "--yes"}
+ if !confirmDelete("doc", "id") {
+ t.Fatal("--yes should confirm")
+ }
+ for _, tc := range []struct {
+ answer string
+ want bool
+ }{{"yes\n", true}, {"Y\n", true}, {"no\n", false}} {
+ path := filepath.Join(t.TempDir(), "answer")
+ if err := os.WriteFile(path, []byte(tc.answer), 0o600); err != nil {
+ t.Fatalf("write confirmation: %v", err)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ t.Fatalf("open confirmation: %v", err)
+ }
+ os.Stdin = file
+ os.Args = []string{"dws"}
+ if got := confirmDelete("doc", "id"); got != tc.want {
+ t.Errorf("confirmDelete(%q) = %v", tc.answer, got)
+ }
+ _ = file.Close()
+ }
+}
+
+func TestCamelCaseAliasesAndFlagCopying(t *testing.T) {
+ for input, want := range map[string]string{"base-id": "baseId", "plain": "plain", "a--b": "aB"} {
+ if got := toCamelCase(input); got != want {
+ t.Errorf("toCamelCase(%q) = %q, want %q", input, got, want)
+ }
+ }
+ root := &cobra.Command{Use: "root"}
+ root.Flags().Int("int-value", 0, "")
+ root.Flags().Int64("long-value", 0, "")
+ root.Flags().Float64("float-value", 0, "")
+ root.Flags().Bool("bool-value", false, "")
+ root.Flags().StringSlice("slice-value", nil, "")
+ root.Flags().String("text-value", "", "")
+ root.Flags().String("textValue", "existing", "")
+ child := &cobra.Command{Use: "child"}
+ child.Flags().String("child-value", "", "")
+ root.AddCommand(child)
+ RegisterCamelCaseAliases(root)
+ for _, name := range []string{"intValue", "longValue", "floatValue", "boolValue", "sliceValue"} {
+ flag := root.Flags().Lookup(name)
+ if flag == nil || !flag.Hidden {
+ t.Errorf("camel alias --%s missing or visible", name)
+ }
+ }
+ if child.Flags().Lookup("childValue") == nil {
+ t.Fatal("child camel alias missing")
+ }
+ if flag := root.Flags().Lookup("textValue"); flag == nil || flag.Hidden || flag.DefValue != "existing" {
+ t.Fatal("existing camel-case flag should be preserved")
+ }
+
+ src, dst := &cobra.Command{Use: "src"}, &cobra.Command{Use: "dst"}
+ src.Flags().String("copied", "value", "")
+ copyFlags(src, dst, "missing", "copied")
+ if flag := dst.Flags().Lookup("copied"); flag == nil || flag.DefValue != "value" {
+ t.Fatal("copyFlags() did not copy the requested flag")
+ }
+}
diff --git a/internal/helpers/http_coverage_more_test.go b/internal/helpers/http_coverage_more_test.go
new file mode 100644
index 00000000..e9a1a7ac
--- /dev/null
+++ b/internal/helpers/http_coverage_more_test.go
@@ -0,0 +1,208 @@
+package helpers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
+
+func TestDefaultFileHTTPTransfersCoverage(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "file.txt")
+ if err := os.WriteFile(file, []byte("payload"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ status := http.StatusOK
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if status == http.StatusOK && r.Header.Get("X-Test") != "yes" && r.Method == http.MethodPut {
+ t.Error("upload header missing")
+ }
+ w.WriteHeader(status)
+ _, _ = io.WriteString(w, "response")
+ }))
+ defer server.Close()
+
+ if err := defaultHTTPPutFile(context.Background(), server.URL, map[string]string{"X-Test": "yes"}, file, 7); err != nil {
+ t.Fatal(err)
+ }
+ status = http.StatusBadRequest
+ if err := defaultHTTPPutFile(context.Background(), server.URL, nil, file, 7); err == nil {
+ t.Fatal("failed upload succeeded")
+ }
+ if err := defaultHTTPPutFile(context.Background(), server.URL, nil, filepath.Join(t.TempDir(), "missing"), 0); err == nil {
+ t.Fatal("missing upload file succeeded")
+ }
+ if err := defaultHTTPPutFile(context.Background(), ":", nil, file, 7); err == nil {
+ t.Fatal("invalid upload URL succeeded")
+ }
+
+ status = http.StatusOK
+ destination := filepath.Join(t.TempDir(), "download.txt")
+ if err := defaultHTTPGetFile(context.Background(), server.URL, map[string]string{"X-Test": "yes"}, destination); err != nil {
+ t.Fatal(err)
+ }
+ status = http.StatusNotFound
+ if err := defaultHTTPGetFile(context.Background(), server.URL, nil, destination); err == nil {
+ t.Fatal("failed download succeeded")
+ }
+ if err := defaultHTTPGetFile(context.Background(), ":", nil, destination); err == nil {
+ t.Fatal("invalid download URL succeeded")
+ }
+ status = http.StatusOK
+ if err := defaultHTTPGetFile(context.Background(), server.URL, nil, filepath.Join(t.TempDir(), "missing", "file")); err == nil {
+ t.Fatal("uncreatable download path succeeded")
+ }
+
+ SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
+ SetHTTPPutFile(nil)
+ SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { return nil })
+ SetHTTPGetFile(nil)
+}
+
+func TestMailHTTPTransfersCoverage(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "attachment.bin")
+ if err := os.WriteFile(file, []byte("mail"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ status := http.StatusOK
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(status)
+ _, _ = io.WriteString(w, "mail-body")
+ }))
+ defer server.Close()
+
+ if err := httpPutMailAttachment(context.Background(), "PERSONAL", server.URL, file, 4); err != nil {
+ t.Fatal(err)
+ }
+ status = http.StatusBadRequest
+ if err := httpPutMailAttachment(context.Background(), "ENTERPRISE", server.URL, file, 4); err == nil {
+ t.Fatal("failed mail upload succeeded")
+ }
+ if err := httpPutMailAttachment(context.Background(), "PERSONAL", server.URL, "missing", 0); err == nil {
+ t.Fatal("missing mail upload file succeeded")
+ }
+ if err := httpPutMailAttachment(context.Background(), "PERSONAL", ":", file, 4); err == nil {
+ t.Fatal("invalid mail upload URL succeeded")
+ }
+
+ status = http.StatusOK
+ destination := filepath.Join(t.TempDir(), "mail.bin")
+ if err := httpGetMailAttachment(context.Background(), "PERSONAL", server.URL, destination); err != nil {
+ t.Fatal(err)
+ }
+ status = http.StatusBadRequest
+ if err := httpGetMailAttachment(context.Background(), "ENTERPRISE", server.URL, destination); err == nil {
+ t.Fatal("failed mail download succeeded")
+ }
+ if err := httpGetMailAttachment(context.Background(), "PERSONAL", ":", destination); err == nil {
+ t.Fatal("invalid mail download URL succeeded")
+ }
+ status = http.StatusOK
+ if err := httpGetMailAttachment(context.Background(), "PERSONAL", server.URL, filepath.Join(t.TempDir(), "missing", "file")); err == nil {
+ t.Fatal("uncreatable mail path succeeded")
+ }
+}
+
+func TestChatMediaHTTPAndDocVersionsCoverage(t *testing.T) {
+ previousTransport := http.DefaultTransport
+ mode := "success"
+ http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if mode == "transport-error" {
+ return nil, errors.New("transport")
+ }
+ if req.Body != nil {
+ if _, err := io.ReadAll(req.Body); err != nil {
+ return nil, err
+ }
+ }
+ status := http.StatusOK
+ body := `{"access_token":"token","errcode":0}`
+ if strings.Contains(req.URL.Path, "media/upload") {
+ body = `{"media_id":"media-id","errcode":0}`
+ }
+ switch mode {
+ case "http-error":
+ status = http.StatusInternalServerError
+ body = `failure`
+ case "invalid-json":
+ body = `{`
+ case "api-error":
+ body = `{"errcode":1,"errmsg":"failed"}`
+ }
+ return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
+ })
+ t.Cleanup(func() { http.DefaultTransport = previousTransport })
+
+ t.Setenv("DWS_CLIENT_ID", "")
+ t.Setenv("DWS_CLIENT_SECRET", "")
+ if _, err := mediaResolveAppToken(context.Background()); err == nil {
+ t.Fatal("missing media credentials succeeded")
+ }
+ t.Setenv("DWS_CLIENT_ID", "client")
+ t.Setenv("DWS_CLIENT_SECRET", "secret")
+ for _, current := range []string{"transport-error", "http-error", "invalid-json", "api-error", "success"} {
+ mode = current
+ _, _ = mediaResolveAppToken(context.Background())
+ }
+ file := filepath.Join(t.TempDir(), "image.png")
+ if err := os.WriteFile(file, []byte("image"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, current := range []string{"transport-error", "http-error", "invalid-json", "api-error", "success"} {
+ mode = current
+ _, _ = mediaUploadFile(context.Background(), "token", file, "image")
+ }
+ mode = "success"
+ _, _ = mediaUploadFile(context.Background(), "token", filepath.Join(t.TempDir(), "missing"), "image")
+
+ for _, value := range []any{float64(3), float64(3.5), "3", "bad", jsonNumber("3"), jsonNumber("bad"), true} {
+ _ = docVersionNumberMatches(value, 3)
+ }
+ for _, payload := range []any{
+ map[string]any{"hasMore": false, "nextCursor": "ignored"},
+ map[string]any{"nextToken": "next"},
+ map[string]any{"result": map[string]any{"cursor": "cursor"}},
+ []any{map[string]any{"nextCursor": "array"}},
+ "bad",
+ } {
+ _ = docVersionNextCursor(payload)
+ _ = docVersionPayloadContains(payload, 3)
+ }
+}
+
+func jsonNumber(value string) json.Number { return json.Number(value) }
+
+func TestRunDocReadJsonMLCoverage(t *testing.T) {
+ previous := deps
+ t.Cleanup(func() { deps = previous })
+ caller := &helpersCoreCaller{format: "json"}
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ for _, text := range []string{
+ `{`,
+ `{}`,
+ `{"jsonml":"{\"type\":\"doc\"}","revision":2}`,
+ `{"jsonml":"{\"type\":\"doc\"}","revision":"3"}`,
+ `{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`,
+ } {
+ caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
+ _ = runDocReadJsonML(nil, "node", "")
+ }
+ caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}}
+ _ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json"))
+ _ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json"))
+}
diff --git a/internal/helpers/mail_coverage_more_test.go b/internal/helpers/mail_coverage_more_test.go
new file mode 100644
index 00000000..1bc51b20
--- /dev/null
+++ b/internal/helpers/mail_coverage_more_test.go
@@ -0,0 +1,129 @@
+package helpers
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestMailDraftAttachmentValidationCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "mail"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ empty := filepath.Join(t.TempDir(), "empty.txt")
+ if err := os.WriteFile(empty, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ text := filepath.Join(t.TempDir(), "file.txt")
+ if err := os.WriteFile(text, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ badInline := filepath.Join(t.TempDir(), "inline.txt")
+ if err := os.WriteFile(badInline, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ attachments, inline []string
+ }{
+ {attachments: []string{filepath.Join(t.TempDir(), "missing")}},
+ {attachments: []string{t.TempDir()}},
+ {attachments: []string{empty}},
+ {inline: []string{filepath.Join(t.TempDir(), "missing")}},
+ {inline: []string{t.TempDir()}},
+ {inline: []string{empty}},
+ {inline: []string{badInline}},
+ } {
+ _, _ = runMailDraftWithAttachment("create_draft", map[string]any{"from": "user@example.com"}, "", "body", tc.attachments, tc.inline)
+ }
+}
+
+func TestMailDraftAttachmentWorkflowCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "mail"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ serverStatus := http.StatusOK
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.Copy(io.Discard, r.Body)
+ w.WriteHeader(serverStatus)
+ _, _ = w.Write([]byte("ok"))
+ }))
+ defer server.Close()
+ attachment := filepath.Join(t.TempDir(), "file.txt")
+ inline := filepath.Join(t.TempDir(), "image.png")
+ if err := os.WriteFile(attachment, []byte("attachment"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(inline, []byte("image"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ mailboxes := `{"emailAccounts":[{"email":"user@example.com","type":"PERSONAL"}]}`
+ draft := `{"result":{"message":{"id":"message"}}}`
+ upload := `{"uploadUrl":"` + server.URL + `"}`
+ caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: mailboxes}, {text: draft}, {text: upload}, {text: upload}}}
+ installScriptedCaller(t, caller)
+ if id, err := runMailDraftWithAttachment("create_draft", map[string]any{"from": "user@example.com"}, "", "body [inline:image.png]", []string{attachment}, []string{inline}); err != nil || id != "message" {
+ t.Fatalf("draft workflow = %q, %v", id, err)
+ }
+
+ caller.steps = []scriptedToolStep{{text: mailboxes}, {text: `{}`}}
+ caller.index = 0
+ _, _ = runMailDraftWithAttachment("ignored", map[string]any{"from": "user@example.com"}, "message", "body", nil, nil)
+
+ cases := [][]scriptedToolStep{
+ {{err: errors.New("mailboxes")}},
+ {{text: mailboxes}, {err: errors.New("draft")}},
+ {{text: mailboxes}, {text: `{}`}},
+ {{text: mailboxes}, {text: draft}, {err: errors.New("session")}},
+ {{text: mailboxes}, {text: draft}, {text: `{}`}},
+ }
+ for _, steps := range cases {
+ caller.steps = steps
+ caller.index = 0
+ _, _ = runMailDraftWithAttachment("create_draft", map[string]any{"from": "user@example.com"}, "", "body", []string{attachment}, nil)
+ }
+ serverStatus = http.StatusInternalServerError
+ caller.steps = []scriptedToolStep{{text: mailboxes}, {text: draft}, {text: upload}}
+ caller.index = 0
+ _, _ = runMailDraftWithAttachment("create_draft", map[string]any{"from": "user@example.com"}, "", "body", []string{attachment}, nil)
+}
+
+func TestMailParsingAndValidationCoverage(t *testing.T) {
+ for _, raw := range []string{`{`, `{}`, `{"messageId":"id"}`, `{"result":{"message":{"id":"nested"}}}`} {
+ _, _ = parseMailDraftId(raw)
+ }
+ for _, raw := range []string{`{`, `{}`, `{"uploadUrl":"url"}`, `{"result":{"uploadUrl":"nested"}}`} {
+ _, _ = parseMailUploadSession(raw)
+ }
+ for _, raw := range []string{`{`, `{}`, `{"emailAccounts":["bad",{"email":"other"},{"email":"USER@example.com","type":"PERSONAL"}]}`, `{"result":{"emailAccounts":[]}}`} {
+ _ = parseMailAccountType(raw, "user@example.com")
+ }
+ _ = mailUploadBaseURL("PERSONAL")
+ _ = mailUploadBaseURL("ENTERPRISE")
+ _ = parseRecipients(" one@example.com, ,two@example.com ")
+ for _, raw := range []string{`{`, `{}`, `{"downloadUrl":"url"}`, `{"result":{"downloadUrl":"nested"}}`} {
+ _, _ = parseMailDownloadSession(raw)
+ }
+
+ for _, limit := range []string{"0", "1", "101"} {
+ cmd := &cobra.Command{Use: "thread"}
+ cmd.Flags().Int("limit", 0, "")
+ _ = cmd.Flags().Set("limit", limit)
+ _, _ = validateMailboxThreadLimit(cmd)
+ }
+ for _, tc := range []struct{ action, tags string }{
+ {"invalid", ""}, {"markRead", ""}, {"markUnread", ""},
+ {"addTags", ""}, {"addTags", "tag"}, {"removeTags", "tag"},
+ } {
+ cmd := &cobra.Command{Use: "thread"}
+ cmd.Flags().String("tag-ids", "", "")
+ cmd.Flags().String("tags", "", "")
+ _ = cmd.Flags().Set("tag-ids", tc.tags)
+ _ = validateMailboxThreadAction(cmd, tc.action)
+ }
+}
diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go
new file mode 100644
index 00000000..8f1df99e
--- /dev/null
+++ b/internal/helpers/polling_coverage_more_test.go
@@ -0,0 +1,258 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type scriptedToolStep struct {
+ text string
+ err error
+}
+
+type scriptedToolCaller struct {
+ steps []scriptedToolStep
+ index int
+ format string
+ dry bool
+}
+
+func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
+ if len(c.steps) == 0 {
+ return &edition.ToolResult{}, nil
+ }
+ index := c.index
+ if index >= len(c.steps) {
+ index = len(c.steps) - 1
+ }
+ c.index++
+ step := c.steps[index]
+ if step.err != nil {
+ return nil, step.err
+ }
+ return textToolResult(step.text), nil
+}
+
+func (c *scriptedToolCaller) Format() string { return c.format }
+func (c *scriptedToolCaller) DryRun() bool { return c.dry }
+func (*scriptedToolCaller) Fields() string { return "" }
+func (*scriptedToolCaller) JQ() string { return "" }
+
+func installScriptedCaller(t *testing.T, caller *scriptedToolCaller) {
+ t.Helper()
+ previous := deps
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ t.Cleanup(func() { deps = previous })
+}
+
+func installImmediateTiming(t *testing.T) {
+ t.Helper()
+ previousSleep := helperSleep
+ previousAfter := helperAfter
+ helperSleep = func(time.Duration) {}
+ helperAfter = func(time.Duration) <-chan time.Time {
+ ch := make(chan time.Time, 1)
+ ch <- time.Now()
+ return ch
+ }
+ t.Cleanup(func() {
+ helperSleep = previousSleep
+ helperAfter = previousAfter
+ })
+}
+
+func TestDocPollingCoverage(t *testing.T) {
+ installImmediateTiming(t)
+ oldArgs := os.Args
+ os.Args = []string{"dws", "doc"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ cases := [][]scriptedToolStep{
+ {{err: errors.New("query failed")}},
+ {{text: `{`}},
+ {{text: `{"status":"SUCCESS"}`}},
+ {{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/file"}`}},
+ {{text: `{"status":"FAILED","message":"failed"}`}},
+ {{text: `{"status":"FAILED"}`}},
+ {{text: `{"status":"PROCESSING"}`}},
+ }
+ for _, steps := range cases {
+ caller := &scriptedToolCaller{steps: steps}
+ installScriptedCaller(t, caller)
+ _, _ = pollDocExportJob(context.Background(), "job")
+ }
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _ = pollDocExportJob(cancelled, "job")
+
+ importCases := [][]scriptedToolStep{
+ {{err: errors.New("query failed")}},
+ {{text: `{`}},
+ {{text: `{"status":"completed","nodeId":"node"}`}},
+ {{text: `{"status":"failed","message":"failed"}`}},
+ {{text: `{"status":"failed"}`}},
+ {{text: `{"status":"processing"}`}},
+ {{text: `{"status":"unknown"}`}},
+ }
+ for _, steps := range importCases {
+ caller := &scriptedToolCaller{steps: steps}
+ installScriptedCaller(t, caller)
+ _, _ = pollDocImportTask(context.Background(), "task")
+ }
+ _, _ = pollDocImportTask(cancelled, "task")
+}
+
+func TestSheetExportPollingCoverage(t *testing.T) {
+ installImmediateTiming(t)
+ cases := []struct {
+ format string
+ steps []scriptedToolStep
+ }{
+ {"table", []scriptedToolStep{{err: errors.New("temporary")}, {text: `{"status":"SUCCESS","downloadUrl":"https://example.test/file.xlsx"}`}}},
+ {"json", []scriptedToolStep{{text: `{`}}},
+ {"table", []scriptedToolStep{{text: `{"status":"SUCCESS"}`}}},
+ {"table", []scriptedToolStep{{text: `{"status":"FAILED","message":"failed"}`}}},
+ {"table", []scriptedToolStep{{text: `{"status":"FAIL"}`}}},
+ {"table", []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}},
+ {"table", []scriptedToolStep{{text: `{"status":"MYSTERY"}`}}},
+ }
+ for _, tc := range cases {
+ caller := &scriptedToolCaller{steps: tc.steps, format: tc.format}
+ installScriptedCaller(t, caller)
+ _, _ = pollSheetExportJob(context.Background(), "job")
+ }
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _ = pollSheetExportJob(cancelled, "job")
+
+ for _, raw := range []string{`{`, `{}`, `{"success":false}`, `{"success":false,"message":"failed"}`, `{"result":{"jobId":"job"}}`} {
+ _, _ = parseExportSubmitResult(raw)
+ }
+ for _, raw := range []string{`{`, `{}`, `{"result":{"status":"SUCCESS","downloadUrl":"url","message":"ok"}}`} {
+ _, _, _, _ = parseExportQueryResult(raw)
+ }
+ for _, raw := range []string{"", "https://example.test/", "https://example.test/a%2Fb.xlsx?x=1", "https://example.test/%"} {
+ _ = inferSheetExportFilename(raw)
+ }
+}
+
+func TestChunkedWriteCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "doc"}
+ t.Cleanup(func() { os.Args = oldArgs })
+
+ cases := []struct {
+ name string
+ tool string
+ args map[string]any
+ markdown string
+ chunkSize int
+ steps []scriptedToolStep
+ cancel bool
+ }{
+ {"create", "create_document", map[string]any{"title": "x"}, "abcdef", 3, []scriptedToolStep{{text: `{"nodeId":"node"}`}, {text: `{}`}}, false},
+ {"create-error", "create_document", nil, "abcdef", 3, []scriptedToolStep{{err: errors.New("create failed")}}, false},
+ {"create-missing-id", "create_document", nil, "abcdef", 3, []scriptedToolStep{{text: `{}`}}, false},
+ {"update", "update_document", map[string]any{"nodeId": "node", "mode": "overwrite"}, "abcdef", 3, []scriptedToolStep{{text: `{}`}, {text: `{}`}}, false},
+ {"update-first-error", "update_document", map[string]any{"nodeId": "node"}, "abcdef", 3, []scriptedToolStep{{err: errors.New("update failed")}}, false},
+ {"update-later-error", "update_document", map[string]any{"nodeId": "node"}, "abcdef", 3, []scriptedToolStep{{text: `{}`}, {err: errors.New("update failed")}}, false},
+ {"update-timeout-floor", "update_document", map[string]any{"nodeId": "node"}, strings.Repeat("x", 12000), 6000, []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}}, false},
+ {"cancelled", "update_document", map[string]any{"nodeId": "node"}, "abcdef", 3, []scriptedToolStep{{text: `{}`}}, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &scriptedToolCaller{steps: tc.steps}
+ installScriptedCaller(t, caller)
+ ctx := context.Background()
+ if tc.cancel {
+ cancelled, cancel := context.WithCancel(ctx)
+ cancel()
+ ctx = cancelled
+ }
+ _, _, _, _ = chunkedWrite(ctx, tc.tool, tc.args, tc.markdown, "test", tc.chunkSize)
+ })
+ }
+}
+
+func TestTodoListAutoPageCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "todo"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ root := newTodoCommand()
+ cmd, _, err := root.Find([]string{"task", "list"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, tc := range []struct {
+ name string
+ caller *scriptedToolCaller
+ page string
+ size int
+ role string
+ }{
+ {name: "dry", caller: &scriptedToolCaller{dry: true}, page: "1", size: 45},
+ {name: "json", caller: &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"result":{"todoCards":[{"id":1}]}}`}}}, page: "bad", size: 45},
+ {name: "raw", caller: &scriptedToolCaller{format: "table", steps: []scriptedToolStep{{text: `{"result":{"todoCards":[{"id":1}]}}`}}}, page: "0", size: 45},
+ {name: "empty", caller: &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"todoCards":[]}}`}}}, page: "1", size: 45},
+ {name: "invalid-json", caller: &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}}, page: "1", size: 45},
+ {name: "call-error", caller: &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("failed")}}}, page: "1", size: 45},
+ {name: "invalid-role", caller: &scriptedToolCaller{}, page: "1", size: 45, role: "invalid"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ installScriptedCaller(t, tc.caller)
+ _ = cmd.Flags().Set("role-types", tc.role)
+ _ = todoListAutoPage(cmd, tc.page, tc.size)
+ _ = cmd.Flags().Set("role-types", "")
+ })
+ }
+}
+
+func TestAitableRetryAndPaginationCoverage(t *testing.T) {
+ installImmediateTiming(t)
+ oldArgs := os.Args
+ os.Args = []string{"dws", "aitable"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ for _, steps := range [][]scriptedToolStep{
+ {{err: errors.New("validation failed")}},
+ {{err: errors.New("HTTP 500 temporary")}, {text: `{"success":true}`}},
+ {{err: errors.New("HTTP 503 retryable")}},
+ } {
+ caller := &scriptedToolCaller{steps: steps, format: "json"}
+ installScriptedCaller(t, caller)
+ _ = callAitableTool("tool", nil)
+ caller.index = 0
+ _ = callAitableHelperTool("tool", nil)
+ }
+
+ for _, steps := range [][]scriptedToolStep{
+ {{err: errors.New("first page failed")}},
+ {{text: ""}},
+ {{text: `{`}},
+ {{text: `{"data":{"records":[{"id":"one"}],"nextCursor":"next"}}`}, {err: errors.New("second page failed")}},
+ {{text: `{"records":[{"id":"one"}],"nextCursor":"next"}`}},
+ {{text: `{"data":{"records":[{"id":"one"}],"cursor":"next"}}`}},
+ } {
+ caller := &scriptedToolCaller{steps: steps, format: "json"}
+ installScriptedCaller(t, caller)
+ _ = recordQueryFetchAll(map[string]any{}, 1)
+ }
+ for _, steps := range [][]scriptedToolStep{
+ {{text: `{"data":{"records":[{"id":"one"}],"nextCursor":"next"}}`}, {err: errors.New("second page failed")}},
+ {{text: `{"data":{"records":[{"id":"one"}],"cursor":"next"}}`}, {text: ""}},
+ {{text: `{"records":[{"id":"one"}],"nextCursor":"next"}`}, {text: `{`}},
+ {{text: `{"records":[{"id":"one"}],"cursor":"next"}`}, {text: `{"records":[]}`}},
+ } {
+ caller := &scriptedToolCaller{steps: steps, format: "json"}
+ installScriptedCaller(t, caller)
+ _ = recordQueryFetchAll(map[string]any{}, 0)
+ }
+}
diff --git a/internal/helpers/product_examples_test.go b/internal/helpers/product_examples_test.go
new file mode 100644
index 00000000..bc93ac8b
--- /dev/null
+++ b/internal/helpers/product_examples_test.go
@@ -0,0 +1,424 @@
+package helpers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "testing"
+ "time"
+ "unicode"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+type productExampleCaller struct {
+ calls int
+ mode string
+ dry bool
+ format string
+}
+
+func (c *productExampleCaller) CallTool(_ context.Context, _, _ string, _ map[string]any) (*edition.ToolResult, error) {
+ c.calls++
+ switch c.mode {
+ case "transport-error":
+ return nil, fmt.Errorf("synthetic tool transport failure")
+ case "invalid-json":
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{"}}}, nil
+ case "empty-content":
+ return &edition.ToolResult{}, nil
+ case "non-text":
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image"}}}, nil
+ }
+ record := map[string]any{
+ "id": "item-id", "openId": "open-id", "unionId": "union-id", "name": "Example",
+ "title": "Example", "text": "content", "content": "content", "status": "SUCCESS",
+ "type": "text", "url": "https://example.invalid/item", "downloadUrl": "https://example.invalid/download",
+ "createdAt": "2026-01-02T03:04:05Z", "updatedAt": "2026-01-02T03:04:05Z",
+ "startTime": "2026-01-02T03:04:05Z", "endTime": "2026-01-02T04:04:05Z",
+ "fileName": "fixture.txt", "dentryId": float64(1), "spaceId": float64(2),
+ "messageId": "message-id", "docId": "doc-id", "workspaceId": "workspace-id",
+ "sheetId": "sheet-id", "tableId": "table-id", "baseId": "base-id",
+ "recordId": "record-id", "taskId": "task-id", "jobId": "job-id",
+ "eventId": "event-id", "calendarId": "calendar-id", "roomId": "room-id",
+ "conversationId": "conversation-id", "openConversationId": "cid-item-id",
+ "processInstanceId": "process-id", "reportId": "report-id", "templateId": "template-id",
+ "todoId": "todo-id", "mediaId": "media-id", "attachmentId": "attachment-id",
+ "userId": "user-id", "email": "user@example.com", "code": float64(0), "errcode": float64(0),
+ }
+ dataMap := cloneStringAnyMap(record)
+ for key, value := range map[string]any{
+ "success": true, "status": "SUCCESS", "taskStatus": "SUCCESS",
+ "hasMore": false, "nextCursor": "", "cursor": "",
+ "items": []any{record}, "records": []any{record}, "data": []any{record}, "list": []any{record},
+ "result": record, "downloadUrl": "https://example.invalid/download", "resourceUrl": "https://example.invalid/resource",
+ "message": map[string]any{"id": "message-id"},
+ "emailAccounts": []any{map[string]any{"email": "user@example.com", "type": "PERSONAL"}},
+ } {
+ dataMap[key] = value
+ }
+ resultMap := cloneStringAnyMap(record)
+ for key, value := range map[string]any{
+ "success": true, "status": "SUCCESS", "taskStatus": "SUCCESS", "hasMore": false,
+ "nextCursor": "", "items": []any{record}, "records": []any{record}, "data": []any{record},
+ "downloadUrl": "https://example.invalid/download", "resourceUrl": "https://example.invalid/resource",
+ "uploadKey": "upload-key", "jobId": "job-id", "taskId": "task-id",
+ "message": map[string]any{"id": "message-id"},
+ "emailAccounts": []any{map[string]any{"email": "user@example.com", "type": "PERSONAL"}},
+ } {
+ resultMap[key] = value
+ }
+ payload := map[string]any{
+ "success": true,
+ "result": resultMap,
+ "data": dataMap,
+ "items": []any{record},
+ "records": []any{record},
+ "hasMore": false,
+ "nextCursor": "",
+ "status": "SUCCESS",
+ "taskStatus": "SUCCESS",
+ "downloadUrl": "https://example.invalid/download",
+ "resourceUrl": "https://example.invalid/resource",
+ "presignedUrl": "https://example.invalid/upload",
+ "uploadKey": "upload-key",
+ "jobId": "job-id",
+ "taskId": "task-id",
+ "messageId": "message-id",
+ "fileName": "fixture.txt",
+ "dentryId": float64(1),
+ "spaceId": float64(2),
+ "code": float64(0),
+ "errcode": float64(0),
+ }
+ if c.mode == "list-shapes" {
+ payload["result"] = []any{record}
+ payload["data"] = []any{record}
+ }
+ if c.mode == "flat-record" {
+ payload = record
+ }
+ if c.mode == "nested-data" {
+ payload = map[string]any{"data": map[string]any{"result": resultMap}}
+ }
+ if c.mode == "empty-result" {
+ payload = map[string]any{}
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(raw)}}}, nil
+}
+
+func (c *productExampleCaller) Format() string {
+ if c.format == "" {
+ return "json"
+ }
+ return c.format
+}
+func (c *productExampleCaller) DryRun() bool { return c.dry }
+func (*productExampleCaller) Fields() string { return "" }
+func (*productExampleCaller) JQ() string { return "" }
+
+func TestProductCommandExamplesAreExecutableContracts(t *testing.T) {
+ workingDirectory, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("get working directory: %v", err)
+ }
+ if err := os.Chdir(t.TempDir()); err != nil {
+ t.Fatalf("use isolated working directory: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(workingDirectory) })
+
+ previousDeps := deps
+ previousArgs := os.Args
+ previousStdin := os.Stdin
+ previousPut := httpPutFile
+ previousGet := httpGetFile
+ t.Cleanup(func() {
+ deps = previousDeps
+ os.Args = previousArgs
+ os.Stdin = previousStdin
+ httpPutFile = previousPut
+ httpGetFile = previousGet
+ })
+
+ caller := &productExampleCaller{}
+ InitDeps(caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
+ httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
+ if destPath == "" {
+ return nil
+ }
+ return os.WriteFile(destPath, []byte("example"), 0o600)
+ }
+ confirmationInput := t.TempDir() + "/confirmations.txt"
+ if err := os.WriteFile(confirmationInput, []byte(strings.Repeat("yes\n", 2048)), 0o600); err != nil {
+ t.Fatalf("write confirmation input: %v", err)
+ }
+ confirmationFile, err := os.Open(confirmationInput)
+ if err != nil {
+ t.Fatalf("open confirmation input: %v", err)
+ }
+ t.Cleanup(func() { _ = confirmationFile.Close() })
+ os.Stdin = confirmationFile
+
+ commands := NewPublicCommands(&captureRunner{})
+ executed := 0
+ malformedExecuted := 0
+ for _, root := range commands {
+ installExampleGlobalFlags(root)
+ root.SilenceErrors = true
+ root.SilenceUsage = true
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ os.Args = []string{"dws", root.Name(), "--yes"}
+
+ for _, invocation := range commandExampleInvocations(root) {
+ executed++
+ caller.mode = ""
+ resetCommandFlags(root)
+ root.SetArgs(invocation)
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ err := root.ExecuteContext(ctx)
+ cancel()
+ if isCommandSyntaxError(err) {
+ t.Errorf("dws %s %s: %v", root.Name(), strings.Join(invocation, " "), err)
+ }
+
+ for _, mode := range []string{"list-shapes", "flat-record", "nested-data", "empty-result", "empty-content", "non-text", "transport-error", "invalid-json"} {
+ caller.mode = mode
+ resetCommandFlags(root)
+ root.SetArgs(invocation)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ _ = root.ExecuteContext(ctx)
+ cancel()
+ }
+ caller.mode = ""
+
+ for _, malformed := range malformedExampleInvocations(invocation) {
+ malformedExecuted++
+ resetCommandFlags(root)
+ root.SetArgs(malformed)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ _ = root.ExecuteContext(ctx)
+ cancel()
+ }
+ }
+ }
+
+ if executed < 400 {
+ t.Fatalf("executed %d product examples, want at least 400", executed)
+ }
+ if caller.calls < 250 {
+ t.Fatalf("product examples made %d tool calls, want at least 250", caller.calls)
+ }
+ if malformedExecuted < 300 {
+ t.Fatalf("executed %d malformed example variants, want at least 300", malformedExecuted)
+ }
+}
+
+func malformedExampleInvocations(invocation []string) [][]string {
+ var variants [][]string
+ for index, token := range invocation {
+ if !strings.HasPrefix(token, "--") {
+ continue
+ }
+ if equal := strings.IndexByte(token, '='); equal >= 0 {
+ variant := append([]string(nil), invocation...)
+ variant[index] = token[:equal+1] + malformedFlagValue(token[equal+1:])
+ variants = append(variants, variant)
+ continue
+ }
+ if index+1 >= len(invocation) || strings.HasPrefix(invocation[index+1], "-") {
+ continue
+ }
+ variant := append([]string(nil), invocation...)
+ variant[index+1] = malformedFlagValue(invocation[index+1])
+ variants = append(variants, variant)
+ }
+ return variants
+}
+
+func malformedFlagValue(value string) string {
+ for _, r := range value {
+ if r < '0' || r > '9' {
+ return "invalid"
+ }
+ }
+ return "-1"
+}
+
+func commandExampleInvocations(root *cobra.Command) [][]string {
+ var invocations [][]string
+ var walk func(*cobra.Command)
+ walk = func(command *cobra.Command) {
+ for _, line := range logicalExampleLines(command.Example) {
+ tokens, err := splitExampleCommand(line)
+ if err != nil || len(tokens) < 2 || tokens[0] != "dws" || tokens[1] != root.Name() {
+ continue
+ }
+ if unsupportedExampleTokens(tokens) {
+ continue
+ }
+ invocations = append(invocations, tokens[2:])
+ }
+ for _, child := range command.Commands() {
+ walk(child)
+ }
+ }
+ walk(root)
+ return invocations
+}
+
+func logicalExampleLines(example string) []string {
+ physical := strings.Split(example, "\n")
+ logical := make([]string, 0, len(physical))
+ var current string
+ for _, line := range physical {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if current != "" {
+ current += " " + line
+ } else {
+ current = line
+ }
+ if strings.HasSuffix(current, "\\") {
+ current = strings.TrimSpace(strings.TrimSuffix(current, "\\"))
+ continue
+ }
+ if index := strings.Index(current, "dws "); index >= 0 {
+ logical = append(logical, current[index:])
+ }
+ current = ""
+ }
+ if current != "" {
+ if index := strings.Index(current, "dws "); index >= 0 {
+ logical = append(logical, current[index:])
+ }
+ }
+ return logical
+}
+
+func splitExampleCommand(line string) ([]string, error) {
+ var tokens []string
+ var token strings.Builder
+ var quote rune
+ escaped := false
+ tokenStarted := false
+ flush := func() {
+ if tokenStarted {
+ tokens = append(tokens, token.String())
+ token.Reset()
+ tokenStarted = false
+ }
+ }
+ for _, r := range line {
+ if escaped {
+ token.WriteRune(r)
+ tokenStarted = true
+ escaped = false
+ continue
+ }
+ if r == '\\' && quote != '\'' {
+ escaped = true
+ continue
+ }
+ if quote != 0 {
+ if r == quote {
+ quote = 0
+ } else {
+ token.WriteRune(r)
+ tokenStarted = true
+ }
+ continue
+ }
+ switch {
+ case r == '\'' || r == '"':
+ quote = r
+ tokenStarted = true
+ case r == '#':
+ flush()
+ return tokens, nil
+ case unicode.IsSpace(r):
+ flush()
+ default:
+ token.WriteRune(r)
+ tokenStarted = true
+ }
+ }
+ if escaped || quote != 0 {
+ return nil, fmt.Errorf("unterminated escape or quote")
+ }
+ flush()
+ return tokens, nil
+}
+
+func installExampleGlobalFlags(root *cobra.Command) {
+ flags := root.PersistentFlags()
+ if flags.Lookup("format") == nil {
+ flags.String("format", "json", "test global output format")
+ }
+ if flags.Lookup("fields") == nil {
+ flags.String("fields", "", "test global field projection")
+ }
+ if flags.Lookup("jq") == nil {
+ flags.String("jq", "", "test global jq filter")
+ }
+ if flags.Lookup("timeout") == nil {
+ flags.Int("timeout", 30, "test global timeout")
+ }
+ if flags.Lookup("dry-run") == nil {
+ flags.Bool("dry-run", false, "test global dry-run")
+ }
+ if flags.Lookup("yes") == nil {
+ flags.Bool("yes", false, "test global confirmation bypass")
+ }
+}
+
+func unsupportedExampleTokens(tokens []string) bool {
+ for _, token := range tokens {
+ if strings.ContainsAny(token, "|`$") || strings.HasPrefix(token, "[") || strings.HasSuffix(token, "]") {
+ return true
+ }
+ }
+ return false
+}
+
+func resetCommandFlags(command *cobra.Command) {
+ reset := func(flag *pflag.Flag) {
+ _ = flag.Value.Set(flag.DefValue)
+ flag.Changed = false
+ }
+ command.Flags().VisitAll(reset)
+ command.PersistentFlags().VisitAll(reset)
+ for _, child := range command.Commands() {
+ resetCommandFlags(child)
+ }
+}
+
+func isCommandSyntaxError(err error) bool {
+ if err == nil {
+ return false
+ }
+ message := strings.ToLower(err.Error())
+ for _, fragment := range []string{
+ "unknown command", "unknown flag", "flag needs an argument", "requires at least",
+ "requires exactly", "accepts 1 arg", "accepts 2 arg", "required flag",
+ } {
+ if strings.Contains(message, fragment) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/helpers/register_products_test.go b/internal/helpers/register_products_test.go
new file mode 100644
index 00000000..08a0bb9b
--- /dev/null
+++ b/internal/helpers/register_products_test.go
@@ -0,0 +1,60 @@
+package helpers
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestPublicProductCommandsBuildCompleteUniqueTrees(t *testing.T) {
+ commands := NewPublicCommands(&captureRunner{})
+ if len(commands) == 0 {
+ t.Fatal("NewPublicCommands() returned no commands")
+ }
+
+ seenProducts := make(map[string]bool, len(commands))
+ for _, command := range commands {
+ if command == nil {
+ t.Fatal("NewPublicCommands() returned a nil command")
+ }
+ name := command.Name()
+ if name == "" {
+ t.Fatal("public command has an empty name")
+ }
+ if seenProducts[name] {
+ t.Fatalf("duplicate public command %q", name)
+ }
+ seenProducts[name] = true
+ assertCommandTree(t, command, make(map[*cobra.Command]bool))
+ }
+
+ for _, want := range []string{
+ "agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
+ "contact", "devdoc", "ding", "doc", "drive", "live", "mail",
+ "minutes", "oa", "report", "sheet", "todo", "wiki",
+ } {
+ if !seenProducts[want] {
+ t.Errorf("public product %q was not registered", want)
+ }
+ }
+}
+
+func assertCommandTree(t *testing.T, command *cobra.Command, seen map[*cobra.Command]bool) {
+ t.Helper()
+ if seen[command] {
+ t.Fatalf("command tree contains a cycle at %q", command.CommandPath())
+ }
+ seen[command] = true
+
+ seenNames := make(map[string]bool, len(command.Commands()))
+ for _, child := range command.Commands() {
+ if child.Parent() != command {
+ t.Errorf("command %q has parent %q, want %q", child.Name(), child.Parent().Name(), command.Name())
+ }
+ if seenNames[child.Name()] {
+ t.Errorf("command %q contains duplicate child %q", command.CommandPath(), child.Name())
+ }
+ seenNames[child.Name()] = true
+ assertCommandTree(t, child, seen)
+ }
+}
diff --git a/internal/helpers/report_test.go b/internal/helpers/report_test.go
new file mode 100644
index 00000000..079c7ff9
--- /dev/null
+++ b/internal/helpers/report_test.go
@@ -0,0 +1,457 @@
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type reportTestCaller struct {
+ dry bool
+ format string
+ response string
+ err error
+ calls []string
+}
+
+func (c *reportTestCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
+ c.calls = append(c.calls, tool)
+ if c.err != nil {
+ return nil, c.err
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.response}}}, nil
+}
+
+func (c *reportTestCaller) Format() string { return c.format }
+func (c *reportTestCaller) DryRun() bool { return c.dry }
+func (*reportTestCaller) Fields() string { return "" }
+func (*reportTestCaller) JQ() string { return "" }
+
+func installReportTestDeps(t *testing.T, caller *reportTestCaller) (*bytes.Buffer, *bytes.Buffer) {
+ t.Helper()
+ previous := deps
+ t.Cleanup(func() { deps = previous })
+ InitDeps(caller)
+ out := &bytes.Buffer{}
+ errOut := &bytes.Buffer{}
+ deps.Out.w = out
+ deps.Out.errW = errOut
+ return out, errOut
+}
+
+func TestReportReadableListEnrichment(t *testing.T) {
+ caller := &reportTestCaller{format: "json", response: `{
+ "result":{"reportName":"Detail","creatorName":"Alice","createTime":1700000000000,"readStatus":true,"url":"dingtalk://detail","contents":[{"key":"Done","content":"A|B"}]}}
+ `}
+ installReportTestDeps(t, caller)
+ body := map[string]any{
+ "success": true,
+ "hasMore": false,
+ "nextCursor": "next",
+ "result": []any{
+ map[string]any{"reportId": "older", "createTime": float64(1600000000000), "senderName": "Old"},
+ map[string]any{"report_id": "newer", "createTime": "2026-01-02T03:04:05Z"},
+ map[string]any{"title": "no id"},
+ },
+ }
+ got := enrichReportListReadable(context.Background(), "sent", body).(map[string]any)
+ rows := got["result"].([]map[string]string)
+ if len(rows) != 2 || got["count"] != 2 || !got["agentDisplayContentIncluded"].(bool) {
+ t.Fatalf("enriched sent list = %#v", got)
+ }
+ if !strings.Contains(got["agentDisplayMarkdown"].(string), "A|B") || len(caller.calls) != 2 {
+ t.Fatalf("markdown/calls = %q / %#v", got["agentDisplayMarkdown"], caller.calls)
+ }
+ if got["nextCursor"] != "next" || got["hasMore"] != false {
+ t.Fatalf("pagination = %#v", got)
+ }
+
+ caller.err = errors.New("detail unavailable")
+ inbox := enrichReportListReadable(context.Background(), "list", map[string]any{
+ "result": map[string]any{"items": []any{map[string]any{"reportId": "id"}}, "cursor": "nested"},
+ }).(map[string]any)
+ if inbox["agentDisplayContentIncluded"].(bool) || inbox["cursor"] != "nested" {
+ t.Fatalf("enriched inbox = %#v", inbox)
+ }
+ if enrichReportListReadable(context.Background(), "list", []any{"not-map"}) == nil {
+ t.Fatal("non-map list enrichment changed value")
+ }
+ plain := map[string]any{"success": "ok"}
+ if got := enrichReportListReadable(context.Background(), "list", plain).(map[string]any); got["success"] != "ok" || len(got) != 1 {
+ t.Fatalf("map without list was changed: %#v", got)
+ }
+}
+
+func TestReportReadableValueFormatting(t *testing.T) {
+ columns := reportListDisplayColumns("sent")
+ if len(columns) != 6 || len(reportListDisplayColumns("list")) != 5 {
+ t.Fatal("display columns changed")
+ }
+ if !strings.Contains(reportListAgentDisplayInstruction(true, "header"), "日志内容") ||
+ !strings.Contains(reportListAgentDisplayInstruction(false, "header"), "禁止返回日志正文") {
+ t.Fatal("display instructions changed")
+ }
+ if reportReadableListTitle("sent") == reportReadableListTitle("list") {
+ t.Fatal("sent and inbox titles should differ")
+ }
+ if reportListSuccess(map[string]any{}) != true || reportListSuccess(map[string]any{"success": false}) != false {
+ t.Fatal("reportListSuccess changed")
+ }
+
+ values := []any{
+ " text ", float64(1.25), true, false,
+ []any{"a", float64(2), nil}, map[string]any{"text": "nested"}, nil,
+ }
+ for _, value := range values {
+ _ = reportDisplayStringFromValue(value)
+ _ = reportReadableContentFromValue(value)
+ }
+ content := reportReadableContentFromValue([]any{
+ map[string]any{"key": "Done", "content": "work"},
+ map[string]any{"value": "bare"},
+ map[string]any{"key": "empty"},
+ })
+ if content != "Done:work;bare" {
+ t.Fatalf("readable content = %q", content)
+ }
+ if reportFirstDisplayValue(map[string]any{"first": nil, "second": "ok"}, "first", "second") != "ok" {
+ t.Fatal("reportFirstDisplayValue did not fall through")
+ }
+ if reportCompactDisplayText(" a | b \n c ", 4) != "a | ..." || reportCompactDisplayText("short", 0) != "short" {
+ t.Fatal("compact display formatting changed")
+ }
+
+ row := reportReadableRow(map[string]any{"creatorName": "Alice", "read": false})
+ if row["标题"] != "Alice的日志" || row["状态"] != "未读" {
+ t.Fatalf("readable row = %#v", row)
+ }
+ if reportReadableRow(map[string]any{})["标题"] != "日志" {
+ t.Fatal("empty row title fallback changed")
+ }
+ for _, status := range []any{true, false, "read", "unread", "custom", float64(1), float64(0), float64(2), nil} {
+ _ = reportStatusValueToString(status)
+ }
+
+ timestamps := []any{float64(1700000000), int64(1700000000000), int(1700000000), json.Number("1700000000"), json.Number("bad"), "1700000000", "2026-01-02T03:04:05Z", "2026-01-02 03:04", "bad", "", nil}
+ for _, timestamp := range timestamps {
+ _ = reportTimeValueToMillis(timestamp)
+ _ = reportTimeValueToString(timestamp)
+ }
+ if normalizeReportTimestamp(-1) != 0 || normalizeReportTimestamp(1700000000) != 1700000000000 || reportMillisToLocalString(0) != "" {
+ t.Fatal("timestamp normalization changed")
+ }
+
+ entries := makeReportReadableEntries([]map[string]any{
+ {"reportId": "zero", "title": "Z"},
+ {"reportId": "old", "createTime": float64(1600000000000)},
+ {"reportId": "new", "createTime": float64(1700000000000)},
+ {"reportId": "same-a", "createTime": "date-b"},
+ {"reportId": "same-b", "createTime": "date-a"},
+ })
+ sortReportReadableEntries(entries)
+ if entries[0].reportID != "new" || entries[len(entries)-1].reportID != "zero" {
+ t.Fatalf("sorted entries = %#v", entries)
+ }
+
+ table := reportListMarkdownTable([]string{"A", "B"}, []map[string]string{{"A": "x|y\nnext", "B": "z"}})
+ if !strings.Contains(table, "x|y next") || reportListMarkdownHeader([]string{"A"}) != "| A |" {
+ t.Fatalf("markdown table = %q", table)
+ }
+}
+
+func TestReportResponseTraversalAndLinks(t *testing.T) {
+ detail := map[string]any{"result": map[string]any{
+ "report_name": "Title", "creatorName": "Alice", "createTime": "2026-01-02T03:04:05Z",
+ "readStatus": "read", "contents": []any{map[string]any{"key": "Done", "content": "work"}},
+ "url": "dingtalk://report",
+ }}
+ if reportReadableDetailTitle(detail) != "Title" || reportReadableDetailSender(detail) != "Alice" ||
+ reportReadableDetailDate(detail) == "" || reportReadableDetailStatus(detail) != "已读" ||
+ reportReadableDetailContent(detail) != "Done:work" || !strings.Contains(reportMarkdownLinkFromResponse(detail), "dingtalk://report") {
+ t.Fatalf("detail traversal failed: %#v", detail)
+ }
+ if reportStringFromResponse(map[string]any{"dingtalkOpenMarkdownLink": " [link](url) "}, "dingtalkOpenMarkdownLink") != "[link](url)" {
+ t.Fatal("reportStringFromResponse trimming changed")
+ }
+ for _, value := range []any{
+ map[string]any{"result": map[string]any{"dingtalkOpenUrl": " nested "}},
+ map[string]any{"result": map[string]any{"dingtalkOpenLink": map[string]any{"url": " nested-link "}}},
+ map[string]any{"url": " top "},
+ map[string]any{"dingtalkOpenLink": map[string]any{"url": " top-link "}},
+ []any{},
+ } {
+ _ = reportURLFromResponse(value)
+ }
+ body := map[string]any{"result": map[string]any{}}
+ attachReportURL(body, "dingtalk://url")
+ if body["url"] != "dingtalk://url" || body["result"].(map[string]any)["url"] != "dingtalk://url" {
+ t.Fatalf("attachReportURL = %#v", body)
+ }
+ if reportDingtalkOpenLink("url")["url"] != "url" || !strings.Contains(reportDingtalkMarkdownLink("url"), "url") {
+ t.Fatal("report link helpers changed")
+ }
+
+ ids := []map[string]any{{"reportId": " id "}, {"reportID": "id"}, {"report_id": "id"}, {"report_Id": "id"}, {"report-id": "id"}, {"reportId": 1}}
+ for index, item := range ids {
+ got := reportIDFromMap(item)
+ if index < 5 && got != "id" {
+ t.Errorf("reportIDFromMap(%#v) = %q", item, got)
+ }
+ }
+ if reportIDFromCreateResponse(map[string]any{"result": " id "}) != "id" ||
+ reportIDFromCreateResponse(map[string]any{"result": map[string]any{"report_id": "nested"}}) != "nested" ||
+ reportIDFromCreateResponse(map[string]any{"reportId": "top"}) != "top" ||
+ reportIDFromCreateResponse(map[string]any{}) != "" {
+ t.Fatal("create response ID extraction changed")
+ }
+}
+
+func TestReportCreateEnrichmentBranches(t *testing.T) {
+ caller := &reportTestCaller{format: "json", response: `{"result":{"url":"dingtalk://detail"}}`}
+ out, _ := installReportTestDeps(t, caller)
+ if enrichReportCreateWithDetailURL(context.Background(), []any{"unchanged"}) == nil {
+ t.Fatal("non-map create response changed")
+ }
+ direct := enrichReportCreateWithDetailURL(context.Background(), map[string]any{"url": "dingtalk://direct"}).(map[string]any)
+ if direct["dingtalkOpenUrl"] != "dingtalk://direct" {
+ t.Fatalf("direct URL enrichment = %#v", direct)
+ }
+ missing := enrichReportCreateWithDetailURL(context.Background(), map[string]any{}).(map[string]any)
+ if missing["urlLookupError"] == nil {
+ t.Fatal("missing report ID had no lookup error")
+ }
+ resolved := enrichReportCreateWithDetailURL(context.Background(), map[string]any{"reportId": "id"}).(map[string]any)
+ if resolved["url"] != "dingtalk://detail" {
+ t.Fatalf("detail URL enrichment = %#v", resolved)
+ }
+
+ caller.response = "not-json"
+ invalid := enrichReportCreateWithDetailURL(context.Background(), map[string]any{"reportId": "id"}).(map[string]any)
+ if !strings.Contains(invalid["urlLookupError"].(string), "parse") {
+ t.Fatalf("invalid detail response = %#v", invalid)
+ }
+ caller.response = `{}`
+ noURL := enrichReportCreateWithDetailURL(context.Background(), map[string]any{"reportId": "id"}).(map[string]any)
+ if noURL["urlLookupError"] == nil {
+ t.Fatalf("detail without URL = %#v", noURL)
+ }
+ caller.err = errors.New("lookup failed")
+ failed := enrichReportCreateWithDetailURL(context.Background(), map[string]any{"reportId": "id"}).(map[string]any)
+ if !strings.Contains(failed["urlLookupError"].(string), "lookup failed") {
+ t.Fatalf("detail lookup error = %#v", failed)
+ }
+
+ caller.err = nil
+ caller.response = `{}`
+ out.Reset()
+ if err := printReportCreateWithDetailURL(context.Background(), "not-json"); err != nil || out.String() != "not-json\n" {
+ t.Fatalf("raw create output = %q, %v", out.String(), err)
+ }
+ out.Reset()
+ if err := printReportCreateWithDetailURL(context.Background(), `{}`); err != nil || !strings.Contains(out.String(), "urlLookupError") {
+ t.Fatalf("JSON create output = %q, %v", out.String(), err)
+ }
+}
+
+func TestCallReportCreateWithDetailURLCoverage(t *testing.T) {
+ caller := &reportTestCaller{dry: true, format: "json", response: `{"ok":true}`}
+ installReportTestDeps(t, caller)
+ _ = callReportCreateWithDetailURL(map[string]any{"template_id": "template"})
+
+ caller.dry = false
+ caller.response = `{"reportId":"id","url":"dingtalk://direct"}`
+ if err := callReportCreateWithDetailURL(map[string]any{"template_id": "template"}); err != nil {
+ t.Fatal(err)
+ }
+ caller.err = errors.New("create failed")
+ if err := callReportCreateWithDetailURL(map[string]any{}); err == nil {
+ t.Fatal("report create error was ignored")
+ }
+}
+
+func TestReportContentsValidationMatrix(t *testing.T) {
+ valid := map[string]any{"key": " Done ", "sort": 1, "content": "", "type": "text", "contentType": "text"}
+ if err := validateAndNormalizeReportContents([]map[string]any{valid}); err != nil {
+ t.Fatalf("valid contents error = %v", err)
+ }
+ if valid["key"] != "Done" || valid["sort"] != "1" || valid["type"] != "1" || valid["contentType"] != "markdown" {
+ t.Fatalf("normalized contents = %#v", valid)
+ }
+ validNonText := map[string]any{"key": "Number", "sort": json.Number("2"), "content": "3", "type": "number", "contentType": "raw"}
+ if err := validateAndNormalizeReportContents([]map[string]any{validNonText}); err != nil || validNonText["contentType"] != "origin" {
+ t.Fatalf("valid non-text = %#v, %v", validNonText, err)
+ }
+
+ invalid := [][]map[string]any{
+ nil, {nil},
+ {{"sort": 1, "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": 1, "sort": 1, "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": " ", "sort": 1, "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "sort": map[string]any{}, "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "sort": " ", "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "sort": "bad", "content": "x", "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "sort": 1, "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "sort": 1, "content": 1, "type": "1", "contentType": "markdown"}},
+ {{"key": "k", "sort": 1, "content": "x", "contentType": "markdown"}},
+ {{"key": "k", "sort": 1, "content": "x", "type": map[string]any{}, "contentType": "markdown"}},
+ {{"key": "k", "sort": 1, "content": "x", "type": "bad", "contentType": "markdown"}},
+ {{"key": "k", "sort": 1, "content": "x", "type": "1"}},
+ {{"key": "k", "sort": 1, "content": "x", "type": "1", "contentType": 1}},
+ {{"key": "k", "sort": 1, "content": "x", "type": "1", "contentType": "origin"}},
+ {{"key": "k", "sort": 1, "content": "x", "type": "2", "contentType": "markdown"}},
+ }
+ for index, contents := range invalid {
+ if err := validateAndNormalizeReportContents(contents); err == nil {
+ t.Errorf("invalid contents case %d returned nil", index)
+ }
+ }
+
+ for _, value := range []any{"1", float64(1), float64(1.5), float32(1), float32(1.5), int(1), int64(1), int32(1), json.Number("1"), json.Number("1.5"), true} {
+ _, _ = reportScalarToString(value)
+ }
+ for _, value := range []any{"1", "text", "number", "single", "date", "multi", "image", "attachment", "bad", true} {
+ _, _ = normalizeReportFieldType(value, 0)
+ }
+}
+
+type failingReader struct{}
+
+func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") }
+
+func TestReportContentsSourcesAndSafePaths(t *testing.T) {
+ dir := t.TempDir()
+ previousWD, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(previousWD) })
+ if err := os.WriteFile("report.json", []byte(`[{}]`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ safe, err := resolveSafeReportContentsFilePath("report.json")
+ if err != nil || !filepath.IsAbs(safe) {
+ t.Fatalf("safe path = %q, %v", safe, err)
+ }
+ if got, err := readReportContentsFile(safe); err != nil || got != `[{}]` {
+ t.Fatalf("read report = %q, %v", got, err)
+ }
+ for _, path := range []string{".", "-", "../outside.json", "missing.json"} {
+ if _, err := resolveSafeReportContentsFilePath(path); err == nil {
+ t.Errorf("unsafe path %q returned nil error", path)
+ }
+ }
+ if _, err := resolveSafeReportContentsFilePath(dir); err == nil {
+ t.Fatal("directory path returned nil error")
+ }
+ if _, err := readReportContentsFile("missing.json"); err == nil {
+ t.Fatal("missing report file returned nil error")
+ }
+ if !pathEscapesUpward("..") || !pathEscapesUpward("../x") || pathEscapesUpward("inside") {
+ t.Fatal("pathEscapesUpward changed")
+ }
+ if !pathWithinRoot(dir, filepath.Join(dir, "file")) || pathWithinRoot(dir, filepath.Join(filepath.Dir(dir), "outside")) {
+ t.Fatal("pathWithinRoot changed")
+ }
+
+ outside := filepath.Join(t.TempDir(), "outside.json")
+ if err := os.WriteFile(outside, []byte(`[]`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, "link.json"); err == nil {
+ if _, err := resolveSafeReportContentsFilePath("link.json"); err == nil {
+ t.Fatal("symlink escaping cwd returned nil error")
+ }
+ }
+
+ if got, err := readReportContentsStdin(strings.NewReader("stdin")); err != nil || got != "stdin" {
+ t.Fatalf("stdin contents = %q, %v", got, err)
+ }
+ if _, err := readReportContentsLimited(failingReader{}, "source"); err == nil {
+ t.Fatal("reader error was ignored")
+ }
+ if _, err := readReportContentsLimited(io.LimitReader(strings.NewReader(strings.Repeat("x", reportContentsMaxBytes+1)), int64(reportContentsMaxBytes+1)), "large"); err == nil {
+ t.Fatal("oversized contents returned nil error")
+ }
+ if cli, ok := reportContentsTooLargeError("large").(*CLIError); !ok || cli.Code != CodeInputTooLarge {
+ t.Fatal("oversized error classification changed")
+ }
+
+ cmd := &cobra.Command{Use: "test"}
+ addReportCreateFlags(cmd)
+ cmd.SetIn(strings.NewReader("from stdin"))
+ if err := cmd.Flags().Set("contents", "-"); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := resolveReportContentsFromFlags(cmd); err != nil || got != "from stdin" {
+ t.Fatalf("stdin flag contents = %q, %v", got, err)
+ }
+ _ = cmd.Flags().Set("contents", "literal")
+ if got, err := resolveReportContentsFromFlags(cmd); err != nil || got != "literal" {
+ t.Fatalf("literal contents = %q, %v", got, err)
+ }
+ _ = cmd.Flags().Set("contents-file", "report.json")
+ if got, err := resolveReportContentsFromFlags(cmd); err != nil || got != `[{}]` {
+ t.Fatalf("file contents = %q, %v", got, err)
+ }
+}
+
+func TestReportDispatchHintsAndDeprecation(t *testing.T) {
+ caller := &reportTestCaller{format: "table"}
+ out, _ := installReportTestDeps(t, caller)
+ for _, operation := range []string{"template-list", "template-detail", "detail", "stats", "list", "sent", "create", "unknown"} {
+ _ = reportDispatchHint(operation, "")
+ _ = reportDispatchHint(operation, "parameter error")
+ }
+ if reportDispatchHint("create", "PARAM_ERROR") == "" || reportDispatchHint("unknown", "bad") != "" {
+ t.Fatal("dispatch hint routing changed")
+ }
+ if err := withReportDispatchHint("list", nil); err != nil || !strings.Contains(out.String(), "可复用") {
+ t.Fatalf("successful dispatch hint = %q, %v", out.String(), err)
+ }
+ caller.format = "json"
+ if err := withReportDispatchHint("list", nil); err != nil {
+ t.Fatal(err)
+ }
+ plain := errors.New("plain")
+ if withReportDispatchHint("list", plain) != plain {
+ t.Fatal("plain error was wrapped")
+ }
+ cli := &CLIError{Code: CodeMCPToolError, Message: "bad"}
+ if got := withReportDispatchHint("list", cli); got != cli || cli.Suggestion == "" {
+ t.Fatalf("CLI dispatch hint = %#v", got)
+ }
+ cli.Suggestion = "existing"
+ _ = withReportDispatchHint("list", cli)
+ if !strings.Contains(cli.Suggestion, "existing\n") {
+ t.Fatalf("existing suggestion = %q", cli.Suggestion)
+ }
+
+ cmd := &cobra.Command{Use: "old"}
+ errOut := &bytes.Buffer{}
+ cmd.SetErr(errOut)
+ called := false
+ run := withReportDeprecationWarning("old", "new", func(*cobra.Command, []string) error { called = true; return nil })
+ if err := run(cmd, nil); err != nil || !called || !strings.Contains(errOut.String(), "deprecated") {
+ t.Fatalf("deprecation wrapper = %q, %v, %v", errOut.String(), called, err)
+ }
+ if got := parseReportUserIDs(" a, ,b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
+ t.Fatalf("parseReportUserIDs = %#v", got)
+ }
+
+ _ = time.Now() // keep time-zone dependent helpers exercised in this test file.
+}
diff --git a/internal/helpers/sheet_batch_test.go b/internal/helpers/sheet_batch_test.go
new file mode 100644
index 00000000..38b0f88b
--- /dev/null
+++ b/internal/helpers/sheet_batch_test.go
@@ -0,0 +1,122 @@
+package helpers
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSheetBatchOperationTranslationCoversEveryMapping(t *testing.T) {
+ input := map[string]any{
+ "sheet-id": "Sheet1", "range": "A1:B2", "type": "all", "values": []any{"value"},
+ "merge-type": "mergeRows", "source-range": "A1", "target-range": "B2",
+ "target-sheet-id": "Sheet2", "paste-type": "values", "dimension": "row",
+ "length": float64(2), "position": "3", "start-index": 1, "end-index": "2",
+ "destination-index": float64(4), "options": []string{"a", "b"}, "multi-select": true,
+ "csv": "a,b\r\n1,2", "start-cell": "A1", "allow-overwrite": true,
+ "float-image-id": "image-id", "pixel-size": "24", "hidden": true,
+ "group-state": "fold",
+ }
+ for name, mapping := range batchOpDispatch {
+ got, err := translateBatchOp(map[string]any{"toolName": name, "input": input})
+ if err != nil {
+ t.Errorf("translateBatchOp(%q): %v", name, err)
+ continue
+ }
+ if got["toolName"] != mapping.mcpTool {
+ t.Errorf("translateBatchOp(%q) tool = %v, want %q", name, got["toolName"], mapping.mcpTool)
+ }
+ if _, ok := got["input"].(map[string]any); !ok {
+ t.Errorf("translateBatchOp(%q) input = %#v", name, got["input"])
+ }
+ }
+ if _, err := translateBatchOp(map[string]any{"toolName": "unknown"}); err == nil {
+ t.Fatal("unknown batch operation should fail")
+ }
+ if _, err := translateBatchOp(map[string]any{"toolName": "range clear"}); err != nil {
+ t.Fatalf("nil input should be accepted: %v", err)
+ }
+}
+
+func TestSheetBatchValueConversionsAndDefaults(t *testing.T) {
+ if got := batchStr(map[string]any{"second": 42}, "first", "second"); got != "42" {
+ t.Fatalf("batchStr() = %q", got)
+ }
+ if got := batchStr(nil, "missing"); got != "" {
+ t.Fatalf("missing batchStr() = %q", got)
+ }
+ for _, tc := range []struct {
+ input map[string]any
+ want int
+ }{
+ {map[string]any{"n": float64(3)}, 3},
+ {map[string]any{"n": 4}, 4},
+ {map[string]any{"n": "5"}, 5},
+ {nil, 0},
+ } {
+ if got := batchInt(tc.input, "missing", "n"); got != tc.want {
+ t.Errorf("batchInt(%v) = %d, want %d", tc.input, got, tc.want)
+ }
+ }
+ for _, input := range []map[string]any{nil, {"type": nil}, {"type": ""}} {
+ if got := batchStrOr(input, "type", "content"); got != "content" {
+ t.Errorf("batchStrOr(%v) = %q", input, got)
+ }
+ }
+ if got := batchStrOr(map[string]any{"type": "all"}, "type", "content"); got != "all" {
+ t.Fatalf("explicit batchStrOr() = %q", got)
+ }
+
+ if got := BuildMergeCellsArgs(nil)["mergeType"]; got != "mergeAll" {
+ t.Fatalf("default merge type = %v", got)
+ }
+ if _, ok := BuildFillRangeArgs(nil)["fillType"]; ok {
+ t.Fatal("empty fill type should be omitted")
+ }
+ if got := BuildGroupDimensionArgs(nil)["groupState"]; got != "expand" {
+ t.Fatalf("default group state = %v", got)
+ }
+ if _, ok := BuildUpdateDimensionArgs(nil)["pixelSize"]; ok {
+ t.Fatal("zero pixel size should be omitted")
+ }
+ if _, ok := BuildSetDropdownArgs(nil)["enableMultiSelect"]; ok {
+ t.Fatal("unset multi-select should be omitted")
+ }
+}
+
+func TestResolveCSVContent(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "input.csv")
+ if err := os.WriteFile(path, []byte("\xef\xbb\xbfhead\r\nvalue"), 0o600); err != nil {
+ t.Fatalf("write csv: %v", err)
+ }
+ if got := resolveCsvContent("@" + path); got != "head\nvalue" {
+ t.Fatalf("file csv = %q", got)
+ }
+ missing := "@" + filepath.Join(dir, "missing.csv")
+ if got := resolveCsvContent(missing); got != missing {
+ t.Fatalf("missing file csv = %q", got)
+ }
+
+ oldStdin := os.Stdin
+ pipeRead, pipeWrite, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ if _, err := pipeWrite.WriteString("a,b\r\n1,2"); err != nil {
+ t.Fatalf("write pipe: %v", err)
+ }
+ _ = pipeWrite.Close()
+ os.Stdin = pipeRead
+ t.Cleanup(func() {
+ os.Stdin = oldStdin
+ _ = pipeRead.Close()
+ })
+ if got := resolveCsvContent("-"); got != "a,b\n1,2" {
+ t.Fatalf("stdin csv = %q", got)
+ }
+ if got := resolveCsvContent("plain\r\n"); got != strings.ReplaceAll("plain\r\n", "\r", "") {
+ t.Fatalf("plain csv = %q", got)
+ }
+}
diff --git a/internal/helpers/sheet_export.go b/internal/helpers/sheet_export.go
index 0e924d6c..98b09c33 100644
--- a/internal/helpers/sheet_export.go
+++ b/internal/helpers/sheet_export.go
@@ -155,7 +155,7 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
- case <-time.After(wait):
+ case <-helperAfter(wait):
}
text, err := callMCPToolReturnText(ctx, "query_export_job", map[string]any{
diff --git a/internal/helpers/sheet_style_coverage_more_test.go b/internal/helpers/sheet_style_coverage_more_test.go
new file mode 100644
index 00000000..8d61769e
--- /dev/null
+++ b/internal/helpers/sheet_style_coverage_more_test.go
@@ -0,0 +1,106 @@
+package helpers
+
+import (
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSheetStyleRangeAndFontCoverage(t *testing.T) {
+ for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} {
+ _, _, _ = parseA1Range(raw)
+ }
+ _ = fillStringMatrix(2, 2, "x")
+ _ = fillIntMatrix(2, 2, 1)
+ for _, spec := range []*styleSpec{
+ {FontSize: 1, FontSizesJSON: `[[1]]`},
+ {FontSize: -1},
+ {FontSize: 12},
+ {FontSizesJSON: `{`},
+ {FontSizesJSON: `[[1,2]]`},
+ {FontSizesJSON: `[[1]]`},
+ } {
+ _ = applyFontSize(spec, 1, 1, map[string]any{})
+ }
+ for _, tc := range []struct {
+ scalar, raw string
+ enum map[string]bool
+ }{
+ {"x", `[["x"]]`, nil}, {"bad", "", hAlignEnum}, {"left", "", hAlignEnum},
+ {"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil},
+ {"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum},
+ } {
+ _ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
+ }
+ _ = maxColLenStr([][]string{{}, {"a", "b"}})
+ _ = maxColLen2D([][]int{{}, {1, 2}})
+ _ = checkMatrixShape(1, 1, 1, 1, "matrix")
+ _ = checkMatrixShape(1, 2, 1, 1, "matrix")
+
+ for _, tc := range []struct {
+ spec styleSpec
+ rows, cols int
+ }{
+ {styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31},
+ {styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1},
+ {styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1},
+ } {
+ _ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{})
+ }
+}
+
+func TestSheetBatchSetStyleCoverage(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "sheet"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ write := func(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "batch.json")
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+ }
+ cases := []struct {
+ name string
+ body string
+ path string
+ format string
+ response string
+ callErr error
+ cont bool
+ }{
+ {name: "missing-file", path: filepath.Join(t.TempDir(), "missing")},
+ {name: "invalid-json", body: `{`},
+ {name: "empty", body: `[]`},
+ {name: "missing-fields-stop", body: `[{"sheetId":""}]`},
+ {name: "missing-fields-continue", body: `[{"sheetId":""},{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, cont: true},
+ {name: "invalid-range", body: `[{"sheetId":"Sheet1","range":"bad","bgColor":"red"}]`},
+ {name: "invalid-style", body: `[{"sheetId":"Sheet1","range":"A1"}]`},
+ {name: "json-success", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "json", response: `{"ok":true}`},
+ {name: "json-raw-result", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "json", response: `{`},
+ {name: "json-call-error-stop", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "json", callErr: errors.New("failed")},
+ {name: "json-call-error-continue", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"},{"sheetId":"Sheet1","range":"A2","fontSize":12}]`, format: "json", callErr: errors.New("failed"), cont: true},
+ {name: "raw-success", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "raw", response: `{"ok":true}`},
+ {name: "raw-call-error", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "raw", callErr: errors.New("failed"), cont: true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &helpersCoreCaller{format: tc.format, err: tc.callErr, result: textToolResult(tc.response)}
+ installHelpersCoreDeps(t, caller)
+ deps.Out.w = io.Discard
+ deps.Out.errW = io.Discard
+ cmd := newRangeBatchSetStyleCmd()
+ _ = cmd.Flags().Set("node", "node")
+ path := tc.path
+ if path == "" {
+ path = write(t, tc.body)
+ }
+ _ = cmd.Flags().Set("batch", path)
+ _ = cmd.Flags().Set("continue-on-error", map[bool]string{true: "true", false: "false"}[tc.cont])
+ _ = cmd.RunE(cmd, nil)
+ })
+ }
+}
diff --git a/internal/helpers/sheet_validate_test.go b/internal/helpers/sheet_validate_test.go
new file mode 100644
index 00000000..f709c329
--- /dev/null
+++ b/internal/helpers/sheet_validate_test.go
@@ -0,0 +1,131 @@
+package helpers
+
+import "testing"
+
+func TestValidateComplexValueStyle(t *testing.T) {
+ valid := map[string]any{"bold": true, "italic": false, "underline": true, "strike": false, "color": "#fff", "size": float64(12)}
+ if err := validateComplexValueStyle(valid, "style"); err != nil {
+ t.Fatalf("valid style error = %v", err)
+ }
+ for _, style := range []map[string]any{
+ {"unknown": true}, {"bold": "yes"}, {"color": 1}, {"size": "large"},
+ } {
+ if err := validateComplexValueStyle(style, "style"); err == nil {
+ t.Errorf("invalid style %#v returned nil", style)
+ }
+ }
+}
+
+func TestValidateRichTextItems(t *testing.T) {
+ valid := []map[string]any{
+ {"type": "text", "text": "hello", "style": map[string]any{"bold": true}},
+ {"type": "link", "text": "site", "link": "https://example.test", "style": map[string]any{"color": "#fff"}},
+ {"type": "attachment", "text": "file", "resourceId": "rid", "mimeType": "text/plain"},
+ {"type": "image", "resourceId": "rid", "resourceUrl": "https://example.test/image"},
+ }
+ for _, item := range valid {
+ if err := validateRichTextItem(item, "item"); err != nil {
+ t.Errorf("valid rich-text item %#v: %v", item, err)
+ }
+ }
+ invalid := []map[string]any{
+ {}, {"type": 1}, {"type": "text"},
+ {"type": "link", "link": "url"}, {"type": "link", "text": "x"},
+ {"type": "attachment", "resourceId": "rid", "mimeType": "mime"},
+ {"type": "attachment", "text": "x", "mimeType": "mime"},
+ {"type": "attachment", "text": "x", "resourceId": "rid"},
+ {"type": "image", "resourceUrl": "url"}, {"type": "image", "resourceId": "rid"},
+ {"type": "unknown"},
+ {"type": "attachment", "text": "x", "resourceId": "rid", "mimeType": "mime", "style": map[string]any{}},
+ {"type": "text", "text": "x", "style": "bad"},
+ {"type": "text", "text": "x", "style": map[string]any{"bold": "bad"}},
+ }
+ for _, item := range invalid {
+ if err := validateRichTextItem(item, "item"); err == nil {
+ t.Errorf("invalid rich-text item %#v returned nil", item)
+ }
+ }
+}
+
+func TestValidateComplexValueCells(t *testing.T) {
+ valid := []map[string]any{
+ {"dataValidation": map[string]any{"type": "none"}},
+ {"cellStyles": map[string]any{}},
+ {"hyperlink": nil},
+ {"type": "text"},
+ {"type": "text", "text": "", "style": map[string]any{"bold": true}},
+ {"type": "text", "text": "same", "hyperlink": map[string]any{"type": "path", "link": "url", "text": "same"}},
+ {"type": "richText", "texts": []any{map[string]any{"type": "text", "text": "x"}}},
+ }
+ for _, cell := range valid {
+ if err := validateComplexValueCell(cell, "cell"); err != nil {
+ t.Errorf("valid cell %#v: %v", cell, err)
+ }
+ }
+ invalid := []map[string]any{
+ {}, {"dataValidation": "bad"}, {"hyperlink": "bad"}, {"type": 1},
+ {"type": "text", "text": 1}, {"type": "text", "style": "bad"},
+ {"type": "text", "style": map[string]any{"color": 1}},
+ {"type": "text", "text": "cell", "hyperlink": map[string]any{"type": "path", "link": "url", "text": "different"}},
+ {"type": "richText", "hyperlink": nil, "texts": []any{}},
+ {"type": "richText"}, {"type": "richText", "texts": "bad"},
+ {"type": "richText", "texts": []any{}}, {"type": "richText", "texts": []any{"bad"}},
+ {"type": "richText", "texts": []any{map[string]any{"type": "text"}}},
+ {"type": "number"},
+ }
+ for _, cell := range invalid {
+ if err := validateComplexValueCell(cell, "cell"); err == nil {
+ t.Errorf("invalid cell %#v returned nil", cell)
+ }
+ }
+}
+
+func TestValidateCellHyperlinks(t *testing.T) {
+ for _, value := range []any{
+ nil,
+ map[string]any{"type": "none"},
+ map[string]any{"type": "path", "link": "url"},
+ map[string]any{"type": "sheet", "link": "url", "text": "label"},
+ map[string]any{"type": "range", "link": "url"},
+ } {
+ if err := validateCellHyperlink(value, "link"); err != nil {
+ t.Errorf("valid hyperlink %#v: %v", value, err)
+ }
+ }
+ for _, value := range []any{
+ "bad", map[string]any{}, map[string]any{"type": 1},
+ map[string]any{"type": "path"}, map[string]any{"type": "path", "link": " "},
+ map[string]any{"type": "path", "link": "url", "text": 1},
+ map[string]any{"type": "unknown"},
+ } {
+ if err := validateCellHyperlink(value, "link"); err == nil {
+ t.Errorf("invalid hyperlink %#v returned nil", value)
+ }
+ }
+}
+
+func TestValidateDataValidations(t *testing.T) {
+ valid := []any{
+ map[string]any{"type": "dropdown", "options": []any{map[string]any{"value": "one"}}},
+ map[string]any{"type": "checkbox"}, map[string]any{"type": "checkbox", "checked": true},
+ map[string]any{"type": "none"},
+ }
+ for _, value := range valid {
+ if err := validateDataValidation(value, "dv"); err != nil {
+ t.Errorf("valid data validation %#v: %v", value, err)
+ }
+ }
+ invalid := []any{
+ "bad", map[string]any{}, map[string]any{"type": "dropdown"},
+ map[string]any{"type": "dropdown", "options": "bad"},
+ map[string]any{"type": "dropdown", "options": []any{"bad"}},
+ map[string]any{"type": "dropdown", "options": []any{map[string]any{}}},
+ map[string]any{"type": "checkbox", "checked": "yes"},
+ map[string]any{"type": "unknown"},
+ }
+ for _, value := range invalid {
+ if err := validateDataValidation(value, "dv"); err == nil {
+ t.Errorf("invalid data validation %#v returned nil", value)
+ }
+ }
+}
diff --git a/internal/helpers/todo_coverage_more_test.go b/internal/helpers/todo_coverage_more_test.go
new file mode 100644
index 00000000..4980ed52
--- /dev/null
+++ b/internal/helpers/todo_coverage_more_test.go
@@ -0,0 +1,87 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestTodoPureUtilityCoverage(t *testing.T) {
+ cmd := &cobra.Command{Use: "todo"}
+ cmd.Flags().String("remind-at", "", "")
+ _ = rejectUnsupportedTodoReminderFlags(cmd)
+ _ = cmd.Flags().Set("remind-at", "2026-01-02")
+ _ = rejectUnsupportedTodoReminderFlags(cmd)
+ for _, raw := range []string{"", " one, ,two "} {
+ _ = parseExecutorIds(raw)
+ _ = parseIntList(raw)
+ _ = parseStringList(raw)
+ }
+ _ = parseIntList("1,bad,,2")
+ for _, raw := range []string{"", "creator", "creator,executor,participant", "invalid"} {
+ _, _ = parseRoleTypes(raw)
+ }
+ for _, raw := range []string{`{`, `{}`, `{"result":{"dentryId":1,"spaceId":"2"}}`} {
+ _, _, _ = parseTodoFileSendIDs(raw)
+ }
+}
+
+func TestTodoFileUtilityCoverage(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "file.txt")
+ if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, _ = buildTodoLocalFileMeta(filepath.Join(t.TempDir(), "missing"), "", "")
+ _, _ = buildTodoLocalFileMeta(t.TempDir(), "", "")
+ _, _ = buildTodoLocalFileMeta(file, "custom.bin", "provided")
+ _, _ = buildTodoLocalFileMeta(file, "", "")
+ for _, raw := range []string{
+ `{`, `{}`,
+ `{"resourceUrls":["https://upload"],"uploadKey":"key","headers":{"x-test":1,"blank":null},"ossHeaders":{"x-other":"yes"}}`,
+ `{"result":{"resourceUrl":"https://upload","key":"key"}}`,
+ } {
+ _, _, _, _ = parseTodoFileUploadInfo(raw)
+ }
+}
+
+func TestUploadTodoLocalFileCoverage(t *testing.T) {
+ oldPut := httpPutFile
+ t.Cleanup(func() { httpPutFile = oldPut })
+ meta := todoLocalFileMeta{LocalPath: "file", FileName: "file.txt", FileSize: 4, MD5: "md5"}
+ for _, tc := range []struct {
+ name string
+ steps []scriptedToolStep
+ put error
+ }{
+ {"success", []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {text: `{"ok":true}`}}, nil},
+ {"init-error", []scriptedToolStep{{err: errors.New("init")}}, nil},
+ {"parse-error", []scriptedToolStep{{text: `{}`}}, nil},
+ {"put-error", []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}}, errors.New("put")},
+ {"commit-error", []scriptedToolStep{{text: `{"resourceUrl":"https://upload","uploadKey":"key"}`}, {err: errors.New("commit")}}, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &scriptedToolCaller{steps: tc.steps}
+ installScriptedCaller(t, caller)
+ httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return tc.put }
+ _, _ = uploadTodoLocalFile(context.Background(), meta)
+ })
+ }
+}
+
+func TestTodoAutoPageStopsAtWantedSize(t *testing.T) {
+ oldArgs := os.Args
+ os.Args = []string{"dws", "todo"}
+ t.Cleanup(func() { os.Args = oldArgs })
+ root := newTodoCommand()
+ cmd, _, err := root.Find([]string{"task", "list"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"result":{"todoCards":[{"id":1},{"id":2}]}}`}}}
+ installScriptedCaller(t, caller)
+ _ = todoListAutoPage(cmd, "1", 1)
+}
diff --git a/internal/helpers/zero_coverage_more_test.go b/internal/helpers/zero_coverage_more_test.go
new file mode 100644
index 00000000..44d1da45
--- /dev/null
+++ b/internal/helpers/zero_coverage_more_test.go
@@ -0,0 +1,58 @@
+package helpers
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestLookupOpenDingTalkIDsByAisearchPersonCoverage(t *testing.T) {
+ caller := &helpersCoreCaller{result: textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"open1","name":"Alice"}]}`)}
+ installHelpersCoreDeps(t, caller)
+ openIDs := map[string]string{}
+ names := map[string]string{}
+ if err := lookupOpenDingTalkIDsByAisearchPerson(context.Background(), "Alice", openIDs, names); err != nil {
+ t.Fatal(err)
+ }
+ if openIDs["u1"] != "open1" || names["u1"] != "Alice" {
+ t.Fatalf("person mappings = %#v / %#v", openIDs, names)
+ }
+ caller.result = textToolResult(`{`)
+ if err := lookupOpenDingTalkIDsByAisearchPerson(context.Background(), "Alice", openIDs, names); err == nil {
+ t.Fatal("invalid person response succeeded")
+ }
+ caller.err = errors.New("search failed")
+ if err := lookupOpenDingTalkIDsByAisearchPerson(context.Background(), "Alice", openIDs, names); err == nil {
+ t.Fatal("person search error was ignored")
+ }
+}
+
+func TestOpencodeWaitHealthyCoverage(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != opencodeServerHealthPath {
+ t.Fatalf("health path = %q", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"healthy":true}`))
+ }))
+ defer server.Close()
+ client := &opencodeHTTPClient{baseURL: server.URL, httpClient: server.Client()}
+ if err := (&opencodeServer{}).waitHealthy(context.Background(), client); err != nil {
+ t.Fatalf("healthy server: %v", err)
+ }
+
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := (&opencodeServer{}).waitHealthy(cancelled, client); !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancelled wait = %v", err)
+ }
+ for _, doneErr := range []error{errors.New("exited"), nil} {
+ done := make(chan error, 1)
+ done <- doneErr
+ bad := &opencodeHTTPClient{baseURL: "http://127.0.0.1:1", httpClient: http.DefaultClient}
+ if err := (&opencodeServer{done: done}).waitHealthy(context.Background(), bad); err == nil {
+ t.Fatal("exited server wait succeeded")
+ }
+ }
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index e93dadeb..4f3963dd 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -147,6 +147,10 @@ func loadCatalog(lang string) map[string]string {
if err != nil {
return map[string]string{}
}
+ return parseCatalog(data)
+}
+
+func parseCatalog(data []byte) map[string]string {
var catalog map[string]string
if err := json.Unmarshal(data, &catalog); err != nil {
return map[string]string{}
diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go
new file mode 100644
index 00000000..b5cca61d
--- /dev/null
+++ b/internal/i18n/i18n_test.go
@@ -0,0 +1,77 @@
+package i18n
+
+import (
+ "strings"
+ "testing"
+
+ "golang.org/x/text/language"
+)
+
+func TestLanguageSelectionAndTranslation(t *testing.T) {
+ Init()
+ SetLang(" zh_CN.UTF-8 ")
+ if Lang() != "zh" || LangTag() != language.Chinese {
+ t.Fatalf("Chinese language = %q, %v", Lang(), LangTag())
+ }
+ var knownKey string
+ for key := range zhCatalog {
+ knownKey = key
+ break
+ }
+ if knownKey == "" || T(knownKey) != zhCatalog[knownKey] {
+ t.Fatalf("known Chinese translation was not resolved: %q", knownKey)
+ }
+
+ SetLang("EN-us")
+ if Lang() != "en" || LangTag() != language.English {
+ t.Fatalf("English language = %q, %v", Lang(), LangTag())
+ }
+ if T("missing.translation.key") != "missing.translation.key" {
+ t.Fatal("missing translation did not fall back to its key")
+ }
+
+ const fallbackKey = "test.english.fallback"
+ oldEnglish, hadEnglish := enCatalog[fallbackKey]
+ oldChinese, hadChinese := zhCatalog[fallbackKey]
+ enCatalog[fallbackKey] = "Hello %s"
+ delete(zhCatalog, fallbackKey)
+ t.Cleanup(func() {
+ if hadEnglish {
+ enCatalog[fallbackKey] = oldEnglish
+ } else {
+ delete(enCatalog, fallbackKey)
+ }
+ if hadChinese {
+ zhCatalog[fallbackKey] = oldChinese
+ }
+ })
+ SetLang("zh")
+ if got := Tf(fallbackKey, "Codex"); got != "Hello Codex" {
+ t.Fatalf("Tf() fallback = %q", got)
+ }
+ if catalogForLang("zh") != nil && len(catalogForLang("zh")) == 0 {
+ t.Fatal("Chinese catalog is empty")
+ }
+ if len(catalogForLang("unsupported")) == 0 {
+ t.Fatal("unsupported language did not use English catalog")
+ }
+}
+
+func TestLoadCatalogHandlesValidAndInvalidResources(t *testing.T) {
+ if catalog := loadCatalog("en"); len(catalog) == 0 {
+ t.Fatal("loadCatalog(en) returned an empty catalog")
+ }
+ if catalog := loadCatalog("missing"); len(catalog) != 0 {
+ t.Fatalf("loadCatalog(missing) = %#v", catalog)
+ }
+ if catalog := parseCatalog([]byte(`{`)); len(catalog) != 0 {
+ t.Fatalf("parseCatalog(invalid) = %#v", catalog)
+ }
+ if catalog := parseCatalog([]byte(`{"key":"value"}`)); catalog["key"] != "value" {
+ t.Fatalf("parseCatalog(valid) = %#v", catalog)
+ }
+ SetLang(strings.Repeat(" ", 2))
+ if Lang() != "en" {
+ t.Fatalf("blank language = %q", Lang())
+ }
+}
diff --git a/internal/jsonutil/json_test.go b/internal/jsonutil/json_test.go
new file mode 100644
index 00000000..4bc3fbeb
--- /dev/null
+++ b/internal/jsonutil/json_test.go
@@ -0,0 +1,34 @@
+package jsonutil
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMarshalPreservesHTMLCharacters(t *testing.T) {
+ got, err := Marshal(map[string]string{"url": "https://example.test/?a=1&b="})
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+ if strings.Contains(string(got), `\u0026`) || !strings.Contains(string(got), `&b=`) {
+ t.Fatalf("Marshal() = %s", got)
+ }
+
+ got, err = MarshalIndent(map[string]any{"nested": map[string]string{"value": "a&b"}}, "prefix:", " ")
+ if err != nil {
+ t.Fatalf("MarshalIndent() error = %v", err)
+ }
+ if !strings.Contains(string(got), "\n") || strings.Contains(string(got), `\u0026`) {
+ t.Fatalf("MarshalIndent() = %s", got)
+ }
+}
+
+func TestMarshalReturnsEncoderErrors(t *testing.T) {
+ unsupported := make(chan int)
+ if _, err := Marshal(unsupported); err == nil {
+ t.Fatal("Marshal(channel) error = nil")
+ }
+ if _, err := MarshalIndent(unsupported, "", " "); err == nil {
+ t.Fatal("MarshalIndent(channel) error = nil")
+ }
+}
diff --git a/internal/keychain/coverage_edges_test.go b/internal/keychain/coverage_edges_test.go
new file mode 100644
index 00000000..5b73a86e
--- /dev/null
+++ b/internal/keychain/coverage_edges_test.go
@@ -0,0 +1,489 @@
+//go:build darwin
+
+package keychain
+
+import (
+ "encoding/base64"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/zalando/go-keyring"
+)
+
+var errKeychainInjected = errors.New("injected keychain failure")
+
+func TestUnavailableErrorEdges(t *testing.T) {
+ var nilErr *UnavailableError
+ if nilErr.Error() != "" || nilErr.Unwrap() != nil {
+ t.Fatal("nil unavailable error should be empty")
+ }
+ for _, tc := range []struct {
+ err *UnavailableError
+ want string
+ }{
+ {&UnavailableError{}, "keychain unavailable"},
+ {&UnavailableError{Err: errKeychainInjected}, errKeychainInjected.Error()},
+ {&UnavailableError{Op: "read"}, "read: keychain unavailable"},
+ {&UnavailableError{Op: "read", Err: errKeychainInjected}, "read: injected keychain failure"},
+ } {
+ if got := tc.err.Error(); got != tc.want {
+ t.Fatalf("Error() = %q, want %q", got, tc.want)
+ }
+ _ = tc.err.Unwrap()
+ }
+ _ = Diagnose()
+}
+
+func TestFileDEKFailureEdges(t *testing.T) {
+ origRead := keychainReadFile
+ origMkdir := keychainMkdirAll
+ origWrite := keychainWriteFile
+ origRename := keychainRename
+ origRemove := keychainRemove
+ origRand := keychainRandRead
+ t.Cleanup(func() {
+ keychainReadFile = origRead
+ keychainMkdirAll = origMkdir
+ keychainWriteFile = origWrite
+ keychainRename = origRename
+ keychainRemove = origRemove
+ keychainRandRead = origRand
+ })
+ t.Setenv(StorageDirEnv, t.TempDir())
+
+ valid := bytesOf(7, dekBytes)
+ keychainReadFile = func(string) ([]byte, error) { return valid, nil }
+ if got, err := fileDEK("existing"); err != nil || len(got) != dekBytes {
+ t.Fatalf("existing fileDEK = %d, %v", len(got), err)
+ }
+
+ keychainReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
+ keychainMkdirAll = func(string, os.FileMode) error { return errKeychainInjected }
+ if _, err := fileDEK("mkdir"); err == nil {
+ t.Fatal("mkdir error expected")
+ }
+ keychainMkdirAll = func(string, os.FileMode) error { return nil }
+ keychainRandRead = func([]byte) (int, error) { return 0, errKeychainInjected }
+ if _, err := fileDEK("rand"); err == nil {
+ t.Fatal("rand error expected")
+ }
+ keychainRandRead = origRand
+ keychainWriteFile = func(string, []byte, os.FileMode) error { return errKeychainInjected }
+ if _, err := fileDEK("write"); err == nil {
+ t.Fatal("write error expected")
+ }
+ keychainWriteFile = func(string, []byte, os.FileMode) error { return nil }
+ keychainRename = func(string, string) error { return errKeychainInjected }
+ reads := 0
+ keychainReadFile = func(string) ([]byte, error) {
+ reads++
+ if reads == 1 {
+ return nil, os.ErrNotExist
+ }
+ return valid, nil
+ }
+ if got, err := fileDEK("rename-race"); err != nil || len(got) != dekBytes {
+ t.Fatalf("rename race = %d, %v", len(got), err)
+ }
+ reads = 0
+ keychainReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
+ if _, err := fileDEK("rename"); err == nil {
+ t.Fatal("rename error expected")
+ }
+
+ keychainReadFile = func(string) ([]byte, error) { return valid, nil }
+ if got, err := fileDEKReadOnly("valid"); err != nil || len(got) != dekBytes {
+ t.Fatalf("read-only valid = %d, %v", len(got), err)
+ }
+ keychainReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
+ if _, err := fileDEKReadOnly("missing"); !IsDEKMissing(err) {
+ t.Fatalf("missing error = %v", err)
+ }
+ keychainReadFile = func(string) ([]byte, error) { return nil, errKeychainInjected }
+ if _, err := fileDEKReadOnly("read"); err == nil {
+ t.Fatal("read error expected")
+ }
+ keychainReadFile = func(string) ([]byte, error) { return []byte("short"), nil }
+ if _, err := fileDEKReadOnly("short"); !IsDEKMissing(err) {
+ t.Fatalf("short error = %v", err)
+ }
+}
+
+func TestDarwinStorageAndDiagnosticEdges(t *testing.T) {
+ t.Setenv(DisableKeychainEnv, "")
+ origHome := keychainUserHomeDir
+ origReadDefault := readDefaultKeychain
+ origStat := keychainStat
+ t.Cleanup(func() {
+ keychainUserHomeDir = origHome
+ readDefaultKeychain = origReadDefault
+ keychainStat = origStat
+ })
+ t.Setenv(StorageDirEnv, "")
+ _, _ = origReadDefault()
+ keychainUserHomeDir = func() (string, error) { return "/home/test", nil }
+ if got := StorageDir("svc"); !strings.Contains(got, "Application Support") {
+ t.Fatalf("StorageDir = %q", got)
+ }
+ keychainUserHomeDir = func() (string, error) { return "", errKeychainInjected }
+ if got := StorageDir("svc"); got != filepath.Join(".dws", "keychain", "svc") {
+ t.Fatalf("fallback StorageDir = %q", got)
+ }
+ if safeFileName("a/b:c") != "a_b_c.enc" {
+ t.Fatal("safe file name mismatch")
+ }
+ for input, want := range map[string]string{
+ "": "",
+ "/plain/path": "/plain/path",
+ `"/quoted/path"`: "/quoted/path",
+ `"invalid\xquote"`: `invalid\xquote`,
+ } {
+ if got := defaultKeychainPathFromSecurityOutput([]byte(input)); got != want {
+ t.Fatalf("path(%q) = %q, want %q", input, got, want)
+ }
+ }
+
+ readDefaultKeychain = func() ([]byte, error) { return nil, errKeychainInjected }
+ if err := checkDefaultKeychainAvailable(); err != nil {
+ t.Fatalf("security command failure is tolerated: %v", err)
+ }
+ d := platformDiagnose()
+ if d.OK || d.Reason != "keychain_check_failed" {
+ t.Fatalf("diagnostic = %#v", d)
+ }
+ readDefaultKeychain = func() ([]byte, error) { return nil, nil }
+ if err := checkDefaultKeychainAvailable(); err != nil {
+ t.Fatal(err)
+ }
+ d = platformDiagnose()
+ if d.OK || d.Reason != "keychain_unavailable" {
+ t.Fatalf("empty diagnostic = %#v", d)
+ }
+ readDefaultKeychain = func() ([]byte, error) { return []byte(`"/keychain"`), nil }
+ keychainStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
+ if !IsUnavailable(checkDefaultKeychainAvailable()) {
+ t.Fatal("missing keychain should be unavailable")
+ }
+ d = platformDiagnose()
+ if d.OK || d.Reason != "keychain_unavailable" {
+ t.Fatalf("missing diagnostic = %#v", d)
+ }
+ keychainStat = func(string) (os.FileInfo, error) { return nil, errKeychainInjected }
+ d = platformDiagnose()
+ if d.OK || d.Reason != "keychain_check_failed" {
+ t.Fatalf("stat diagnostic = %#v", d)
+ }
+ keychainStat = func(string) (os.FileInfo, error) { return nil, nil }
+ if err := checkDefaultKeychainAvailable(); err != nil {
+ t.Fatal(err)
+ }
+ d = platformDiagnose()
+ if !d.OK {
+ t.Fatalf("healthy diagnostic = %#v", d)
+ }
+ t.Setenv(DisableKeychainEnv, "1")
+ d = platformDiagnose()
+ if !d.OK || d.Detail["mode"] != "file_dek" {
+ t.Fatalf("file diagnostic = %#v", d)
+ }
+}
+
+func TestDarwinDEKKeyringEdges(t *testing.T) {
+ origReadDefault := readDefaultKeychain
+ origStat := keychainStat
+ origGet := keyringGet
+ origSet := keyringSet
+ origRand := keychainRandRead
+ origTimeout := keychainTimeout
+ t.Cleanup(func() {
+ readDefaultKeychain = origReadDefault
+ keychainStat = origStat
+ keyringGet = origGet
+ keyringSet = origSet
+ keychainRandRead = origRand
+ keychainTimeout = origTimeout
+ })
+ t.Setenv(DisableKeychainEnv, "")
+ readDefaultKeychain = func() ([]byte, error) { return []byte(`"/keychain"`), nil }
+ keychainStat = func(string) (os.FileInfo, error) { return nil, nil }
+ valid := bytesOf(3, dekBytes)
+ encoded := base64.StdEncoding.EncodeToString(valid)
+
+ keyringGet = func(string, string) (string, error) { return encoded, nil }
+ if got, err := getDEKReadOnly("valid"); err != nil || len(got) != dekBytes {
+ t.Fatalf("read valid = %d, %v", len(got), err)
+ }
+ if got, err := getOrCreateDEK("valid"); err != nil || len(got) != dekBytes {
+ t.Fatalf("create valid = %d, %v", len(got), err)
+ }
+ for _, value := range []string{"%%%", base64.StdEncoding.EncodeToString([]byte("short"))} {
+ keyringGet = func(string, string) (string, error) { return value, nil }
+ if _, err := getDEKReadOnly("invalid"); !IsDEKMissing(err) {
+ t.Fatalf("invalid read error = %v", err)
+ }
+ }
+ keyringGet = func(string, string) (string, error) { return "", keyring.ErrNotFound }
+ if _, err := getDEKReadOnly("missing"); !IsDEKMissing(err) {
+ t.Fatalf("missing read error = %v", err)
+ }
+ keyringGet = func(string, string) (string, error) { return "", errKeychainInjected }
+ if _, err := getDEKReadOnly("unavailable"); !IsUnavailable(err) {
+ t.Fatalf("unavailable read error = %v", err)
+ }
+ if _, err := getOrCreateDEK("unavailable"); !IsUnavailable(err) {
+ t.Fatalf("unavailable create error = %v", err)
+ }
+
+ setValue := ""
+ keyringGet = func(string, string) (string, error) { return "invalid", nil }
+ keyringSet = func(_, _, value string) error { setValue = value; return nil }
+ if got, err := getOrCreateDEK("generate-invalid"); err != nil || len(got) != dekBytes || setValue == "" {
+ t.Fatalf("generate invalid = %d, %v, %q", len(got), err, setValue)
+ }
+ keyringGet = func(string, string) (string, error) { return "", keyring.ErrNotFound }
+ if got, err := getOrCreateDEK("generate-missing"); err != nil || len(got) != dekBytes {
+ t.Fatalf("generate missing = %d, %v", len(got), err)
+ }
+ keychainRandRead = func([]byte) (int, error) { return 0, errKeychainInjected }
+ if _, err := getOrCreateDEK("rand"); err == nil {
+ t.Fatal("rand error expected")
+ }
+ keychainRandRead = origRand
+ keyringSet = func(string, string, string) error { return errKeychainInjected }
+ if _, err := getOrCreateDEK("set"); !IsUnavailable(err) {
+ t.Fatalf("set error = %v", err)
+ }
+
+ keychainTimeout = time.Millisecond
+ keyringGet = func(string, string) (string, error) { select {} }
+ if _, err := getDEKReadOnly("timeout"); !IsUnavailable(err) {
+ t.Fatalf("read timeout = %v", err)
+ }
+ if _, err := getOrCreateDEK("timeout"); !IsUnavailable(err) {
+ t.Fatalf("create timeout = %v", err)
+ }
+ keyringGet = func(string, string) (string, error) { panic("keyring panic") }
+ if _, err := getDEKReadOnly("panic"); !IsUnavailable(err) {
+ t.Fatalf("read panic = %v", err)
+ }
+ if _, err := getOrCreateDEK("panic"); !IsUnavailable(err) {
+ t.Fatalf("create panic = %v", err)
+ }
+
+ keychainStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
+ if _, err := getDEKReadOnly("missing-keychain"); !IsUnavailable(err) {
+ t.Fatalf("preflight read = %v", err)
+ }
+ if _, err := getOrCreateDEK("missing-keychain"); !IsUnavailable(err) {
+ t.Fatalf("preflight create = %v", err)
+ }
+}
+
+func TestDarwinCryptoAndPlatformFailureEdges(t *testing.T) {
+ origRead := keychainReadFile
+ origMkdir := keychainMkdirAll
+ origWrite := keychainWriteFile
+ origRename := keychainRename
+ origRemove := keychainRemove
+ origRand := keychainRandRead
+ t.Cleanup(func() {
+ keychainReadFile = origRead
+ keychainMkdirAll = origMkdir
+ keychainWriteFile = origWrite
+ keychainRename = origRename
+ keychainRemove = origRemove
+ keychainRandRead = origRand
+ })
+ t.Setenv(StorageDirEnv, t.TempDir())
+ t.Setenv(DisableKeychainEnv, "1")
+ if _, err := encryptData("x", []byte("short")); err == nil {
+ t.Fatal("invalid encryption key expected")
+ }
+ keychainRandRead = func([]byte) (int, error) { return 0, errKeychainInjected }
+ if _, err := encryptData("x", bytesOf(1, dekBytes)); err == nil {
+ t.Fatal("encryption random error expected")
+ }
+ keychainRandRead = origRand
+ key := bytesOf(2, dekBytes)
+ ciphertext, err := encryptData("secret", key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if plaintext, err := decryptData(ciphertext, key); err != nil || plaintext != "secret" {
+ t.Fatalf("decrypt = %q, %v", plaintext, err)
+ }
+ if _, err := decryptData([]byte("short"), key); err == nil {
+ t.Fatal("short ciphertext error expected")
+ }
+ if _, err := decryptData(ciphertext, []byte("short")); err == nil {
+ t.Fatal("invalid decryption key expected")
+ }
+ if _, err := decryptData(ciphertext, bytesOf(9, dekBytes)); err == nil {
+ t.Fatal("authentication error expected")
+ }
+
+ keychainReadFile = func(string) ([]byte, error) { return nil, errKeychainInjected }
+ if _, err := platformGet("svc", "account"); err == nil {
+ t.Fatal("read error expected")
+ }
+ keychainReadFile = func(path string) ([]byte, error) {
+ if strings.HasSuffix(path, ".enc") {
+ return []byte("bad ciphertext"), nil
+ }
+ return nil, os.ErrNotExist
+ }
+ if _, err := platformGet("svc", "account"); !IsDEKMissing(err) {
+ t.Fatalf("DEK read error = %v", err)
+ }
+ keychainReadFile = func(path string) ([]byte, error) {
+ if strings.HasSuffix(path, ".enc") {
+ return []byte("bad ciphertext"), nil
+ }
+ return bytesOf(1, dekBytes), nil
+ }
+ if _, err := platformGet("svc", "account"); err == nil {
+ t.Fatal("decrypt error expected")
+ }
+
+ keychainReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
+ keychainMkdirAll = func(string, os.FileMode) error { return errKeychainInjected }
+ if err := platformSet("dek-error", "a", "v"); err == nil {
+ t.Fatal("DEK error expected")
+ }
+ keychainMkdirAll = origMkdir
+ if got, err := getDEK("direct"); err != nil || len(got) != dekBytes {
+ t.Fatalf("getDEK = %d, %v", len(got), err)
+ }
+ keychainReadFile = func(string) ([]byte, error) { return bytesOf(1, dekBytes), nil }
+ keychainMkdirAll = func(string, os.FileMode) error { return errKeychainInjected }
+ if err := platformSet("mkdir", "a", "v"); err == nil {
+ t.Fatal("mkdir error expected")
+ }
+ keychainMkdirAll = func(string, os.FileMode) error { return nil }
+ keychainRandRead = func(p []byte) (int, error) {
+ if len(p) == dekBytes {
+ for i := range p {
+ p[i] = 1
+ }
+ return len(p), nil
+ }
+ return 0, errKeychainInjected
+ }
+ if err := platformSet("encrypt", "a", "v"); err == nil {
+ t.Fatal("encrypt error expected")
+ }
+ keychainRandRead = origRand
+ keychainWriteFile = func(string, []byte, os.FileMode) error { return errKeychainInjected }
+ if err := platformSet("write", "a", "v"); err == nil {
+ t.Fatal("write error expected")
+ }
+ keychainWriteFile = func(string, []byte, os.FileMode) error { return nil }
+ keychainRename = func(string, string) error { return errKeychainInjected }
+ if err := platformSet("rename", "a", "v"); err == nil {
+ t.Fatal("rename error expected")
+ }
+ keychainRemove = func(string) error { return errKeychainInjected }
+ if err := platformRemove("remove", "a"); err == nil {
+ t.Fatal("remove error expected")
+ }
+}
+
+func TestMigrationEdges(t *testing.T) {
+ origMAC := migrateGetMACAddress
+ origDecrypt := migrateDecrypt
+ origExists := migrateExists
+ origSet := migrateSet
+ origRead := keychainReadFile
+ origStat := keychainStat
+ origRename := keychainRename
+ origRemove := keychainRemove
+ t.Cleanup(func() {
+ migrateGetMACAddress = origMAC
+ migrateDecrypt = origDecrypt
+ migrateExists = origExists
+ migrateSet = origSet
+ keychainReadFile = origRead
+ keychainStat = origStat
+ keychainRename = origRename
+ keychainRemove = origRemove
+ })
+
+ migrateExists = func(string, string) bool { return true }
+ if got := MigrateFromLegacy(t.TempDir()); got.Migrated || got.Error != nil {
+ t.Fatalf("already migrated = %#v", got)
+ }
+ migrateExists = func(string, string) bool { return false }
+ keychainStat = func(string) (os.FileInfo, error) { return nil, errKeychainInjected }
+ migrateGetMACAddress = func() (string, error) { return "", errKeychainInjected }
+ got := MigrateFromLegacy(t.TempDir())
+ if !got.NeedRelogin || got.Error == nil || got.FromPath == "" {
+ t.Fatalf("MAC failure = %#v", got)
+ }
+ if _, err := loadLegacyData(t.TempDir()); err == nil {
+ t.Fatal("MAC failure expected")
+ }
+
+ migrateGetMACAddress = func() (string, error) { return "mac", nil }
+ keychainReadFile = func(string) ([]byte, error) { return nil, errKeychainInjected }
+ if _, err := loadLegacyData(t.TempDir()); err == nil {
+ t.Fatal("read failure expected")
+ }
+ keychainReadFile = func(string) ([]byte, error) { return []byte("cipher"), nil }
+ migrateDecrypt = func([]byte, []byte) ([]byte, error) { return nil, errKeychainInjected }
+ if _, err := loadLegacyData(t.TempDir()); err == nil {
+ t.Fatal("decrypt failure expected")
+ }
+ migrateDecrypt = func([]byte, []byte) ([]byte, error) { return []byte("not-json"), nil }
+ if _, err := loadLegacyData(t.TempDir()); err == nil {
+ t.Fatal("JSON failure expected")
+ }
+ migrateDecrypt = func([]byte, []byte) ([]byte, error) { return []byte(`{"token":"ok"}`), nil }
+ data, err := loadLegacyData(t.TempDir())
+ if err != nil || data["token"] != "ok" {
+ t.Fatalf("legacy data = %#v, %v", data, err)
+ }
+
+ keychainStat = func(string) (os.FileInfo, error) { return nil, nil }
+ migrateSet = func(string, string, string) error { return errKeychainInjected }
+ got = MigrateFromLegacy(t.TempDir())
+ if got.Error == nil || got.NeedRelogin {
+ t.Fatalf("set failure = %#v", got)
+ }
+ migrateSet = func(string, string, string) error { return nil }
+ keychainRename = func(string, string) error { return nil }
+ got = MigrateFromLegacy(t.TempDir())
+ if !got.Migrated || got.BackupPath == "" {
+ t.Fatalf("migration success = %#v", got)
+ }
+ keychainRename = func(string, string) error { return errKeychainInjected }
+ keychainRemove = func(string) error { return nil }
+ got = MigrateFromLegacy(t.TempDir())
+ if !got.Migrated || got.BackupPath != "" {
+ t.Fatalf("rename fallback = %#v", got)
+ }
+
+ keychainRemove = func(string) error { return os.ErrNotExist }
+ if err := CleanupLegacyBackup(t.TempDir()); err != nil {
+ t.Fatal(err)
+ }
+ keychainRemove = func(string) error { return errKeychainInjected }
+ if err := CleanupLegacyBackup(t.TempDir()); err == nil {
+ t.Fatal("cleanup error expected")
+ }
+ keychainRemove = func(string) error { return nil }
+ if err := CleanupLegacyBackup(t.TempDir()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func bytesOf(value byte, n int) []byte {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = value
+ }
+ return b
+}
diff --git a/internal/keychain/file_dek.go b/internal/keychain/file_dek.go
index fefab213..25498e0e 100644
--- a/internal/keychain/file_dek.go
+++ b/internal/keychain/file_dek.go
@@ -24,6 +24,17 @@ import (
"github.com/google/uuid"
)
+var (
+ keychainReadFile = os.ReadFile
+ keychainMkdirAll = os.MkdirAll
+ keychainWriteFile = os.WriteFile
+ keychainRename = os.Rename
+ keychainRemove = os.Remove
+ keychainStat = os.Stat
+ keychainUserHomeDir = os.UserHomeDir
+ keychainRandRead = rand.Read
+)
+
// fileDEK retrieves or generates a Data Encryption Key stored as a plain
// file under the platform storage directory. Shared by Linux (default) and
// the macOS sandbox fallback path (DWS_DISABLE_KEYCHAIN=1).
@@ -31,30 +42,30 @@ func fileDEK(service string) ([]byte, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, "dek")
- key, err := os.ReadFile(keyPath)
+ key, err := keychainReadFile(keyPath)
if err == nil && len(key) == dekBytes {
return key, nil
}
- if err := os.MkdirAll(dir, 0700); err != nil {
+ if err := keychainMkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create keychain dir: %w", err)
}
key = make([]byte, dekBytes)
- if _, err := rand.Read(key); err != nil {
+ if _, err := keychainRandRead(key); err != nil {
return nil, fmt.Errorf("generate dek: %w", err)
}
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
- defer os.Remove(tmpKeyPath)
+ defer keychainRemove(tmpKeyPath)
- if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
+ if err := keychainWriteFile(tmpKeyPath, key, 0600); err != nil {
return nil, fmt.Errorf("write dek: %w", err)
}
- if err := os.Rename(tmpKeyPath, keyPath); err != nil {
+ if err := keychainRename(tmpKeyPath, keyPath); err != nil {
// If rename fails, another process might have created it. Try reading again.
- existingKey, readErr := os.ReadFile(keyPath)
+ existingKey, readErr := keychainReadFile(keyPath)
if readErr == nil && len(existingKey) == dekBytes {
return existingKey, nil
}
@@ -66,7 +77,7 @@ func fileDEK(service string) ([]byte, error) {
func fileDEKReadOnly(service string) ([]byte, error) {
keyPath := filepath.Join(StorageDir(service), "dek")
- key, err := os.ReadFile(keyPath)
+ key, err := keychainReadFile(keyPath)
if err == nil && len(key) == dekBytes {
return key, nil
}
diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go
index a543cd87..cd3584a5 100644
--- a/internal/keychain/keychain_darwin.go
+++ b/internal/keychain/keychain_darwin.go
@@ -19,7 +19,6 @@ import (
"context"
"crypto/aes"
"crypto/cipher"
- "crypto/rand"
"encoding/base64"
"errors"
"fmt"
@@ -36,12 +35,13 @@ import (
)
const (
- keychainTimeout = 5 * time.Second
- dekBytes = 32 // DEK = Data Encryption Key (AES-256)
- ivBytes = 12
- tagBytes = 16
+ dekBytes = 32 // DEK = Data Encryption Key (AES-256)
+ ivBytes = 12
+ tagBytes = 16
)
+var keychainTimeout = 5 * time.Second
+
// StorageDir returns the storage directory for a given service name on macOS.
// Uses ~/Library/Application Support/ following Apple conventions.
// When the DWS_KEYCHAIN_DIR environment variable is set (used by tests for
@@ -50,7 +50,7 @@ func StorageDir(service string) string {
if override := os.Getenv(StorageDirEnv); override != "" {
return filepath.Join(override, service)
}
- home, err := os.UserHomeDir()
+ home, err := keychainUserHomeDir()
if err != nil || home == "" {
return filepath.Join(".dws", "keychain", service)
}
@@ -92,7 +92,7 @@ func checkDefaultKeychainAvailable() error {
if path == "" {
return nil
}
- if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
+ if _, err := keychainStat(path); err != nil && os.IsNotExist(err) {
return NewUnavailableError("read macOS default Keychain", fmt.Errorf("default keychain %q does not exist", path))
}
return nil
@@ -139,7 +139,7 @@ func platformDiagnose() Diagnostic {
Detail: detail,
}
}
- if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
+ if _, err := keychainStat(path); err != nil && os.IsNotExist(err) {
return Diagnostic{
OK: false,
Reason: "keychain_unavailable",
@@ -242,7 +242,7 @@ func getOrCreateDEK(service string) ([]byte, error) {
// Generate new DEK if not found or invalid
key := make([]byte, dekBytes)
- if _, randErr := rand.Read(key); randErr != nil {
+ if _, randErr := keychainRandRead(key); randErr != nil {
resCh <- result{key: nil, err: randErr}
return
}
@@ -269,13 +269,10 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
if err != nil {
return nil, err
}
- aesGCM, err := cipher.NewGCM(block)
- if err != nil {
- return nil, err
- }
+ aesGCM, _ := cipher.NewGCM(block) // AES always supports the standard GCM nonce size.
iv := make([]byte, ivBytes)
- if _, err := rand.Read(iv); err != nil {
+ if _, err := keychainRandRead(iv); err != nil {
return nil, err
}
@@ -294,10 +291,7 @@ func decryptData(data []byte, key []byte) (string, error) {
if err != nil {
return "", err
}
- aesGCM, err := cipher.NewGCM(block)
- if err != nil {
- return "", err
- }
+ aesGCM, _ := cipher.NewGCM(block) // AES always supports the standard GCM nonce size.
iv := data[:ivBytes]
ciphertext := data[ivBytes:]
@@ -374,7 +368,7 @@ func keyForNewEntry(service, account string) ([]byte, error) {
}
func platformGet(service, account string) (string, error) {
- data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
+ data, err := keychainReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
if os.IsNotExist(err) {
return "", nil // Not found is not an error
@@ -408,8 +402,7 @@ func platformSet(service, account, data string) error {
if err != nil {
return err
}
-
- if err := os.MkdirAll(dir, 0700); err != nil {
+ if err := keychainMkdirAll(dir, 0700); err != nil {
return err
}
encrypted, err := encryptData(data, key)
@@ -418,14 +411,14 @@ func platformSet(service, account, data string) error {
}
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
- defer os.Remove(tmpPath)
+ defer keychainRemove(tmpPath)
- if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
+ if err := keychainWriteFile(tmpPath, encrypted, 0600); err != nil {
return err
}
// Atomic rename to prevent file corruption during multi-process writes
- if err := os.Rename(tmpPath, targetPath); err != nil {
+ if err := keychainRename(tmpPath, targetPath); err != nil {
return err
}
return nil
@@ -449,7 +442,7 @@ func platformValidateAuthTokenEntries(service string) error {
}
func platformRemove(service, account string) error {
- err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
+ err := keychainRemove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
return err
}
diff --git a/internal/keychain/migrate.go b/internal/keychain/migrate.go
index e464b416..9fb1b3e6 100644
--- a/internal/keychain/migrate.go
+++ b/internal/keychain/migrate.go
@@ -27,6 +27,13 @@ const (
legacyBackupFile = ".data.migrated.bak"
)
+var (
+ migrateGetMACAddress = security.GetMACAddress
+ migrateDecrypt = security.Decrypt
+ migrateExists = Exists
+ migrateSet = Set
+)
+
// MigrationResult contains the result of a migration attempt.
type MigrationResult struct {
Migrated bool // true if migration was performed
@@ -50,13 +57,13 @@ func MigrateFromLegacy(configDir string) *MigrationResult {
result := &MigrationResult{}
// Check if keychain already has data - skip migration
- if Exists(Service, AccountToken) {
+ if migrateExists(Service, AccountToken) {
return result // Already migrated or fresh install
}
// Check if legacy .data file exists
legacyPath := filepath.Join(configDir, legacyDataFile)
- if _, err := os.Stat(legacyPath); os.IsNotExist(err) {
+ if _, err := keychainStat(legacyPath); os.IsNotExist(err) {
return result // No legacy data to migrate
}
@@ -72,22 +79,18 @@ func MigrateFromLegacy(configDir string) *MigrationResult {
}
// Store in new keychain
- jsonData, err := json.Marshal(legacyData)
- if err != nil {
- result.Error = fmt.Errorf("marshal token data: %w", err)
- return result
- }
+ jsonData, _ := json.Marshal(legacyData)
- if err := Set(Service, AccountToken, string(jsonData)); err != nil {
+ if err := migrateSet(Service, AccountToken, string(jsonData)); err != nil {
result.Error = fmt.Errorf("store in keychain: %w", err)
return result
}
// Backup old file instead of deleting
backupPath := filepath.Join(configDir, legacyBackupFile)
- if err := os.Rename(legacyPath, backupPath); err != nil {
+ if err := keychainRename(legacyPath, backupPath); err != nil {
// Non-fatal - data is already migrated
- _ = os.Remove(legacyPath)
+ _ = keychainRemove(legacyPath)
} else {
result.BackupPath = backupPath
}
@@ -99,20 +102,20 @@ func MigrateFromLegacy(configDir string) *MigrationResult {
// loadLegacyData loads and decrypts the legacy .data file using MAC address.
func loadLegacyData(configDir string) (map[string]interface{}, error) {
// Get MAC address for decryption
- mac, err := security.GetMACAddress()
+ mac, err := migrateGetMACAddress()
if err != nil {
return nil, fmt.Errorf("get MAC address: %w", err)
}
// Read encrypted file
path := filepath.Join(configDir, legacyDataFile)
- ciphertext, err := os.ReadFile(path)
+ ciphertext, err := keychainReadFile(path)
if err != nil {
return nil, fmt.Errorf("read legacy file: %w", err)
}
// Decrypt using legacy method
- plaintext, err := security.Decrypt(ciphertext, []byte(mac))
+ plaintext, err := migrateDecrypt(ciphertext, []byte(mac))
if err != nil {
return nil, fmt.Errorf("decrypt legacy data: %w", err)
}
@@ -130,7 +133,7 @@ func loadLegacyData(configDir string) (map[string]interface{}, error) {
// Call this after confirming the new keychain storage works correctly.
func CleanupLegacyBackup(configDir string) error {
backupPath := filepath.Join(configDir, legacyBackupFile)
- if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
+ if err := keychainRemove(backupPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
@@ -139,6 +142,6 @@ func CleanupLegacyBackup(configDir string) error {
// HasLegacyData checks if legacy .data file exists.
func HasLegacyData(configDir string) bool {
legacyPath := filepath.Join(configDir, legacyDataFile)
- _, err := os.Stat(legacyPath)
+ _, err := keychainStat(legacyPath)
return err == nil
}
diff --git a/internal/logging/coverage_edges_test.go b/internal/logging/coverage_edges_test.go
new file mode 100644
index 00000000..e2ad91a5
--- /dev/null
+++ b/internal/logging/coverage_edges_test.go
@@ -0,0 +1,173 @@
+package logging
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
+)
+
+func TestLoggerFailureAndRecoveryEdges(t *testing.T) {
+ if (*FileLogger)(nil).Writer() != io.Discard || (&FileLogger{}).Writer() != io.Discard {
+ t.Fatal("uninitialized Writer should discard")
+ }
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "direct.log")
+ file, err := NewRotatingFile(logPath)
+ if err != nil {
+ t.Fatalf("NewRotatingFile: %v", err)
+ }
+ if _, err := file.Write([]byte("entry")); err != nil {
+ t.Fatalf("write rotating file: %v", err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatalf("close rotating file: %v", err)
+ }
+ configured := Setup(t.TempDir())
+ if configured.Writer() == io.Discard {
+ t.Fatal("configured Writer should expose rotating writer")
+ }
+ _ = configured.Close()
+ if _, err := NewRotatingFile(filepath.Join(dir, "missing", "file")); err == nil {
+ t.Fatal("rotating file in missing directory should fail")
+ }
+
+ blocked := filepath.Join(t.TempDir(), logSubDir, logFileName)
+ if err := os.MkdirAll(blocked, config.DirPerm); err != nil {
+ t.Fatalf("create blocking directory: %v", err)
+ }
+ nop := Setup(filepath.Dir(filepath.Dir(blocked)))
+ if nop.writer != nil {
+ t.Fatal("Setup with directory at log path should return no-op logger")
+ }
+
+ oldOpen := openLogFile
+ t.Cleanup(func() { openLogFile = oldOpen })
+ wantErr := errors.New("open failed")
+ openLogFile = func(string, int, os.FileMode) (*os.File, error) { return nil, wantErr }
+ w := newRotatingWriter(logPath, 1, 1)
+ if _, err := w.Write([]byte("x")); !errors.Is(err, wantErr) {
+ t.Fatalf("reopen error = %v", err)
+ }
+
+ closedFile := func() *os.File {
+ f, err := os.CreateTemp(t.TempDir(), "closed")
+ if err != nil {
+ t.Fatalf("create temp file: %v", err)
+ }
+ _ = f.Close()
+ return f
+ }
+ openLogFile = func(string, int, os.FileMode) (*os.File, error) { return closedFile(), nil }
+ if err := newRotatingWriter(logPath, 1, 1).open(); err == nil {
+ t.Fatal("open should report stat failure")
+ }
+ w = newRotatingWriter(logPath, 1, 1)
+ if err := w.reopenLocked(); err == nil {
+ t.Fatal("reopen should report stat failure")
+ }
+
+ openLogFile = oldOpen
+ w = newRotatingWriter(logPath, 1, 1)
+ if err := w.open(); err != nil {
+ t.Fatalf("open before rotation failure: %v", err)
+ }
+ openLogFile = func(string, int, os.FileMode) (*os.File, error) { return nil, wantErr }
+ if _, err := w.Write([]byte("oversized")); !errors.Is(err, os.ErrClosed) {
+ t.Fatalf("rotation reopen error = %v", err)
+ }
+
+ openLogFile = oldOpen
+ w = newRotatingWriter(filepath.Join(t.TempDir(), "reopened.log"), 100, 1)
+ if _, err := w.Write([]byte("reopened")); err != nil {
+ t.Fatalf("successful reopen write: %v", err)
+ }
+ _ = w.close()
+
+ w = newRotatingWriter(filepath.Join(t.TempDir(), "closed-write.log"), 100, 1)
+ if err := w.open(); err != nil {
+ t.Fatalf("open for closed write: %v", err)
+ }
+ _ = w.file.Close()
+ if _, err := w.Write([]byte("x")); err == nil {
+ t.Fatal("write to externally closed file should fail")
+ }
+ if err := w.close(); err == nil {
+ t.Fatal("close of externally closed file should fail")
+ }
+ if err := w.close(); err != nil {
+ t.Fatalf("second close = %v", err)
+ }
+}
+
+func TestLoggingTransportOptionalAndNilBranches(t *testing.T) {
+ LogRequest(nil, "", "", "", 0)
+ LogRequestBody(nil, "tools/call", "", "", nil)
+ LogResponse(nil, "", "", "", 0, 0, 0, nil)
+ LogResponseBody(nil, "", "", 0, nil, "")
+ LogRetryAttempt(nil, "", "", 0, 0, 0, 0, nil)
+ LogErrorClassified(nil, "", "", "", "", 0, 0, false, "")
+ LogCommandStart(nil, "", "", "", "", "", false, 0)
+ LogCommandEnd(nil, "", "", "", true, 0, "", "")
+
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ LogResponseBody(logger, "method", "exec", 200, []byte("ok"), "")
+ LogRetryAttempt(logger, "method", "exec", 1, 2, 503, time.Second, nil)
+ LogErrorClassified(logger, "method", "exec", "category", "reason", 0, 0, false, "")
+ LogErrorClassified(logger, "method", "exec", "category", "reason", 418, -32000, false, "trace")
+ LogCommandStart(logger, "exec", "product", "tool", "https://example.test?q=secret", "v1", true, 0)
+ LogCommandStart(logger, "exec", "product", "tool", "https://example.test", "v1", true, 30)
+ LogCommandEnd(logger, "exec", "product", "tool", true, time.Second, "", "")
+ LogCommandEnd(logger, "exec", "product", "tool", false, time.Second, "category", "reason")
+ if !strings.Contains(buf.String(), "command_start") || !strings.Contains(buf.String(), "command_end") {
+ t.Fatalf("optional log records = %q", buf.String())
+ }
+}
+
+func TestRedactionAndMultiHandlerEdges(t *testing.T) {
+ if got := TruncateBody([]byte{0xff, 0xfe, 'a'}, 2); !strings.Contains(got, "truncated") {
+ t.Fatalf("invalid UTF-8 truncation = %q", got)
+ }
+ if got := SanitizeArguments(map[string]any{"unsupported": make(chan int)}, 100); got != "{}" {
+ t.Fatalf("marshal failure sanitization = %q", got)
+ }
+ if RedactHeaders(http.Header{}) != nil {
+ t.Fatal("empty headers should return nil")
+ }
+
+ wantErr := errors.New("handler failure")
+ h := &failingLogHandler{err: wantErr, enabled: true}
+ multi := NewMultiHandler(&failingLogHandler{}, h)
+ if err := multi.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, "message", 0)); !errors.Is(err, wantErr) {
+ t.Fatalf("multi handler error = %v", err)
+ }
+ if !multi.Enabled(context.Background(), slog.LevelInfo) {
+ t.Fatal("multi handler should be enabled")
+ }
+ if NewMultiHandler(&failingLogHandler{}).Enabled(context.Background(), slog.LevelInfo) {
+ t.Fatal("disabled multi handler should not be enabled")
+ }
+}
+
+type failingLogHandler struct {
+ err error
+ enabled bool
+}
+
+func (h *failingLogHandler) Enabled(context.Context, slog.Level) bool { return h.enabled }
+func (h *failingLogHandler) Handle(context.Context, slog.Record) error {
+ return h.err
+}
+func (h *failingLogHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
+func (h *failingLogHandler) WithGroup(string) slog.Handler { return h }
diff --git a/internal/logging/logger.go b/internal/logging/logger.go
index eef07b25..b9dad102 100644
--- a/internal/logging/logger.go
+++ b/internal/logging/logger.go
@@ -38,6 +38,8 @@ const (
maxBackups = 2
)
+var openLogFile = os.OpenFile
+
// FileLogger wraps a rotatingWriter and provides a structured slog.Logger
// that writes JSON lines to disk.
type FileLogger struct {
@@ -134,7 +136,7 @@ func (w *rotatingWriter) open() error {
w.mu.Lock()
defer w.mu.Unlock()
- f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
+ f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
}
@@ -175,7 +177,7 @@ func (w *rotatingWriter) Write(p []byte) (int, error) {
// reopenLocked tries to reopen the log file. Caller must hold w.mu.
func (w *rotatingWriter) reopenLocked() error {
- f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
+ f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
}
@@ -219,7 +221,7 @@ func (w *rotatingWriter) rotate() {
os.Rename(src, dst)
}
- f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, config.FilePerm)
+ f, err := openLogFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, config.FilePerm)
if err != nil {
return
}
diff --git a/internal/output/coverage_edges_test.go b/internal/output/coverage_edges_test.go
new file mode 100644
index 00000000..120923a7
--- /dev/null
+++ b/internal/output/coverage_edges_test.go
@@ -0,0 +1,367 @@
+package output
+
+import (
+ "bytes"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+var errOutputInjected = errors.New("injected output failure")
+
+type failNthWriter struct {
+ nth int
+ calls int
+}
+
+func (w *failNthWriter) Write(p []byte) (int, error) {
+ w.calls++
+ if w.calls >= w.nth {
+ return 0, errOutputInjected
+ }
+ return len(p), nil
+}
+
+func TestOutputJSONFailureInjection(t *testing.T) {
+ origMarshal := marshalJSON
+ origUnmarshal := unmarshalJSON
+ origOutput := marshalJSONOutput
+ origIndent := marshalJSONIndent
+ t.Cleanup(func() {
+ marshalJSON = origMarshal
+ unmarshalJSON = origUnmarshal
+ marshalJSONOutput = origOutput
+ marshalJSONIndent = origIndent
+ })
+
+ marshalJSON = func(any) ([]byte, error) { return nil, errOutputInjected }
+ if _, err := normalizePayload(struct{}{}); err == nil {
+ t.Fatal("normalizePayload should report marshal failure")
+ }
+ for name, call := range map[string]func() error{
+ "csv": func() error { return writeCSV(io.Discard, struct{}{}) },
+ "ndjson": func() error { return writeNDJSON(io.Discard, struct{}{}) },
+ "pretty": func() error { return writePretty(io.Discard, struct{}{}) },
+ "table": func() error { return writeTableish(io.Discard, struct{}{}) },
+ } {
+ if err := call(); err == nil {
+ t.Fatalf("%s should report normalization failure", name)
+ }
+ }
+ if _, err := roundTripJSON(struct{}{}); err == nil {
+ t.Fatal("roundTripJSON should report marshal failure")
+ }
+ if got := toGeneric(make(chan int)); got == nil {
+ t.Fatal("toGeneric should preserve an unmarshalable value")
+ }
+ if got := toGeneric(nil); got != nil {
+ t.Fatalf("toGeneric(nil) = %v", got)
+ }
+
+ marshalJSON = func(any) ([]byte, error) { return []byte("{"), nil }
+ if _, err := normalizePayload(struct{}{}); err == nil {
+ t.Fatal("normalizePayload should report unmarshal failure")
+ }
+ if _, err := roundTripJSON(struct{}{}); err == nil {
+ t.Fatal("roundTripJSON should report unmarshal failure")
+ }
+ if got := toGeneric("original"); got != "original" {
+ t.Fatalf("toGeneric fallback = %v", got)
+ }
+
+ marshalJSON = origMarshal
+ marshalJSONIndent = func(any, string, string) ([]byte, error) { return nil, errOutputInjected }
+ if err := WriteJSON(io.Discard, map[string]any{}); err == nil {
+ t.Fatal("WriteJSON should report marshal failure")
+ }
+ if err := ApplyJQ(io.Discard, map[string]any{"x": 1}, "."); err == nil {
+ t.Fatal("ApplyJQ should report marshal failure")
+ }
+
+ marshalJSONIndent = origIndent
+ marshalJSONOutput = func(any) ([]byte, error) { return nil, errOutputInjected }
+ if err := writeRaw(io.Discard, map[string]any{}); err == nil {
+ t.Fatal("writeRaw should report marshal failure")
+ }
+ if got := formatValue(make(chan int)); got == "" {
+ t.Fatalf("formatValue fallback = %q", got)
+ }
+}
+
+func TestOutputFlagEdges(t *testing.T) {
+ if _, ok := formatFromFlagSet(nil, FormatRaw); ok {
+ t.Fatal("nil flag set should not resolve")
+ }
+ cmd := &cobra.Command{Use: "cmd"}
+ if _, ok := formatFromFlagSet(cmd.Flags(), FormatRaw); ok {
+ t.Fatal("missing flag should not resolve")
+ }
+ cmd.Flags().Int("format", 1, "wrong type")
+ if _, ok := formatFromFlagSet(cmd.Flags(), FormatRaw); ok {
+ t.Fatal("non-string format should not resolve")
+ }
+
+ for _, raw := range []string{"", "json", "raw", "table", "pretty", "ndjson", "csv", "unknown"} {
+ _ = normalizeFormat(raw, FormatRaw)
+ }
+
+ if rootPersistentFlags(nil) != nil {
+ t.Fatal("nil command should not have root flags")
+ }
+ if ResolveFields(nil) != "" || ResolveJQ(nil) != "" {
+ t.Fatal("nil command filters should be empty")
+ }
+
+ root := &cobra.Command{Use: "root"}
+ child := &cobra.Command{Use: "child"}
+ root.AddCommand(child)
+ if ResolveFields(child) != "" || ResolveJQ(child) != "" {
+ t.Fatal("missing global filters should be empty")
+ }
+
+ root.PersistentFlags().String("fields", "", "global fields")
+ root.PersistentFlags().String("jq", "", "global jq")
+ if err := root.PersistentFlags().Set("fields", "id,name"); err != nil {
+ t.Fatal(err)
+ }
+ if err := root.PersistentFlags().Set("jq", ".id"); err != nil {
+ t.Fatal(err)
+ }
+ if got := ResolveFields(child); got != "id,name" {
+ t.Fatalf("ResolveFields = %q", got)
+ }
+ if got := ResolveJQ(child); got != ".id" {
+ t.Fatalf("ResolveJQ = %q", got)
+ }
+
+ wrong := &cobra.Command{Use: "wrong"}
+ wrong.PersistentFlags().Int("fields", 1, "global fields")
+ wrong.PersistentFlags().Int("jq", 1, "global jq")
+ _ = wrong.PersistentFlags().Set("fields", "2")
+ _ = wrong.PersistentFlags().Set("jq", "2")
+ if ResolveFields(wrong) != "" || ResolveJQ(wrong) != "" {
+ t.Fatal("wrongly typed filters should be ignored")
+ }
+}
+
+func TestOutputWriterFailures(t *testing.T) {
+ filtered := filterSlice([]any{"scalar", map[string]any{"keep": 1}}, map[string]bool{"keep": true})
+ if len(filtered) != 2 || filtered[0] != "scalar" {
+ t.Fatalf("filterSlice = %#v", filtered)
+ }
+ payload := map[string]any{
+ "items": []any{
+ map[string]any{"a": "one", "b": strings.Repeat("x", 80)},
+ map[string]any{"a": "two", "b": "three"},
+ },
+ "total": 2,
+ }
+ for nth := 1; nth <= 80; nth++ {
+ _ = writeTableish(&failNthWriter{nth: nth}, payload)
+ _ = writeTable(&failNthWriter{nth: nth}, []string{"a", "b"}, [][]string{{"1", "2"}, {"3", "4"}})
+ _ = writeKeyValues(&failNthWriter{nth: nth}, map[string]any{strings.Repeat("k", 30): "v", "z": 2})
+ }
+ if err := writeTable(io.Discard, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeTable(io.Discard, []string{"a"}, [][]string{{"1", "ignored"}}); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, call := range []func(io.Writer) error{
+ func(w io.Writer) error { return WriteJSON(w, map[string]any{"x": 1}) },
+ func(w io.Writer) error { return writeRaw(w, "text") },
+ func(w io.Writer) error { return writeRaw(w, map[string]any{"x": 1}) },
+ func(w io.Writer) error { return writeTableish(w, payload) },
+ func(w io.Writer) error { return ApplyJQ(w, []any{1, 2}, ".[]") },
+ } {
+ for nth := 1; nth <= 8; nth++ {
+ _ = call(&failNthWriter{nth: nth})
+ }
+ }
+ if err := ApplyJQ(io.Discard, 1, ".foo"); err == nil {
+ t.Fatal("jq evaluation error expected")
+ }
+ if err := ApplyJQ(io.Discard, nil, "["); err == nil {
+ t.Fatal("jq parse error expected")
+ }
+}
+
+func TestCSVFailureAndShapeEdges(t *testing.T) {
+ origWrite := writeCSVRecord
+ t.Cleanup(func() { writeCSVRecord = origWrite })
+
+ for failAt := 1; failAt <= 3; failAt++ {
+ calls := 0
+ writeCSVRecord = func(cw *csv.Writer, row []string) error {
+ calls++
+ if calls == failAt {
+ return errOutputInjected
+ }
+ return origWrite(cw, row)
+ }
+ _ = writeTableCSV(csv.NewWriter(io.Discard), []string{"h"}, [][]string{{"a"}, {"b"}})
+ calls = 0
+ _ = writeKeyValueCSV(csv.NewWriter(io.Discard), map[string]any{"a": 1, "b": 2})
+ calls = 0
+ _ = writeCSV(io.Discard, "scalar")
+ }
+ writeCSVRecord = origWrite
+
+ _ = writeCSV(&failNthWriter{nth: 1}, nil)
+ if err := writeCSV(&failNthWriter{nth: 1}, "scalar"); err == nil {
+ t.Fatal("scalar CSV flush failure expected")
+ }
+ if err := writeTableCSV(csv.NewWriter(&failNthWriter{nth: 1}), []string{"h"}, [][]string{{"v"}}); err == nil {
+ t.Fatal("table CSV flush failure expected")
+ }
+ if err := writeKeyValueCSV(csv.NewWriter(&failNthWriter{nth: 1}), map[string]any{"a": 1}); err == nil {
+ t.Fatal("key/value CSV flush failure expected")
+ }
+
+ headers, rows := broadcastMeta([]string{"id"}, [][]string{{"1"}}, nil)
+ if len(headers) != 1 || len(rows) != 1 {
+ t.Fatal("empty metadata changed rows")
+ }
+ headers, rows = broadcastMeta([]string{"id"}, [][]string{{"1"}}, map[string]any{"id": "collision"})
+ if len(headers) != 1 || len(rows) != 1 {
+ t.Fatal("colliding metadata should be ignored")
+ }
+ _, rows = broadcastMeta([]string{"id"}, nil, map[string]any{"total": 0})
+ if len(rows) != 1 {
+ t.Fatal("empty rows should retain metadata")
+ }
+ if err := writeCSV(io.Discard, map[string]any{"data": map[string]any{"x": 1}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeTableCSV(csv.NewWriter(io.Discard), []string{"a", "b"}, [][]string{{"one"}}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNDJSONFailureEdges(t *testing.T) {
+ origEncode := encodeNDJSON
+ t.Cleanup(func() { encodeNDJSON = origEncode })
+ encodeNDJSON = func(*json.Encoder, any) error { return errOutputInjected }
+ for _, payload := range []any{
+ []any{1},
+ map[string]any{"items": []any{map[string]any{"x": 1}}},
+ map[string]any{"x": 1},
+ "scalar",
+ } {
+ if err := writeNDJSON(io.Discard, payload); err == nil {
+ t.Fatalf("encode failure expected for %T", payload)
+ }
+ }
+ encodeNDJSON = origEncode
+ if err := writeNDJSON(&failNthWriter{nth: 1}, map[string]any{"x": 1}); err == nil {
+ t.Fatal("flush failure expected")
+ }
+}
+
+func TestPrettyAndFormattingEdges(t *testing.T) {
+ forceNoColor(t)
+ for _, payload := range []map[string]any{
+ {"kind": "schema", "degraded": true, "reason": "offline", "hint": "retry"},
+ {"kind": "schema", "degraded": true, "reason": "offline"},
+ {"kind": "schema", "other": true},
+ {"kind": "schema", "products": []any{"invalid", map[string]any{
+ "id": "p", "name": "same", "description": "same", "tools": []any{
+ "invalid",
+ map[string]any{"name": "same", "cli_name": "same"},
+ map[string]any{"name": "rpc", "cli_name": "cli"},
+ map[string]any{"name": "3"}, map[string]any{"name": "4"}, map[string]any{"name": "5"},
+ map[string]any{"name": "6"}, map[string]any{"name": "7"}, map[string]any{"name": "8"},
+ }},
+ }},
+ } {
+ if err := writePretty(io.Discard, payload); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ toolPayload := map[string]any{
+ "kind": "schema",
+ "product": "invalid",
+ "tool": map[string]any{
+ "name": "rpc",
+ "cli_name": "rpc",
+ "title": "rpc",
+ "description": "rpc",
+ "parameters": map[string]any{
+ "any": "invalid",
+ "array": map[string]any{"type": "array", "items": map[string]any{}},
+ "enum": map[string]any{"enum": []any{"a", 2}, "description": "line1\nline2"},
+ "required": map[string]any{"type": "string"},
+ },
+ "required": []any{"required", 3},
+ "flag_overlay": map[string]any{
+ "any": "invalid",
+ "enum": map[string]any{
+ "alias": "enum-alias",
+ "transform": "map",
+ "transform_args": map[string]any{"z": 2},
+ "env_default": "ENUM_ENV",
+ "default": "a",
+ },
+ },
+ "output_schema": map[string]any{"type": "object"},
+ },
+ }
+ if err := writePretty(io.Discard, toolPayload); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, value := range []any{nil, "text", map[string]any{"x": 1}} {
+ _ = formatValue(value)
+ }
+ for _, tc := range []struct {
+ s string
+ w int
+ }{{"abc", 0}, {"abc", 1}, {"abcdef", 3}, {"你好世界", 4}} {
+ _ = truncate(tc.s, tc.w)
+ }
+ for _, prop := range []map[string]any{
+ {"type": "array", "items": map[string]any{"type": "string"}},
+ {"type": "array"},
+ {},
+ {"type": "number"},
+ } {
+ _ = describeType(prop)
+ }
+ if got, err := normalizePayload("text"); err != nil || got != "text" {
+ t.Fatalf("normalize string = %v, %v", got, err)
+ }
+ if got, err := normalizePayload(nil); err != nil || got != nil {
+ t.Fatalf("normalize nil = %v, %v", got, err)
+ }
+}
+
+func TestExtractRowsNestedMetadataCollision(t *testing.T) {
+ payload := map[string]any{
+ "result": map[string]any{
+ "items": []any{map[string]any{"id": 1}},
+ "same": "inner",
+ "inner": true,
+ },
+ "same": "outer",
+ "outer": true,
+ }
+ _, _, meta, ok := extractRowsFromMap(payload)
+ if !ok || meta["same"] != "outer" || meta["inner"] != true || meta["outer"] != true {
+ t.Fatalf("unexpected metadata: %#v", meta)
+ }
+ if _, _, _, ok := extractRowsFromMap(map[string]any{"x": 1}); ok {
+ t.Fatal("non-list map should not extract rows")
+ }
+
+ var buf bytes.Buffer
+ if err := Write(&buf, FormatPretty, map[string]any{"x": 1}); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/output/csv.go b/internal/output/csv.go
index 503952f4..af4013d2 100644
--- a/internal/output/csv.go
+++ b/internal/output/csv.go
@@ -18,6 +18,8 @@ import (
"io"
)
+var writeCSVRecord = (*csv.Writer).Write
+
// writeCSV renders a payload as RFC-4180 CSV.
//
// It mirrors the shape decisions `-f table` already makes (same helpers:
@@ -68,7 +70,7 @@ func writeCSV(w io.Writer, payload any) error {
}
return writeKeyValueCSV(cw, typed)
case []any:
- headers, rows, _ := rowsFromSlice(typed)
+ headers, rows := rowsFromSlice(typed)
return writeTableCSV(cw, headers, rows)
case nil:
// Nothing to write — emit an empty document rather than erroring.
@@ -76,7 +78,7 @@ func writeCSV(w io.Writer, payload any) error {
return cw.Error()
default:
// Scalar: a single-cell, single-row CSV.
- if err := cw.Write([]string{formatValue(normalized)}); err != nil {
+ if err := writeCSVRecord(cw, []string{formatValue(normalized)}); err != nil {
return err
}
cw.Flush()
@@ -85,7 +87,7 @@ func writeCSV(w io.Writer, payload any) error {
}
func writeTableCSV(cw *csv.Writer, headers []string, rows [][]string) error {
- if err := cw.Write(headers); err != nil {
+ if err := writeCSVRecord(cw, headers); err != nil {
return err
}
for _, row := range rows {
@@ -96,7 +98,7 @@ func writeTableCSV(cw *csv.Writer, headers []string, rows [][]string) error {
copy(padded, row)
row = padded
}
- if err := cw.Write(row); err != nil {
+ if err := writeCSVRecord(cw, row); err != nil {
return err
}
}
@@ -145,11 +147,11 @@ func broadcastMeta(headers []string, rows [][]string, meta map[string]any) ([]st
}
func writeKeyValueCSV(cw *csv.Writer, m map[string]any) error {
- if err := cw.Write([]string{"key", "value"}); err != nil {
+ if err := writeCSVRecord(cw, []string{"key", "value"}); err != nil {
return err
}
for _, key := range sortedMapKeys(m) {
- if err := cw.Write([]string{key, formatValue(m[key])}); err != nil {
+ if err := writeCSVRecord(cw, []string{key, formatValue(m[key])}); err != nil {
return err
}
}
diff --git a/internal/output/filter.go b/internal/output/filter.go
index 7fc40533..6b3d9c76 100644
--- a/internal/output/filter.go
+++ b/internal/output/filter.go
@@ -14,13 +14,11 @@
package output
import (
- "encoding/json"
"fmt"
"io"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/itchyny/gojq"
)
@@ -172,7 +170,7 @@ func ApplyJQ(w io.Writer, payload any, expr string) error {
}
first = false
- data, marshalErr := jsonutil.MarshalIndent(value, "", " ")
+ data, marshalErr := marshalJSONIndent(value, "", " ")
if marshalErr != nil {
return apperrors.NewInternal("failed to encode --jq result")
}
@@ -219,12 +217,12 @@ func toGeneric(payload any) any {
if payload == nil {
return nil
}
- data, err := json.Marshal(payload)
+ data, err := marshalJSON(payload)
if err != nil {
return payload
}
var generic any
- if err := json.Unmarshal(data, &generic); err != nil {
+ if err := unmarshalJSON(data, &generic); err != nil {
return payload
}
return generic
diff --git a/internal/output/formatter.go b/internal/output/formatter.go
index 6275422a..8500d17e 100644
--- a/internal/output/formatter.go
+++ b/internal/output/formatter.go
@@ -56,6 +56,13 @@ var preferredListKeys = []string{
"result", "documents", "emailAccounts", "todoCards", "events", "messages",
}
+var (
+ marshalJSON = json.Marshal
+ unmarshalJSON = json.Unmarshal
+ marshalJSONOutput = jsonutil.Marshal
+ marshalJSONIndent = jsonutil.MarshalIndent
+)
+
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
if cmd == nil {
return fallback
@@ -65,11 +72,6 @@ func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
return format
}
}
- if root := cmd.Root(); root != nil {
- if format, ok := formatFromFlagSet(root.PersistentFlags(), fallback); ok {
- return format
- }
- }
return fallback
}
@@ -187,9 +189,6 @@ func ResolveFields(cmd *cobra.Command) string {
return ""
}
rootFlags := rootPersistentFlags(cmd)
- if rootFlags == nil {
- return ""
- }
globalFlag := rootFlags.Lookup("fields")
if globalFlag == nil {
return ""
@@ -200,9 +199,6 @@ func ResolveFields(cmd *cobra.Command) string {
cmd.InheritedFlags(),
rootFlags,
} {
- if flags == nil {
- continue
- }
if f := flags.Lookup("fields"); f != nil && f.Changed {
// To avoid collision with business flags (e.g. table create --fields),
// verify this flag shares the same usage string as the global one.
@@ -223,9 +219,6 @@ func ResolveJQ(cmd *cobra.Command) string {
return ""
}
rootFlags := rootPersistentFlags(cmd)
- if rootFlags == nil {
- return ""
- }
globalFlag := rootFlags.Lookup("jq")
if globalFlag == nil {
return ""
@@ -236,9 +229,6 @@ func ResolveJQ(cmd *cobra.Command) string {
cmd.InheritedFlags(),
rootFlags,
} {
- if flags == nil {
- continue
- }
if f := flags.Lookup("jq"); f != nil && f.Changed {
if f.Usage == globalFlag.Usage {
if v, err := flags.GetString("jq"); err == nil {
@@ -251,15 +241,15 @@ func ResolveJQ(cmd *cobra.Command) string {
}
func rootPersistentFlags(cmd *cobra.Command) *pflag.FlagSet {
- if root := cmd.Root(); root != nil {
- return root.PersistentFlags()
+ if cmd == nil {
+ return nil
}
- return nil
+ return cmd.Root().PersistentFlags()
}
// WriteJSON marshals payload as indented JSON and writes it to w.
func WriteJSON(w io.Writer, payload any) error {
- data, err := jsonutil.MarshalIndent(payload, "", " ")
+ data, err := marshalJSONIndent(payload, "", " ")
if err != nil {
return apperrors.NewInternal("failed to encode command output as JSON")
}
@@ -272,7 +262,7 @@ func writeRaw(w io.Writer, payload any) error {
_, err := fmt.Fprintln(w, SanitizeForTerminal(text))
return err
}
- data, err := jsonutil.Marshal(payload)
+ data, err := marshalJSONOutput(payload)
if err != nil {
return apperrors.NewInternal("failed to encode raw command output")
}
@@ -310,10 +300,8 @@ func writeTableish(w io.Writer, payload any) error {
}
return writeKeyValues(w, typed)
case []any:
- if headers, rows, ok := rowsFromSlice(typed); ok {
- return writeTable(w, headers, rows)
- }
- return writeRaw(w, normalized)
+ headers, rows := rowsFromSlice(typed)
+ return writeTable(w, headers, rows)
default:
return writeRaw(w, normalized)
}
@@ -326,12 +314,12 @@ func normalizePayload(payload any) (any, error) {
if text, ok := payload.(string); ok {
return text, nil
}
- data, err := json.Marshal(payload)
+ data, err := marshalJSON(payload)
if err != nil {
return nil, apperrors.NewInternal("failed to normalize command output")
}
var normalized any
- if err := json.Unmarshal(data, &normalized); err != nil {
+ if err := unmarshalJSON(data, &normalized); err != nil {
return nil, apperrors.NewInternal("failed to decode normalized command output")
}
return normalized, nil
@@ -366,10 +354,7 @@ func extractRowsFromMap(payload map[string]any) ([]string, [][]string, map[strin
if loc == nil {
return nil, nil, nil, false
}
- headers, rows, ok := rowsFromSlice(loc.list)
- if !ok {
- return nil, nil, nil, false
- }
+ headers, rows := rowsFromSlice(loc.list)
meta := make(map[string]any)
if loc.outerKey == "" {
for k, v := range payload {
@@ -385,26 +370,25 @@ func extractRowsFromMap(payload map[string]any) ([]string, [][]string, map[strin
}
meta[k] = v
}
- if inner, ok := payload[loc.outerKey].(map[string]any); ok {
- for k, v := range inner {
- if k == loc.innerKey {
- continue
- }
- if _, exists := meta[k]; exists {
- // Outer wins on key collision so users see the wrapper-level
- // sibling rather than a clobbered inner one.
- continue
- }
- meta[k] = v
+ inner := payload[loc.outerKey].(map[string]any)
+ for k, v := range inner {
+ if k == loc.innerKey {
+ continue
}
+ if _, exists := meta[k]; exists {
+ // Outer wins on key collision so users see the wrapper-level
+ // sibling rather than a clobbered inner one.
+ continue
+ }
+ meta[k] = v
}
}
return headers, rows, meta, true
}
-func rowsFromSlice(items []any) ([]string, [][]string, bool) {
+func rowsFromSlice(items []any) ([]string, [][]string) {
if len(items) == 0 {
- return []string{"value"}, [][]string{}, true
+ return []string{"value"}, [][]string{}
}
allMaps := true
@@ -430,14 +414,14 @@ func rowsFromSlice(items []any) ([]string, [][]string, bool) {
}
rows = append(rows, row)
}
- return headers, rows, true
+ return headers, rows
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{formatValue(item)})
}
- return []string{"value"}, rows, true
+ return []string{"value"}, rows
}
func writeKeyValues(w io.Writer, payload map[string]any) error {
@@ -560,7 +544,7 @@ func formatValue(value any) string {
case string:
return SanitizeForTerminal(typed)
default:
- data, err := jsonutil.Marshal(typed)
+ data, err := marshalJSONOutput(typed)
if err != nil {
return SanitizeForTerminal(fmt.Sprintf("%v", typed))
}
diff --git a/internal/output/ndjson.go b/internal/output/ndjson.go
index 909f0089..30976d55 100644
--- a/internal/output/ndjson.go
+++ b/internal/output/ndjson.go
@@ -19,6 +19,8 @@ import (
"io"
)
+var encodeNDJSON = (*json.Encoder).Encode
+
// writeNDJSON renders payload as newline-delimited JSON (https://ndjson.org):
// - a top-level array → one element per line
// - an object that wraps a → one element of that list per line
@@ -47,24 +49,24 @@ func writeNDJSON(w io.Writer, payload any) error {
switch v := normalized.(type) {
case []any:
for _, item := range v {
- if err := enc.Encode(item); err != nil {
+ if err := encodeNDJSON(enc, item); err != nil {
return err
}
}
case map[string]any:
if loc := findDataList(v); loc != nil {
for _, item := range loc.list {
- if err := enc.Encode(item); err != nil {
+ if err := encodeNDJSON(enc, item); err != nil {
return err
}
}
} else {
- if err := enc.Encode(v); err != nil {
+ if err := encodeNDJSON(enc, v); err != nil {
return err
}
}
default:
- if err := enc.Encode(v); err != nil {
+ if err := encodeNDJSON(enc, v); err != nil {
return err
}
}
@@ -75,12 +77,12 @@ func writeNDJSON(w io.Writer, payload any) error {
// map[string]any / []any / scalar shape used by the rest of this package by
// marshalling and unmarshalling it through encoding/json.
func roundTripJSON(payload any) (any, error) {
- raw, err := json.Marshal(payload)
+ raw, err := marshalJSON(payload)
if err != nil {
return nil, err
}
var out any
- if err := json.Unmarshal(raw, &out); err != nil {
+ if err := unmarshalJSON(raw, &out); err != nil {
return nil, err
}
return out, nil
diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go
index 5667c9fc..efeafc6d 100644
--- a/internal/pat/browser_policy.go
+++ b/internal/pat/browser_policy.go
@@ -29,6 +29,13 @@ import (
const browserPolicyFile = "pat_policy.json"
+var (
+ patPolicyReadFile = os.ReadFile
+ patPolicyUserHomeDir = os.UserHomeDir
+ patPolicyMarshal = json.MarshalIndent
+ patPolicyAtomicWrite = helpers.AtomicWriteJSON
+)
+
type browserPolicyValue struct {
OpenBrowser bool `json:"openBrowser"`
}
@@ -56,7 +63,7 @@ func patConfigDir() string {
if fn := edition.Get().ConfigDir; fn != nil {
return fn()
}
- homeDir, err := os.UserHomeDir()
+ homeDir, err := patPolicyUserHomeDir()
if err != nil {
return ".dws"
}
@@ -65,7 +72,7 @@ func patConfigDir() string {
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
path := patPolicyPath(configDir)
- data, err := os.ReadFile(path)
+ data, err := patPolicyReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &BrowserPolicy{}, nil
@@ -90,12 +97,12 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
if policy.Agents == nil {
policy.Agents = map[string]browserPolicyValue{}
}
- data, err := json.MarshalIndent(policy, "", " ")
+ data, err := patPolicyMarshal(policy, "", " ")
if err != nil {
return fmt.Errorf("marshaling PAT browser policy: %w", err)
}
data = append(data, '\n')
- if err := helpers.AtomicWriteJSON(patPolicyPath(configDir), data); err != nil {
+ if err := patPolicyAtomicWrite(patPolicyPath(configDir), data); err != nil {
return fmt.Errorf("writing PAT browser policy: %w", err)
}
return nil
@@ -205,14 +212,8 @@ func newBrowserPolicyCommand() *cobra.Command {
return fmt.Errorf("--enabled is required")
}
- enabled, err := cmd.Flags().GetBool("enabled")
- if err != nil {
- return err
- }
- agentCode, err := cmd.Flags().GetString("agentCode")
- if err != nil {
- return err
- }
+ enabled, _ := cmd.Flags().GetBool("enabled")
+ agentCode, _ := cmd.Flags().GetString("agentCode")
selection, err := SetBrowserPolicy(patConfigDir(), agentCode, enabled)
if err != nil {
diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go
index 00c45f0d..679edc15 100644
--- a/internal/pat/chmod.go
+++ b/internal/pat/chmod.go
@@ -590,10 +590,7 @@ func PlanLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller)
if err != nil {
return nil, err
}
- products, err := extractLoginRecommendProducts(planResult)
- if err != nil {
- return nil, err
- }
+ products, _ := extractLoginRecommendProducts(planResult)
return &LoginRecommendPlan{
Result: planResult,
Scopes: scopes,
@@ -700,10 +697,7 @@ func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes
if err != nil {
return planResult, nil, false, err
}
- allGranted, err := extractBatchPlanAllGranted(planResult)
- if err != nil {
- return planResult, nil, false, err
- }
+ allGranted, _ := extractBatchPlanAllGranted(planResult)
return planResult, scopes, allGranted, nil
}
@@ -788,15 +782,9 @@ func extractLoginRecommendProducts(result *edition.ToolResult) ([]LoginRecommend
}
if len(order) == 0 {
- scopes, err := extractSelectedScopesAllowEmpty(result)
- if err != nil {
- return nil, err
- }
+ scopes, _ := extractSelectedScopesAllowEmpty(result)
for _, scope := range scopes {
code := productCodeFromScope(scope)
- if code == "" {
- continue
- }
if groups[code] == nil {
groups[code] = &productGroup{
item: LoginRecommendProduct{
@@ -816,9 +804,6 @@ func extractLoginRecommendProducts(result *edition.ToolResult) ([]LoginRecommend
products := make([]LoginRecommendProduct, 0, len(order))
for _, code := range order {
group := groups[code]
- if group == nil {
- continue
- }
group.item.Summary = strings.Join(group.summaryParts, "、")
products = append(products, group.item)
}
@@ -1340,9 +1325,6 @@ func commandBoolFlag(cmd *cobra.Command, name string) bool {
return err == nil && value
}
root := cmd.Root()
- if root == nil {
- return false
- }
value, err := root.PersistentFlags().GetBool(name)
return err == nil && value
}
@@ -1355,9 +1337,6 @@ func commandFlagChanged(cmd *cobra.Command, name string) bool {
return true
}
root := cmd.Root()
- if root == nil {
- return false
- }
flag := root.PersistentFlags().Lookup(name)
return flag != nil && flag.Changed
}
@@ -1371,9 +1350,6 @@ func commandStringFlag(cmd *cobra.Command, name string) string {
return value
}
root := cmd.Root()
- if root == nil {
- return ""
- }
value, _ := root.PersistentFlags().GetString(name)
return value
}
diff --git a/internal/pat/coverage_edges_test.go b/internal/pat/coverage_edges_test.go
new file mode 100644
index 00000000..d677fadf
--- /dev/null
+++ b/internal/pat/coverage_edges_test.go
@@ -0,0 +1,561 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+package pat
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type patEdgeCaller struct {
+ results []*edition.ToolResult
+ errs []error
+ calls int
+ dryRun bool
+}
+
+func (c *patEdgeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
+ index := c.calls
+ c.calls++
+ var result *edition.ToolResult
+ var err error
+ if index < len(c.results) {
+ result = c.results[index]
+ }
+ if index < len(c.errs) {
+ err = c.errs[index]
+ }
+ return result, err
+}
+func (*patEdgeCaller) Format() string { return "json" }
+func (c *patEdgeCaller) DryRun() bool { return c.dryRun }
+func (*patEdgeCaller) Fields() string { return "" }
+func (*patEdgeCaller) JQ() string { return "" }
+
+type patFailWriter struct{}
+
+func (patFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
+
+func patText(text string) *edition.ToolResult {
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
+}
+
+func TestBrowserPolicyInjectedEdges(t *testing.T) {
+ originalRead := patPolicyReadFile
+ originalHome := patPolicyUserHomeDir
+ originalMarshal := patPolicyMarshal
+ originalWrite := patPolicyAtomicWrite
+ originalEdition := edition.Get()
+ t.Cleanup(func() {
+ patPolicyReadFile = originalRead
+ patPolicyUserHomeDir = originalHome
+ patPolicyMarshal = originalMarshal
+ patPolicyAtomicWrite = originalWrite
+ edition.Override(originalEdition)
+ })
+ failure := errors.New("injected failure")
+
+ t.Setenv("DWS_CONFIG_DIR", "/env/config")
+ if got := patConfigDir(); got != "/env/config" {
+ t.Fatalf("env config dir = %q", got)
+ }
+ t.Setenv("DWS_CONFIG_DIR", "")
+ edition.Override(&edition.Hooks{ConfigDir: func() string { return "/edition/config" }})
+ if got := patConfigDir(); got != "/edition/config" {
+ t.Fatalf("edition config dir = %q", got)
+ }
+ edition.Override(&edition.Hooks{})
+ patPolicyUserHomeDir = func() (string, error) { return "", failure }
+ if got := patConfigDir(); got != ".dws" {
+ t.Fatalf("failed home config dir = %q", got)
+ }
+ patPolicyUserHomeDir = func() (string, error) { return "/home/test", nil }
+ if got := patConfigDir(); got != "/home/test/.dws" {
+ t.Fatalf("home config dir = %q", got)
+ }
+
+ patPolicyReadFile = func(string) ([]byte, error) { return nil, failure }
+ if _, err := LoadBrowserPolicy("x"); !errors.Is(err, failure) {
+ t.Fatalf("read error = %v", err)
+ }
+ patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{`), nil }
+ if _, err := LoadBrowserPolicy("x"); err == nil {
+ t.Fatal("invalid policy decoded")
+ }
+ patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{}`), nil }
+ if policy, err := LoadBrowserPolicy("x"); err != nil || policy.Agents == nil {
+ t.Fatalf("empty policy = %#v, %v", policy, err)
+ }
+ patPolicyReadFile = originalRead
+
+ patPolicyMarshal = func(any, string, string) ([]byte, error) { return nil, failure }
+ if err := saveBrowserPolicy("x", nil); !errors.Is(err, failure) {
+ t.Fatalf("marshal error = %v", err)
+ }
+ patPolicyMarshal = originalMarshal
+ patPolicyAtomicWrite = func(string, []byte) error { return failure }
+ if err := saveBrowserPolicy("x", &BrowserPolicy{}); !errors.Is(err, failure) {
+ t.Fatalf("write error = %v", err)
+ }
+ patPolicyAtomicWrite = originalWrite
+
+ badDir := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(badDir, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ResolveBrowserPolicy(badDir, ""); err == nil {
+ t.Fatal("resolve ignored policy read error")
+ }
+ if EffectiveOpenBrowser(badDir) != true {
+ t.Fatal("policy error should preserve browser-open fallback")
+ }
+ if _, err := ResolveBrowserPolicy(t.TempDir(), "bad code"); err == nil {
+ t.Fatal("invalid agent code accepted")
+ }
+ if _, err := SetBrowserPolicy(t.TempDir(), "bad code", true); err == nil {
+ t.Fatal("invalid policy agent code accepted")
+ }
+ if _, err := SetBrowserPolicy(badDir, "", true); err == nil {
+ t.Fatal("set ignored load error")
+ }
+ patPolicyAtomicWrite = func(string, []byte) error { return failure }
+ if _, err := SetBrowserPolicy(t.TempDir(), "agent", true); !errors.Is(err, failure) {
+ t.Fatalf("agent save error = %v", err)
+ }
+ if _, err := SetBrowserPolicy(t.TempDir(), "", true); !errors.Is(err, failure) {
+ t.Fatalf("default save error = %v", err)
+ }
+}
+
+func TestBrowserPolicyCommandWriteError(t *testing.T) {
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir())
+ cmd := newBrowserPolicyCommand()
+ cmd.SetOut(patFailWriter{})
+ cmd.SetArgs([]string{"--enabled=true"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("output write error did not propagate")
+ }
+ if got := EffectiveOpenBrowser(os.Getenv("DWS_CONFIG_DIR")); !got {
+ t.Fatal("saved browser policy was not effective")
+ }
+}
+
+func TestBrowserPolicyCommandSetError(t *testing.T) {
+ badDir := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(badDir, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("DWS_CONFIG_DIR", badDir)
+ cmd := newBrowserPolicyCommand()
+ cmd.SetArgs([]string{"--enabled=true"})
+ if err := cmd.Execute(); err == nil {
+ t.Fatal("policy command ignored load error")
+ }
+}
+
+func TestPATPureHelperEdges(t *testing.T) {
+ if got := collectChmodProductCodes([]string{"", "a,a", " b "}, []string{"b", "c"}); strings.Join(got, ",") != "a,b,c" {
+ t.Fatalf("product codes = %#v", got)
+ }
+ if _, err := callPATBatchGrantWithLegacyFallback(t.Context(), nil, "", "", nil, nil, nil); err == nil {
+ t.Fatal("nil batch grant caller accepted")
+ }
+ if _, err := callPATBatchPlan(t.Context(), nil, "", "", nil); err == nil {
+ t.Fatal("nil batch plan caller accepted")
+ }
+ if _, err := callPATToolWithLegacyFallback(t.Context(), nil, "", "", "", nil, nil); err == nil {
+ t.Fatal("nil legacy caller accepted")
+ }
+ caller := &patEdgeCaller{errs: []error{errors.New("boom")}}
+ if _, err := callPATToolWithLegacyFallback(t.Context(), caller, "pat", "tool", "", nil, nil); err == nil {
+ t.Fatal("empty alias error did not propagate")
+ }
+ caller = &patEdgeCaller{results: []*edition.ToolResult{patText(`{"code":"PAT_FORGED_IDENTITY_FIELD"}`)}, errs: []error{nil, errors.New("second failed")}}
+ if _, err := callPATBatchToolWithIdentityFallback(t.Context(), caller, "agent", "", "tool", map[string]any{"agentCode": "agent"}); err == nil {
+ t.Fatal("identity fallback retry error did not propagate")
+ }
+
+ if !strings.HasPrefix(newBatchClientRequestID(""), "batch-") {
+ t.Fatal("default request ID prefix missing")
+ }
+ if got := normalizeProductCodes([]string{"", "a,a", " b "}); strings.Join(got, ",") != "a,b" {
+ t.Fatalf("normalized product codes = %#v", got)
+ }
+ for scope, want := range map[string]string{"": "", "mail.read": "mail", "doc:read": "doc", "drive": "drive", " x.y:z ": "x"} {
+ if got := productCodeFromScope(scope); got != want {
+ t.Fatalf("productCodeFromScope(%q) = %q, want %q", scope, got, want)
+ }
+ }
+ if !isEmptyToolResult(nil) || !isEmptyToolResult(&edition.ToolResult{}) || !isEmptyToolResult(&edition.ToolResult{Content: []edition.ContentBlock{{Type: "image"}}}) {
+ t.Fatal("empty tool result detection failed")
+ }
+ if firstToolResultText(nil) != "" || firstToolResultText(&edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "x"}}}) != "" {
+ t.Fatal("first text extraction failed")
+ }
+ if patResultAgentCode(nil) != "" || patResultAgentCode(patText(`{`)) != "" {
+ t.Fatal("invalid PAT result produced agent code")
+ }
+ for raw, want := range map[string]string{
+ `{"agentCode":"top"}`: "top",
+ `{"data":{"agentCode":"data"}}`: "data",
+ `{"result":{"agentCode":"result"}}`: "result",
+ `{"result":{"data":{"agentCode":"deep"}}}`: "deep",
+ `{}`: "",
+ } {
+ if got := patResultAgentCode(patText(raw)); got != want {
+ t.Fatalf("agent code from %s = %q, want %q", raw, got, want)
+ }
+ }
+ if err := ensurePATResultAgentCode(nil, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := ensurePATIdentityFallbackAgentCode(nil, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := ensurePATIdentityFallbackAgentCode(nil, "expected"); err == nil {
+ t.Fatal("missing fallback agent code accepted")
+ }
+ if patBatchResultHasCode(nil, func(string) bool { return true }) || patBatchResultHasCode(patText(`{`), func(string) bool { return true }) {
+ t.Fatal("invalid batch result matched")
+ }
+ if !patBatchResultHasCode(patText(`{"error_code":"match"}`), func(code string) bool { return code == "match" }) {
+ t.Fatal("error_code was not matched")
+ }
+ if isPATBatchFallbackError(nil) || !isPATBatchFallbackError(errors.New("PAT_BATCH_AUTH_UNSUPPORTED")) {
+ t.Fatal("batch fallback error detection failed")
+ }
+ if isToolNotRegisteredError(nil) || !isToolNotRegisteredError(errors.New("unknown tool")) {
+ t.Fatal("tool registration error detection failed")
+ }
+ if hasScopeKeyShapeMismatch(nil, nil) || hasScopeKeyShapeMismatch(map[string]any{}, map[string]any{}) {
+ t.Fatal("scope shape mismatch false positive")
+ }
+ canonical := map[string]any{"scopes": []string{"a"}}
+ legacy := map[string]any{"scope": "a"}
+ if isLegacyGrantSchemaMismatchError(nil, canonical, legacy) || isLegacyGrantSchemaMismatchError(&apperrors.PATError{RawJSON: "scope validation"}, canonical, legacy) {
+ t.Fatal("legacy mismatch accepted excluded error")
+ }
+ for _, message := range []string{"missing parameter", "scope missing_scope", "scope invalid permission denied", "scope unknown"} {
+ _ = isLegacyGrantSchemaMismatchError(errors.New(message), canonical, legacy)
+ }
+ if normalizedPATErrorText(nil) != "" {
+ t.Fatal("nil normalized error text was non-empty")
+ }
+ typed := apperrors.NewAPI("message", apperrors.WithReason("reason"), apperrors.WithHint("hint"))
+ if got := normalizedPATErrorText(typed); !strings.Contains(got, "reason") || !strings.Contains(got, "hint") {
+ t.Fatalf("normalized typed error = %q", got)
+ }
+}
+
+func TestPATPlanExtractionEdges(t *testing.T) {
+ invalidResults := []*edition.ToolResult{nil, patText(`{`), patText(`{}`)}
+ for _, result := range invalidResults {
+ _, _ = extractLoginRecommendProducts(result)
+ _, _ = extractBatchPlanAllGranted(result)
+ _, _ = extractSelectedScopes(result)
+ _, _ = extractSelectedScopesAllowEmpty(result)
+ }
+ productsJSON := `{"data":{"selectedScopes":["mail.read",12,""],"items":[12,{"scope":""},{"scope":"mail.read","productName":"","displayName":"Read"},{"scope":"mail.write","operationSummary":"Write"},{"scope":"mail.other","operationSummary":"Write"},{"scope":"mail.more","operationSummary":"More"},{"scope":"mail.last","operationSummary":"Last"}]}}`
+ products, err := extractLoginRecommendProducts(patText(productsJSON))
+ if err != nil || len(products) != 1 || products[0].ProductCode != "mail" || products[0].SelectedScopeCount != 1 {
+ t.Fatalf("products = %#v, %v", products, err)
+ }
+ fallbackJSON := `{"data":{"selectedScopes":["doc.read","doc.write","",12],"items":[]}}`
+ products, err = extractLoginRecommendProducts(patText(fallbackJSON))
+ if err != nil || len(products) != 1 || products[0].ScopeCount != 2 {
+ t.Fatalf("fallback products = %#v, %v", products, err)
+ }
+ if _, err := extractSelectedScopes(patText(`{"data":{}}`)); err == nil {
+ t.Fatal("empty selected scopes accepted")
+ }
+ if got, err := extractSelectedScopes(patText(`{"data":{"allGranted":true}}`)); err != nil || len(got) != 0 {
+ t.Fatalf("all-granted scopes = %#v, %v", got, err)
+ }
+ if _, err := extractSelectedScopes(patText(`{"data":{"selectedScopes":[12," "]}}`)); err == nil {
+ t.Fatal("non-string scopes accepted")
+ }
+ if got, err := extractSelectedScopes(patText(`{"data":{"selectedScopes":[12," "],"allGranted":true}}`)); err != nil || len(got) != 0 {
+ t.Fatalf("filtered all-granted scopes = %#v, %v", got, err)
+ }
+ if got, err := extractSelectedScopesAllowEmpty(patText(`{"data":{"selectedScopes":[12," ","one"]}}`)); err != nil || len(got) != 1 {
+ t.Fatalf("allow-empty scopes = %#v, %v", got, err)
+ }
+}
+
+func TestPATResultAndFlagEdges(t *testing.T) {
+ if err := handleToolResult(nil, nil, nil); err == nil {
+ t.Fatal("nil result accepted")
+ }
+ if err := handleToolResultForWriter(io.Discard, nil, nil); err == nil {
+ t.Fatal("nil writer result accepted")
+ }
+ if err := classifyToolResultText(nil); err == nil {
+ t.Fatal("nil result classified successfully")
+ }
+ nonText := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "x"}}}
+ if err := handleToolResultForWriter(io.Discard, nonText, nil); err == nil {
+ t.Fatal("non-text result accepted")
+ }
+ if err := classifyToolResultText(nonText); err != nil {
+ t.Fatal(err)
+ }
+ patFailure := patText(`{"code":"PAT_NO_PERMISSION"}`)
+ if _, err := captureStdout(t, func() error { return handleToolResult(nil, nil, patFailure) }); err == nil {
+ t.Fatal("PAT error result was accepted by stdout handler")
+ }
+ if _, err := captureStdout(t, func() error { return handleToolResult(nil, nil, nonText) }); err == nil {
+ t.Fatal("non-text result was accepted by stdout handler")
+ }
+ if err := handleToolResultForWriter(io.Discard, patFailure, nil); err == nil {
+ t.Fatal("PAT error result accepted")
+ }
+ if err := classifyToolResultText(patFailure); err == nil {
+ t.Fatal("PAT error was not classified")
+ }
+ if err := handleToolResultForWriter(nil, patText(`{"data":{}}`), nil); err != nil {
+ t.Fatalf("nil output writer: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := handleToolResultForWriter(&buf, patText(`plain text`), nil); err != nil || !strings.Contains(buf.String(), "plain text") {
+ t.Fatalf("plain writer result = %q, %v", buf.String(), err)
+ }
+
+ if commandBoolFlag(nil, "verbose") || commandFlagChanged(nil, "format") || commandStringFlag(nil, "format") != "" {
+ t.Fatal("nil command flag helpers returned values")
+ }
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().Bool("verbose", true, "")
+ root.PersistentFlags().String("format", "raw", "")
+ child := &cobra.Command{Use: "child"}
+ root.AddCommand(child)
+ if !commandBoolFlag(child, "verbose") || commandStringFlag(child, "format") != "raw" {
+ t.Fatal("persistent flags were not resolved")
+ }
+ if commandFlagChanged(child, "format") {
+ t.Fatal("unchanged format reported changed")
+ }
+ if err := root.PersistentFlags().Set("format", "json"); err != nil {
+ t.Fatal(err)
+ }
+ if !commandFlagChanged(child, "format") {
+ t.Fatal("changed persistent format not detected")
+ }
+ local := &cobra.Command{Use: "local"}
+ local.Flags().Bool("verbose", true, "")
+ local.Flags().String("format", "json", "")
+ if !commandBoolFlag(local, "verbose") || commandStringFlag(local, "format") != "json" {
+ t.Fatal("local flags were not resolved")
+ }
+ if err := local.Flags().Set("format", "raw"); err != nil {
+ t.Fatal(err)
+ }
+ if !commandFlagChanged(local, "format") {
+ t.Fatal("changed local format not detected")
+ }
+}
+
+func TestPATAuthorizationSummaryEdges(t *testing.T) {
+ if formatPATAuthorizationSummary("not json", nil) != "" || formatPATAuthorizationSummary(`{}`, nil) != "" {
+ t.Fatal("invalid summary payload rendered")
+ }
+ dry := &patEdgeCaller{dryRun: true}
+ texts := []string{
+ `{"success":true,"data":{"agentCode":"a","grantType":"once","allGranted":true,"items":[],"selectedScopes":[],"grantedScopes":[],"alreadyGrantedScopes":[],"skippedScopes":[],"pendingScopes":[]}}`,
+ `{"code":"OK","data":{"selectedScopes":["a"]}}`,
+ `{"data":{"selectedScopes":["a"]}}`,
+ `{"data":{"pendingScopes":["a"]}}`,
+ `{"data":{"items":["a"],"selectedScopes":"wrong"}}`,
+ }
+ for i, text := range texts {
+ caller := edition.ToolCaller(nil)
+ if i == 1 {
+ caller = dry
+ }
+ if got := formatPATAuthorizationSummary(text, caller); got == "" {
+ t.Fatalf("summary %d was empty", i)
+ }
+ }
+ if _, ok := countField(map[string]any{"values": []string{"a"}}, "values"); !ok {
+ t.Fatal("[]string count was not recognized")
+ }
+ if _, ok := countField(map[string]any{}, "missing"); ok {
+ t.Fatal("missing count was recognized")
+ }
+ if _, ok := countField(map[string]any{"bad": 1}, "bad"); ok {
+ t.Fatal("invalid count was recognized")
+ }
+}
+
+func TestLoginRecommendWrappers(t *testing.T) {
+ if err := RunLoginRecommendAuthorization(t.Context(), nil, io.Discard); err == nil {
+ t.Fatal("nil login recommend caller accepted")
+ }
+ if _, err := PlanLoginRecommendAuthorization(t.Context(), nil); err == nil {
+ t.Fatal("nil plan caller accepted")
+ }
+ allGranted := patText(`{"data":{"selectedScopes":[],"allGranted":true,"items":[]}}`)
+ caller := &patEdgeCaller{results: []*edition.ToolResult{allGranted}}
+ var buf bytes.Buffer
+ if err := RunLoginRecommendAuthorization(t.Context(), caller, &buf); err != nil || buf.Len() == 0 {
+ t.Fatalf("login recommend wrapper = %q, %v", buf.String(), err)
+ }
+ caller = &patEdgeCaller{results: []*edition.ToolResult{allGranted}}
+ plan, err := PlanLoginRecommendAuthorization(t.Context(), caller)
+ if err != nil || !plan.AllGranted {
+ t.Fatalf("login recommend plan = %#v, %v", plan, err)
+ }
+}
+
+func TestChmodCommandRemainingBranches(t *testing.T) {
+ cmd := newChmodCommand(nil)
+ if err := cmd.Args(cmd, []string{"scope"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Args(cmd, nil); err == nil {
+ t.Fatal("empty chmod arguments accepted")
+ }
+ cmd = newChmodCommand(nil)
+ if err := cmd.Flags().Parse([]string{"--recommend"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Args(cmd, nil); err != nil {
+ t.Fatalf("recommend args: %v", err)
+ }
+ cmd = newChmodCommand(nil)
+ if err := cmd.Flags().Parse([]string{"--product", "mail"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Args(cmd, nil); err != nil {
+ t.Fatalf("product args: %v", err)
+ }
+
+ run := func(c edition.ToolCaller, args ...string) error {
+ command := newChmodCommand(c)
+ command.SetArgs(args)
+ return command.Execute()
+ }
+ dryError := &patEdgeCaller{dryRun: true, errs: []error{errors.New("plan failed")}}
+ if err := run(dryError, "--recommend"); err == nil {
+ t.Fatal("dry-run plan error did not propagate")
+ }
+ dryMismatch := &patEdgeCaller{dryRun: true, results: []*edition.ToolResult{patText(`{"data":{"agentCode":"other"}}`)}}
+ if err := run(dryMismatch, "--recommend", "--agentCode", "expected"); err == nil {
+ t.Fatal("dry-run plan agent mismatch accepted")
+ }
+ dryPreview := &patEdgeCaller{dryRun: true}
+ if err := run(dryPreview, "scope", "--grant-type", "session", "--session-id", "sid"); err != nil {
+ t.Fatalf("dry-run session preview: %v", err)
+ }
+ if err := run(nil, "scope"); err == nil {
+ t.Fatal("nil chmod runtime accepted")
+ }
+ planError := &patEdgeCaller{errs: []error{errors.New("plan failed")}}
+ if err := run(planError, "--recommend"); err == nil {
+ t.Fatal("plan error did not propagate")
+ }
+ planMismatch := &patEdgeCaller{results: []*edition.ToolResult{patText(`{"data":{"agentCode":"other"}}`)}}
+ if err := run(planMismatch, "--recommend", "--agentCode", "expected"); err == nil {
+ t.Fatal("plan agent mismatch accepted")
+ }
+ badScopes := &patEdgeCaller{results: []*edition.ToolResult{patText(`{"data":{}}`)}}
+ if err := run(badScopes, "--recommend"); err == nil {
+ t.Fatal("invalid selected scopes accepted")
+ }
+}
+
+func TestLoginRecommendErrorMatrix(t *testing.T) {
+ caller := &patEdgeCaller{errs: []error{errors.New("plan failed")}}
+ if _, err := PlanLoginRecommendAuthorization(t.Context(), caller); err == nil {
+ t.Fatal("plan wrapper error did not propagate")
+ }
+ if _, _, _, err := planLoginRecommend(t.Context(), &patEdgeCaller{}, nil, false); err == nil {
+ t.Fatal("all-scope plan without products accepted")
+ }
+ if _, _, _, err := planLoginRecommend(t.Context(), &patEdgeCaller{errs: []error{errors.New("call failed")}}, nil, true); err == nil {
+ t.Fatal("plan call error did not propagate")
+ }
+ if _, _, _, err := planLoginRecommend(t.Context(), &patEdgeCaller{results: []*edition.ToolResult{patText(`{"code":"PAT_NO_PERMISSION"}`)}}, nil, true); err == nil {
+ t.Fatal("classified plan result accepted")
+ }
+ if _, _, _, err := planLoginRecommend(t.Context(), &patEdgeCaller{results: []*edition.ToolResult{patText(`{}`)}}, nil, true); err == nil {
+ t.Fatal("malformed plan result accepted")
+ }
+
+ validProductPlan := patText(`{"data":{"selectedScopes":["mail.read"],"items":[{"scope":"mail.read","productCode":"mail"}]}}`)
+ initial := &LoginRecommendPlan{Result: validProductPlan, Scopes: []string{"mail.read"}}
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{}, io.Discard, LoginRecommendOptions{
+ InitialPlan: initial,
+ ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
+ return nil, errors.New("selection failed")
+ },
+ }); err == nil {
+ t.Fatal("selector error did not propagate")
+ }
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{}, io.Discard, LoginRecommendOptions{
+ InitialPlan: initial,
+ ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
+ return nil, nil
+ },
+ }); err == nil {
+ t.Fatal("empty product selection accepted")
+ }
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{errs: []error{errors.New("replan failed")}}, io.Discard, LoginRecommendOptions{
+ InitialPlan: initial,
+ ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
+ return []string{"mail"}, nil
+ },
+ }); err == nil {
+ t.Fatal("selector replan error did not propagate")
+ }
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{}, io.Discard, LoginRecommendOptions{
+ ScopeMode: LoginRecommendScopeAll,
+ InitialPlan: &LoginRecommendPlan{Result: patText(`{"data":{"selectedScopes":[],"items":[]}}`), Scopes: []string{"synthetic"}},
+ ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
+ return nil, nil
+ },
+ }); err == nil {
+ t.Fatal("all-scope mode without products accepted")
+ }
+ allGranted := patText(`{"data":{"selectedScopes":[],"allGranted":true,"items":[]}}`)
+ var output bytes.Buffer
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{results: []*edition.ToolResult{allGranted}}, &output, LoginRecommendOptions{
+ InitialPlan: initial,
+ ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
+ return []string{"mail"}, nil
+ },
+ }); err != nil || output.Len() == 0 {
+ t.Fatalf("all-granted replan = %q, %v", output.String(), err)
+ }
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{errs: []error{errors.New("grant failed")}}, io.Discard, LoginRecommendOptions{
+ InitialPlan: initial,
+ }); err == nil {
+ t.Fatal("grant error did not propagate")
+ }
+ if err := RunLoginRecommendAuthorizationWithOptions(t.Context(), &patEdgeCaller{}, io.Discard, LoginRecommendOptions{
+ InitialPlan: &LoginRecommendPlan{Result: patText(`{`), Scopes: []string{"scope"}},
+ ProductSelector: func([]LoginRecommendProduct) ([]string, error) {
+ return []string{"mail"}, nil
+ },
+ }); err == nil {
+ t.Fatal("invalid initial plan products accepted")
+ }
+}
+
+func TestLegacySchemaMismatchWithoutValidationKeyword(t *testing.T) {
+ canonical := map[string]any{"scopes": []string{"a"}}
+ legacy := map[string]any{"scope": "a"}
+ if isLegacyGrantSchemaMismatchError(errors.New("scope problem"), canonical, legacy) {
+ t.Fatal("ambiguous scope error triggered legacy fallback")
+ }
+}
diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go
index 16f348e5..67a444ad 100644
--- a/internal/pipeline/cobra.go
+++ b/internal/pipeline/cobra.go
@@ -42,7 +42,7 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
// Traverse the command tree to find the target command.
target, _, err := root.Traverse(rawArgs)
- if err != nil || target == nil {
+ if err != nil {
return
}
@@ -94,24 +94,24 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
var infos []FlagInfo
cmd.Flags().VisitAll(func(f *pflag.Flag) {
- if seen[f.Name] {
- return
- }
- seen[f.Name] = true
- infos = append(infos, flagInfoFromPflag(f))
+ appendFlagInfo(&infos, seen, f)
})
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
- if seen[f.Name] {
- return
- }
- seen[f.Name] = true
- infos = append(infos, flagInfoFromPflag(f))
+ appendFlagInfo(&infos, seen, f)
})
return infos
}
+func appendFlagInfo(infos *[]FlagInfo, seen map[string]bool, flag *pflag.Flag) {
+ if seen[flag.Name] {
+ return
+ }
+ seen[flag.Name] = true
+ *infos = append(*infos, flagInfoFromPflag(flag))
+}
+
// flagInfoFromPflag builds a FlagInfo from a pflag.Flag, copying
// schema metadata stashed in the flag's annotations map.
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
diff --git a/internal/pipeline/cobra_test.go b/internal/pipeline/cobra_test.go
new file mode 100644
index 00000000..b83620d8
--- /dev/null
+++ b/internal/pipeline/cobra_test.go
@@ -0,0 +1,113 @@
+package pipeline
+
+import (
+ "errors"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestFlagInfoFromCommandIncludesLocalInheritedAndAnnotations(t *testing.T) {
+ if FlagInfoFromCommand(nil) != nil {
+ t.Fatal("FlagInfoFromCommand(nil) != nil")
+ }
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("profile", "", "")
+ child := &cobra.Command{Use: "child"}
+ child.Flags().String("start-time", "", "")
+ child.Flags().Lookup("start-time").Annotations = map[string][]string{
+ "x-cli-format": {"date-time"},
+ "x-cli-enum": {"one", "two"},
+ }
+ root.AddCommand(child)
+
+ infos := FlagInfoFromCommand(child)
+ if len(infos) != 2 {
+ t.Fatalf("FlagInfoFromCommand() = %#v", infos)
+ }
+ byName := make(map[string]FlagInfo)
+ for _, info := range infos {
+ byName[info.Name] = info
+ }
+ if byName["profile"].Type != "string" || byName["start-time"].Format != "date-time" ||
+ !reflect.DeepEqual(byName["start-time"].Enum, []string{"one", "two"}) {
+ t.Fatalf("flag infos = %#v", infos)
+ }
+
+ var deduplicated []FlagInfo
+ seen := make(map[string]bool)
+ flag := child.Flags().Lookup("start-time")
+ appendFlagInfo(&deduplicated, seen, flag)
+ appendFlagInfo(&deduplicated, seen, flag)
+ if len(deduplicated) != 1 {
+ t.Fatalf("appendFlagInfo duplicate result = %#v", deduplicated)
+ }
+}
+
+func TestRunPreParseGuardAndTraversalBranches(t *testing.T) {
+ previousArgs := os.Args
+ t.Cleanup(func() { os.Args = previousArgs })
+ root := &cobra.Command{Use: "root"}
+ root.AddCommand(&cobra.Command{Use: "flagless"})
+
+ RunPreParse(root, nil)
+ RunPreParse(root, NewEngine())
+
+ engine := NewEngine()
+ engine.Register(newStub("noop", PreParse, nil))
+ os.Args = []string{"root"}
+ RunPreParse(root, engine)
+ os.Args = []string{"root", "missing"}
+ RunPreParse(root, engine)
+ os.Args = []string{"root", "--unknown", "value", "flagless"}
+ RunPreParse(root, engine)
+ os.Args = []string{"root", "flagless"}
+ RunPreParse(root, engine)
+}
+
+func TestRunPreParseAppliesCorrectionsOnlyOnSuccess(t *testing.T) {
+ previousArgs := os.Args
+ t.Cleanup(func() { os.Args = previousArgs })
+
+ buildRoot := func() (*cobra.Command, *string) {
+ root := &cobra.Command{Use: "root", SilenceErrors: true, SilenceUsage: true}
+ child := &cobra.Command{Use: "child"}
+ value := ""
+ child.Flags().StringVar(&value, "name", "", "")
+ root.AddCommand(child)
+ return root, &value
+ }
+
+ root, value := buildRoot()
+ engine := NewEngine()
+ engine.Register(newStub("correct", PreParse, func(ctx *Context) error {
+ ctx.Args[len(ctx.Args)-1] = "corrected"
+ ctx.AddCorrection("correct", PreParse, "name", "wrong", "corrected", "test")
+ return nil
+ }))
+ os.Args = []string{"root", "child", "--name", "wrong"}
+ RunPreParse(root, engine)
+ if err := root.Execute(); err != nil || *value != "corrected" {
+ t.Fatalf("corrected execute = %q, %v", *value, err)
+ }
+
+ root, value = buildRoot()
+ noCorrection := NewEngine()
+ noCorrection.Register(newStub("inspect", PreParse, func(*Context) error { return nil }))
+ os.Args = []string{"root", "child", "--name", "original"}
+ RunPreParse(root, noCorrection)
+ if err := root.Execute(); err != nil || *value != "original" {
+ t.Fatalf("uncorrected execute = %q, %v", *value, err)
+ }
+
+ root, value = buildRoot()
+ failing := NewEngine()
+ failing.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("boom") }))
+ os.Args = []string{"root", "child", "--name", "original"}
+ RunPreParse(root, failing)
+ if err := root.Execute(); err != nil || *value != "original" {
+ t.Fatalf("failed preparse execute = %q, %v", *value, err)
+ }
+}
diff --git a/internal/pipeline/handlers/coverage_edges_test.go b/internal/pipeline/handlers/coverage_edges_test.go
new file mode 100644
index 00000000..cc002113
--- /dev/null
+++ b/internal/pipeline/handlers/coverage_edges_test.go
@@ -0,0 +1,62 @@
+package handlers
+
+import (
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+func TestParamValueCoverageEdges(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{"ignored": "value", "unknown": "value"},
+ Schema: makeSchema(map[string]any{"ignored": "not-a-schema"}),
+ }
+ if err := (ParamValueHandler{}).Handle(ctx); err != nil {
+ t.Fatalf("Handle(): %v", err)
+ }
+ if err := (ParamValueHandler{}).Handle(&pipeline.Context{Params: map[string]any{"x": 1}, Schema: map[string]any{"type": "object"}}); err != nil {
+ t.Fatalf("Handle(schema without properties): %v", err)
+ }
+
+ cases := []struct {
+ name string
+ value any
+ schema map[string]any
+ want any
+ }{
+ {"bool non-string", 1, map[string]any{"type": "boolean"}, 1},
+ {"integer parse error", "1,broken", map[string]any{"type": "integer"}, "1,broken"},
+ {"number parse error", "1,broken", map[string]any{"type": "number"}, "1,broken"},
+ {"date non-string", 1, map[string]any{"type": "string", "format": "date"}, 1},
+ {"date empty", " ", map[string]any{"type": "string", "format": "date"}, " "},
+ {"unix seconds", "1711699200", map[string]any{"type": "string", "format": "date-time"}, "2024-03-29T08:00:00Z"},
+ {"invalid date", "not-a-date", map[string]any{"type": "string", "format": "date"}, "not-a-date"},
+ {"ambiguous enum", "a", map[string]any{"enum": []any{"A", "a"}}, "a"},
+ {"enum ignores non-string", "A", map[string]any{"enum": []any{1, "a"}}, "a"},
+ {"unknown type", "value", map[string]any{"type": "object"}, "value"},
+ }
+ for _, tc := range cases {
+ got, _ := normaliseValue(tc.value, tc.schema)
+ if got != tc.want {
+ t.Errorf("%s: got %#v, want %#v", tc.name, got, tc.want)
+ }
+ }
+ if got := classifyCorrection(map[string]any{"type": "object"}); got != "value" {
+ t.Fatalf("unknown correction class = %q", got)
+ }
+}
+
+func TestNameAndStickyCoverageEdges(t *testing.T) {
+ if _, ok := tryFuzzyMatch("--", map[string]bool{}, nil); ok {
+ t.Fatal("bare -- should not fuzzy match")
+ }
+ if _, ok := trySplitSticky("--", nil); ok {
+ t.Fatal("bare -- should not split")
+ }
+ if _, ok := trySplitSticky("--unknown42", map[string]pipeline.FlagInfo{"known": {Type: "integer"}}); ok {
+ t.Fatal("unknown sticky prefix should not split")
+ }
+ if _, ok := trySplitSticky("--pageSize", map[string]pipeline.FlagInfo{"page-size": {Type: "integer"}}); ok {
+ t.Fatal("known camel-case flag should not split")
+ }
+}
diff --git a/internal/pipeline/handlers/paramvalue.go b/internal/pipeline/handlers/paramvalue.go
index 90444f93..02ea6882 100644
--- a/internal/pipeline/handlers/paramvalue.go
+++ b/internal/pipeline/handlers/paramvalue.go
@@ -202,23 +202,15 @@ func normaliseDate(value any, schemaFormat string) (any, bool) {
}
// Try parsing as millisecond timestamp.
- if ms, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 {
+ if ms, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 13 {
t := time.UnixMilli(ms).UTC()
- formatted := formatDate(t, schemaFormat)
- if formatted != str {
- return formatted, true
- }
- return value, false
+ return formatDate(t, schemaFormat), true
}
// Try parsing as second timestamp.
if sec, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 {
t := time.Unix(sec, 0).UTC()
- formatted := formatDate(t, schemaFormat)
- if formatted != str {
- return formatted, true
- }
- return value, false
+ return formatDate(t, schemaFormat), true
}
// Try known date formats.
diff --git a/internal/pipeline/handlers/sticky.go b/internal/pipeline/handlers/sticky.go
index 51d43c3a..add1e773 100644
--- a/internal/pipeline/handlers/sticky.go
+++ b/internal/pipeline/handlers/sticky.go
@@ -129,10 +129,6 @@ func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (sticky
}
suffix := bare[bestLen:]
- if suffix == "" {
- return stickyPair{}, false
- }
-
// Guard: only split if the suffix plausibly looks like a value
// for this flag's declared type/format/enum. Otherwise leave the
// token untouched so Cobra reports "unknown flag" instead of
diff --git a/internal/plugin/coverage_edges_test.go b/internal/plugin/coverage_edges_test.go
new file mode 100644
index 00000000..1046293c
--- /dev/null
+++ b/internal/plugin/coverage_edges_test.go
@@ -0,0 +1,966 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package plugin
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+func writePluginManifest(t *testing.T, dir string, m Manifest) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPluginConverterEdges(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "user", "sample-plugin")
+ t.Setenv("PLUGIN_TOKEN", "secret")
+ p := &Plugin{
+ Manifest: Manifest{
+ Name: "sample-plugin",
+ Description: "sample",
+ MCPServers: map[string]*MCPServer{
+ "missing": {Type: "stdio"},
+ "stdio": {
+ Type: "stdio",
+ Command: "$" + "{DWS_PLUGIN_ROOT}/bin/server",
+ Args: []string{"--data", "$" + "{DWS_PLUGIN_DATA}", "$" + "{PLUGIN_TOKEN}"},
+ Env: map[string]string{"ROOT": "$" + "{DWS_PLUGIN_ROOT}"},
+ },
+ "http-default": {
+ Type: "streamable-http",
+ Endpoint: "https://example.test",
+ CLI: json.RawMessage("{"),
+ },
+ "http-overlay": {
+ Type: "streamable-http",
+ Endpoint: "https://other.test",
+ CLI: json.RawMessage("{\"id\":\"custom\",\"command\":\"run\"}"),
+ Headers: map[string]string{"Authorization": "Bearer $" + "{PLUGIN_TOKEN}"},
+ },
+ },
+ },
+ Root: root,
+ }
+
+ if got := p.StdioClients(nil); len(got) != 1 || got[0].Key != "stdio" || got[0].Client == nil {
+ t.Fatalf("StdioClients(nil) = %#v", got)
+ }
+ if got := p.StdioClients(&UserContext{UserID: "u1", CorpID: "c1"}); len(got) != 1 {
+ t.Fatalf("StdioClients(user) length = %d", len(got))
+ }
+ descriptors := p.ToServerDescriptors()
+ if len(descriptors) != 2 {
+ t.Fatalf("descriptors length = %d", len(descriptors))
+ }
+ seenDefault := false
+ seenOverlay := false
+ for _, d := range descriptors {
+ switch d.Key {
+ case "http-default":
+ seenDefault = d.CLI.ID == "http-default" && d.CLI.Command == "http-default" && d.HasCLIMeta
+ case "http-overlay":
+ seenOverlay = d.CLI.ID == "custom" && d.CLI.Command == "run" &&
+ d.AuthHeaders["Authorization"] == "Bearer secret"
+ }
+ }
+ if !seenDefault || !seenOverlay {
+ t.Fatalf("descriptor defaults/overlay not covered: %#v", descriptors)
+ }
+ dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data")
+ if got := expandPluginVars("$"+"{DWS_PLUGIN_ROOT}|$"+"{DWS_PLUGIN_DATA}|$"+"{PLUGIN_TOKEN}", root); got != root+"|"+dataDir+"|secret" {
+ t.Fatalf("expandPluginVars = %q", got)
+ }
+}
+
+func TestHookAdapterEdges(t *testing.T) {
+ phases := map[string]pipeline.Phase{
+ " PRE-PARSE ": pipeline.PreParse,
+ "post-parse": pipeline.PostParse,
+ "pre-request": pipeline.PreRequest,
+ "post-response": pipeline.PostResponse,
+ "unknown": pipeline.PreRequest,
+ }
+ for raw, want := range phases {
+ h := NewHookAdapter("plug", HookEntry{Phase: raw, Command: "true", Timeout: 1})
+ if h.Phase() != want {
+ t.Errorf("phase %q = %v, want %v", raw, h.Phase(), want)
+ }
+ if !strings.HasPrefix(h.Name(), "plugin-hook:plug/") {
+ t.Errorf("Name = %q", h.Name())
+ }
+ }
+
+ ctx := &pipeline.Context{Command: "chat.send", Params: map[string]any{"x": 1}, Args: []string{"a"}}
+ tests := []struct {
+ name string
+ entry HookEntry
+ context *pipeline.Context
+ wantErr bool
+ }{
+ {"match success", HookEntry{Phase: "pre-request", Matcher: "chat.*", Command: "read input; test -n \"$input\""}, ctx, false},
+ {"no match", HookEntry{Matcher: "mail.*", Command: "exit 2"}, ctx, false},
+ {"invalid matcher", HookEntry{Matcher: "[", Command: "exit 2"}, ctx, false},
+ {"ordinary failure", HookEntry{Command: "echo failed; exit 1"}, ctx, false},
+ {"abort", HookEntry{Command: "echo aborted; exit 2"}, ctx, true},
+ {"marshal failure", HookEntry{Command: "exit 2"}, &pipeline.Context{Params: map[string]any{"bad": func() {}}}, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := NewHookAdapter("plug", tt.entry).Handle(tt.context)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("Handle error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+
+ h := NewHookAdapter("plug", HookEntry{Command: "sleep 1", Timeout: 0})
+ h.timeout = time.Millisecond
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("timeout is non-fatal: %v", err)
+ }
+}
+
+func TestManifestEdges(t *testing.T) {
+ dir := t.TempDir()
+ if _, err := ParseManifest(filepath.Join(dir, "missing.json")); err == nil {
+ t.Fatal("expected missing manifest error")
+ }
+ badPath := filepath.Join(dir, "bad.json")
+ if err := os.WriteFile(badPath, []byte("{"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ParseManifest(badPath); err == nil {
+ t.Fatal("expected malformed manifest error")
+ }
+
+ valid := Manifest{Name: "valid-plugin", Version: "v1.2.3-beta"}
+ for name, mutate := range map[string]func(*Manifest){
+ "absolute stdio command": func(m *Manifest) {
+ m.MCPServers = map[string]*MCPServer{"x": {Type: "stdio", Command: filepath.Join(string(os.PathSeparator), "bin", "x")}}
+ },
+ "unsupported server": func(m *Manifest) {
+ m.MCPServers = map[string]*MCPServer{"x": {Type: "other"}}
+ },
+ "unsafe hooks": func(m *Manifest) { m.Hooks = "../hooks.json" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ m := valid
+ mutate(&m)
+ if err := m.Validate("dev"); err == nil {
+ t.Fatal("expected validation error")
+ }
+ })
+ }
+ if err := valid.Validate("dev"); err != nil {
+ t.Fatalf("valid dev manifest: %v", err)
+ }
+ if err := validateSafePath("skills"); err != nil {
+ t.Fatal(err)
+ }
+ if isValidSemver("1.2") || isValidSemver("1.x.3") || !isValidSemver("v1.2.3-beta") {
+ t.Fatal("semver validation edge failed")
+ }
+ if got := compareSemver("2.0.0", "1.9.9"); got != 1 {
+ t.Fatalf("major comparison = %d", got)
+ }
+ if got := compareSemver("1.2.0", "1.1.9"); got != 1 {
+ t.Fatalf("minor comparison = %d", got)
+ }
+ if got := compareSemver("1.2.3", "1.2.4"); got != -1 {
+ t.Fatalf("patch comparison = %d", got)
+ }
+ if got := compareSemver("1.2.3", "1.2.3"); got != 0 {
+ t.Fatalf("equal comparison = %d", got)
+ }
+ if a, b, c := parseSemver("bad"); a != 0 || b != 0 || c != 0 {
+ t.Fatalf("parse invalid = %d.%d.%d", a, b, c)
+ }
+
+ p := &Plugin{Manifest: Manifest{Name: "valid-plugin"}, Root: dir}
+ if hooks, err := p.LoadHooks(); err != nil || hooks != nil {
+ t.Fatalf("empty hooks = %#v, %v", hooks, err)
+ }
+ p.Manifest.Hooks = "hooks.json"
+ if hooks, err := p.LoadHooks(); err != nil || hooks != nil {
+ t.Fatalf("missing hooks = %#v, %v", hooks, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "hooks.json"), []byte("{"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.LoadHooks(); err == nil {
+ t.Fatal("expected malformed hooks error")
+ }
+ if err := os.WriteFile(filepath.Join(dir, "hooks.json"), []byte("{\"hooks\":[{\"phase\":\"pre-request\",\"command\":\"true\"}]}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if hooks, err := p.LoadHooks(); err != nil || len(hooks.Hooks) != 1 {
+ t.Fatalf("valid hooks = %#v, %v", hooks, err)
+ }
+ if err := os.Remove(filepath.Join(dir, "hooks.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(dir, "hooks.json"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.LoadHooks(); err == nil {
+ t.Fatal("expected hooks read error")
+ }
+ if got := (&Plugin{Root: dir}).SkillsDir(); got != filepath.Join(dir, "skills") {
+ t.Fatalf("default SkillsDir = %q", got)
+ }
+ p.Manifest.Skills = "custom"
+ if got := p.SkillsDir(); got != filepath.Join(dir, "custom") {
+ t.Fatalf("custom SkillsDir = %q", got)
+ }
+}
+
+func TestLoaderDiscoveryLifecycle(t *testing.T) {
+ oldHome := pluginUserHomeDir
+ t.Cleanup(func() { pluginUserHomeDir = oldHome })
+ home := t.TempDir()
+ pluginUserHomeDir = func() (string, error) { return home, nil }
+ defaultLoader := NewLoader("1.2.3")
+ if defaultLoader.PluginsDir != filepath.Join(home, ".dws", "plugins") {
+ t.Fatalf("NewLoader path = %q", defaultLoader.PluginsDir)
+ }
+ pluginUserHomeDir = func() (string, error) { return "", errors.New("home") }
+ if got := NewLoader("dev").PluginsDir; got != filepath.Join(".dws", "plugins") {
+ t.Fatalf("NewLoader error path = %q", got)
+ }
+
+ root := t.TempDir()
+ l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+ userDir := filepath.Join(root, "user")
+ direct := filepath.Join(userDir, "direct-plugin")
+ workspace := filepath.Join(userDir, "acme")
+ nested := filepath.Join(workspace, "nested-plugin")
+ invalid := filepath.Join(userDir, "invalid-plugin")
+ incompatible := filepath.Join(userDir, "future-plugin")
+ writePluginManifest(t, direct, Manifest{Name: "direct-plugin", Version: "1.0.0", Description: "direct"})
+ writePluginManifest(t, nested, Manifest{Name: "nested-plugin", Version: "1.0.0", Description: "nested"})
+ writePluginManifest(t, invalid, Manifest{Name: "x", Version: "1.0.0"})
+ writePluginManifest(t, incompatible, Manifest{Name: "future-plugin", Version: "1.0.0", MinCLIVersion: "9.0.0"})
+ if err := os.WriteFile(filepath.Join(userDir, "not-a-dir"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(workspace, "not-a-dir"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ brokenNested := filepath.Join(workspace, "broken-plugin")
+ if err := os.MkdirAll(brokenNested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(brokenNested, "plugin.json"), []byte("{"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ l.saveSettings(&Settings{EnabledPlugins: map[string]bool{
+ "direct-plugin": false,
+ "acme/nested-plugin": true,
+ }})
+ got := l.LoadUser()
+ if len(got) != 1 || got[0].Manifest.Name != "nested-plugin" {
+ t.Fatalf("LoadUser = %#v", got)
+ }
+
+ infos := l.ListInstalled()
+ if len(infos) != 4 {
+ t.Fatalf("ListInstalled user count = %d: %#v", len(infos), infos)
+ }
+ foundDirect := false
+ foundNested := false
+ for _, info := range infos {
+ if info.Name == "direct-plugin" {
+ foundDirect = !info.Enabled && info.Type == "user" && info.Description == "direct"
+ }
+ if info.Name == "acme/nested-plugin" {
+ foundNested = info.Enabled && info.Type == "user" && info.Description == "nested"
+ }
+ }
+ if !foundDirect || !foundNested {
+ t.Fatalf("installed info missing: %#v", infos)
+ }
+
+ if err := l.SetEnabled("missing-plugin", true); err == nil {
+ t.Fatal("expected missing SetEnabled error")
+ }
+ if err := l.SetEnabled("direct-plugin", true); err != nil {
+ t.Fatal(err)
+ }
+ if got := l.LoadUser(); len(got) != 2 {
+ t.Fatalf("enabled LoadUser = %#v", got)
+ }
+ if err := l.SetEnabled("acme/nested-plugin", false); err != nil {
+ t.Fatal(err)
+ }
+ if !l.loadSettings().EnabledPlugins["direct-plugin"] {
+ t.Fatal("direct plugin was not enabled")
+ }
+ if err := l.RemovePlugin("missing-plugin", false); err == nil {
+ t.Fatal("expected missing RemovePlugin error")
+ }
+ dataDir := filepath.Join(root, configPluginDataDirForTest(), "direct-plugin")
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := l.RemovePlugin("direct-plugin", true); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(dataDir); err != nil {
+ t.Fatalf("keepData removed data: %v", err)
+ }
+ if err := l.RemovePlugin("acme/nested-plugin", false); err != nil {
+ t.Fatal(err)
+ }
+
+ plain := &Loader{PluginsDir: filepath.Join(t.TempDir(), "plugins")}
+ if plain.settingsPath() != filepath.Join(filepath.Dir(plain.PluginsDir), "settings.json") {
+ t.Fatalf("production settingsPath = %q", plain.settingsPath())
+ }
+ invalidSettings := &Loader{PluginsDir: t.TempDir()}
+ if err := os.WriteFile(invalidSettings.settingsPath(), []byte("{"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if settings := invalidSettings.loadSettings(); settings == nil || settings.EnabledPlugins != nil {
+ t.Fatalf("invalid settings = %#v", settings)
+ }
+}
+
+func configPluginDataDirForTest() string {
+ return "data"
+}
+
+func TestLoaderDevAndConfigEdges(t *testing.T) {
+ root := t.TempDir()
+ l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+ if got := l.LoadDev(); got != nil {
+ t.Fatalf("empty LoadDev = %#v", got)
+ }
+ missing := filepath.Join(t.TempDir(), "missing")
+ bad := filepath.Join(t.TempDir(), "bad")
+ valid := filepath.Join(t.TempDir(), "valid")
+ writePluginManifest(t, bad, Manifest{Name: "x", Version: "1.0.0"})
+ writePluginManifest(t, valid, Manifest{Name: "valid-plugin", Version: "1.0.0", Description: "dev"})
+ l.saveSettings(&Settings{DevPlugins: map[string]string{
+ "missing-plugin": missing,
+ "bad-plugin": bad,
+ "valid-plugin": valid,
+ }})
+ if got := l.LoadDev(); len(got) != 1 || got[0].Root != valid {
+ t.Fatalf("LoadDev = %#v", got)
+ }
+ infos := l.ListInstalled()
+ if len(infos) != 2 {
+ t.Fatalf("ListInstalled dev entries = %#v", infos)
+ }
+ if err := l.UnregisterDevPlugin("valid-plugin"); err != nil {
+ t.Fatal(err)
+ }
+
+ if l.UnsetPluginConfig("missing", "key") {
+ t.Fatal("unset missing plugin succeeded")
+ }
+ l.saveSettings(&Settings{PluginConfigs: map[string]map[string]any{
+ "plug": {"number": 1, "text": "value", "empty": "", "PATH": "/bad"},
+ }})
+ if _, ok := l.GetPluginConfig("plug", "number"); ok {
+ t.Fatal("non-string config returned")
+ }
+ if got := l.ListPluginConfig("plug"); len(got) != 3 || got["text"] != "value" {
+ t.Fatalf("ListPluginConfig mixed = %#v", got)
+ }
+ if got := l.ListPluginConfig("missing"); len(got) != 0 {
+ t.Fatalf("missing configs = %#v", got)
+ }
+ key := "DWS_PLUGIN_EDGE_CONFIG"
+ t.Setenv(key, "existing")
+ settings := l.loadSettings()
+ settings.PluginConfigs["plug"][key] = "replacement"
+ settings.PluginConfigs["plug"]["DWS_PLUGIN_EDGE_NEW"] = "new"
+ settings.PluginConfigs["plug"]["not-string"] = 7
+ l.saveSettings(settings)
+ t.Cleanup(func() { _ = os.Unsetenv("DWS_PLUGIN_EDGE_NEW") })
+ l.InjectPluginConfigEnv()
+ if os.Getenv(key) != "existing" || os.Getenv("DWS_PLUGIN_EDGE_NEW") != "new" {
+ t.Fatalf("config env injection failed: %q %q", os.Getenv(key), os.Getenv("DWS_PLUGIN_EDGE_NEW"))
+ }
+
+ empty := &Loader{PluginsDir: t.TempDir()}
+ empty.InjectPluginConfigEnv()
+ empty.saveSettings(nil)
+}
+
+func TestLoaderInstallBuildAndGit(t *testing.T) {
+ root := t.TempDir()
+ l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+ if _, err := l.InstallFromDir(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("expected missing install error")
+ }
+ invalid := t.TempDir()
+ writePluginManifest(t, invalid, Manifest{Name: "x", Version: "1.0.0"})
+ if _, err := l.InstallFromDir(invalid); err == nil {
+ t.Fatal("expected invalid install error")
+ }
+
+ src := t.TempDir()
+ writePluginManifest(t, src, Manifest{
+ Name: "built-plugin",
+ Version: "1.0.0",
+ Build: &BuildConfig{
+ Command: "mkdir -p bin && printf ok > bin/server",
+ Output: "bin/server",
+ },
+ })
+ if err := os.WriteFile(filepath.Join(src, "payload.txt"), []byte("new"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ stale := filepath.Join(root, "user", "built-plugin", "stale.txt")
+ if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ installed, err := l.InstallFromDir(src)
+ if err != nil {
+ t.Fatalf("InstallFromDir: %v", err)
+ }
+ if installed.Manifest.Name != "built-plugin" {
+ t.Fatalf("installed = %#v", installed)
+ }
+ if _, err := os.Stat(stale); !os.IsNotExist(err) {
+ t.Fatalf("stale file remains: %v", err)
+ }
+ info, err := os.Stat(filepath.Join(installed.Root, "bin", "server"))
+ if err != nil || info.Mode()&0o111 == 0 {
+ t.Fatalf("built output is not executable: %v %#o", err, info.Mode())
+ }
+
+ buildFailure := t.TempDir()
+ writePluginManifest(t, buildFailure, Manifest{
+ Name: "failed-plugin", Version: "1.0.0",
+ Build: &BuildConfig{Command: "exit 1"},
+ })
+ if _, err := l.InstallFromDir(buildFailure); err == nil {
+ t.Fatal("expected build failure")
+ }
+ if _, err := os.Stat(filepath.Join(root, "user", "failed-plugin")); !os.IsNotExist(err) {
+ t.Fatalf("failed build destination remains: %v", err)
+ }
+
+ noBuild := t.TempDir()
+ writePluginManifest(t, noBuild, Manifest{Name: "plain-plugin", Version: "1.0.0"})
+ if err := BuildPlugin(noBuild); err != nil {
+ t.Fatal(err)
+ }
+ if err := BuildPlugin(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("expected BuildPlugin parse error")
+ }
+ writePluginManifest(t, noBuild, Manifest{
+ Name: "plain-plugin", Version: "1.0.0",
+ Build: &BuildConfig{Command: "printf ok > output", Output: "output"},
+ })
+ if err := BuildPlugin(noBuild); err != nil {
+ t.Fatal(err)
+ }
+
+ for name, build := range map[string]*BuildConfig{
+ "empty command": {},
+ "absolute output": {Command: "true", Output: filepath.Join(string(os.PathSeparator), "tmp", "x")},
+ "escaping output": {Command: "true", Output: "../x"},
+ "missing output": {Command: "true", Output: "missing"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := runBuild(t.TempDir(), build); err == nil {
+ t.Fatal("expected build error")
+ }
+ })
+ }
+ if got := buildCommandFor("windows", "echo ok"); len(got.Args) < 2 || got.Args[0] != "cmd" {
+ t.Fatalf("windows build command = %#v", got.Args)
+ }
+ if got := buildCommandFor("linux", "true"); len(got.Args) < 2 || got.Args[0] != "sh" {
+ t.Fatalf("unix build command = %#v", got.Args)
+ }
+ if runtime.GOOS == "windows" {
+ t.Skip("fake git script requires a POSIX shell")
+ }
+
+ bin := t.TempDir()
+ gitScript := filepath.Join(bin, "git")
+ script := "#!/bin/sh\nlast=\"\"\nfor arg in \"$@\"; do last=\"$arg\"; done\nmkdir -p \"$last/.git\"\nprintf '%s' '{\"name\":\"git-plugin\",\"version\":\"1.0.0\",\"type\":\"user\"}' > \"$last/plugin.json\"\nprintf cloned > \"$last/content.txt\"\n"
+ if err := os.WriteFile(gitScript, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+ gitPlugin, err := l.InstallFromGit("https://example.test/acme/repository.git")
+ if err != nil {
+ t.Fatalf("InstallFromGit: %v", err)
+ }
+ if gitPlugin.Root != filepath.Join(root, "user", "acme", "git-plugin") {
+ t.Fatalf("git plugin root = %q", gitPlugin.Root)
+ }
+ if _, err := os.Stat(filepath.Join(gitPlugin.Root, ".git")); !os.IsNotExist(err) {
+ t.Fatalf(".git was copied: %v", err)
+ }
+ if _, err := l.InstallFromGit("file:///tmp/plugin"); err == nil {
+ t.Fatal("expected local git URL rejection")
+ }
+
+ failBin := t.TempDir()
+ if err := os.WriteFile(filepath.Join(failBin, "git"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", failBin)
+ if _, err := l.InstallFromGit("https://example.test/acme/fail.git"); err == nil {
+ t.Fatal("expected git clone failure")
+ }
+}
+
+func TestLoaderSystemCallEdges(t *testing.T) {
+ oldReadDir := pluginReadDir
+ oldMkdirTemp := pluginMkdirTemp
+ oldRemoveAll := pluginRemoveAll
+ oldMkdirAll := pluginMkdirAll
+ oldReadFile := pluginReadFile
+ oldWriteFile := pluginWriteFile
+ oldRemove := pluginRemove
+ oldWalk := pluginWalk
+ oldRel := pluginRel
+ oldCopyDir := pluginCopyDir
+ oldRunBuild := pluginRunBuild
+ t.Cleanup(func() {
+ pluginReadDir = oldReadDir
+ pluginMkdirTemp = oldMkdirTemp
+ pluginRemoveAll = oldRemoveAll
+ pluginMkdirAll = oldMkdirAll
+ pluginReadFile = oldReadFile
+ pluginWriteFile = oldWriteFile
+ pluginRemove = oldRemove
+ pluginWalk = oldWalk
+ pluginRel = oldRel
+ pluginCopyDir = oldCopyDir
+ pluginRunBuild = oldRunBuild
+ })
+
+ root := t.TempDir()
+ l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+ pluginReadDir = func(string) ([]os.DirEntry, error) { return nil, errors.New("read dir") }
+ if got := l.LoadUser(); got != nil {
+ t.Fatalf("LoadUser read error = %#v", got)
+ }
+ pluginReadDir = oldReadDir
+
+ workspace := filepath.Join(root, "user", "workspace")
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ pluginReadDir = func(path string) ([]os.DirEntry, error) {
+ if path == workspace {
+ return nil, errors.New("workspace read")
+ }
+ return oldReadDir(path)
+ }
+ if got := l.LoadUser(); len(got) != 0 {
+ t.Fatalf("LoadUser workspace read error = %#v", got)
+ }
+ var infos []PluginInfo
+ l.collectUserPluginInfos(workspace, "workspace", &Settings{}, &infos)
+ pluginReadDir = oldReadDir
+
+ src := t.TempDir()
+ writePluginManifest(t, src, Manifest{Name: "copy-plugin", Version: "1.0.0"})
+ pluginCopyDir = func(string, string) error { return errors.New("copy") }
+ if _, err := l.InstallFromDir(src); err == nil {
+ t.Fatal("expected install copy error")
+ }
+ pluginCopyDir = oldCopyDir
+
+ writePluginManifest(t, filepath.Join(root, "user", "remove-plugin"), Manifest{Name: "remove-plugin", Version: "1.0.0"})
+ pluginRemoveAll = func(string) error { return errors.New("remove") }
+ if err := l.RemovePlugin("remove-plugin", false); err == nil {
+ t.Fatal("expected remove error")
+ }
+ pluginRemoveAll = oldRemoveAll
+ l.purgePluginFromSettings("not-present")
+
+ l.saveSettings(&Settings{PluginConfigs: map[string]map[string]any{"bad": {"value": func() {}}}})
+ l.saveSettings(&Settings{PluginConfigs: map[string]map[string]any{"plug": {"present": "x"}}})
+ if l.UnsetPluginConfig("missing", "present") {
+ t.Fatal("unset missing plugin succeeded")
+ }
+ if l.UnsetPluginConfig("plug", "missing") {
+ t.Fatal("unset missing key succeeded")
+ }
+
+ skillRoot := t.TempDir()
+ if err := os.Mkdir(filepath.Join(skillRoot, "skills"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ oldHome := pluginUserHomeDir
+ t.Cleanup(func() { pluginUserHomeDir = oldHome })
+ home := t.TempDir()
+ pluginUserHomeDir = func() (string, error) { return home, nil }
+ pluginReadDir = func(string) ([]os.DirEntry, error) { return nil, errors.New("skills read") }
+ SyncSkills([]*Plugin{{Manifest: Manifest{Name: "skill-plugin"}, Root: skillRoot}})
+ pluginReadDir = oldReadDir
+
+ pluginMkdirTemp = func(string, string) (string, error) { return "", errors.New("temp") }
+ if _, err := l.InstallFromGit("https://host/acme/plugin.git"); err == nil {
+ t.Fatal("expected temp dir error")
+ }
+ pluginMkdirTemp = oldMkdirTemp
+}
+
+func TestInstallFromGitValidationAndBuildEdges(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("fake git script requires a POSIX shell")
+ }
+ oldCopyDir := pluginCopyDir
+ oldRunBuild := pluginRunBuild
+ t.Cleanup(func() {
+ pluginCopyDir = oldCopyDir
+ pluginRunBuild = oldRunBuild
+ })
+ bin := t.TempDir()
+ script := "#!/bin/sh\nlast=\"\"\nfor arg in \"$@\"; do last=\"$arg\"; done\nmkdir -p \"$last\"\ncase \"$FAKE_MODE\" in\nmissing) ;;\nmalformed) printf '{' > \"$last/plugin.json\" ;;\ninvalid) printf '%s' '{\"name\":\"x\",\"version\":\"1.0.0\"}' > \"$last/plugin.json\" ;;\nbuild) printf '%s' '{\"name\":\"git-build\",\"version\":\"1.0.0\",\"build\":{\"command\":\"true\"}}' > \"$last/plugin.json\" ;;\n*) printf '%s' '{\"name\":\"git-valid\",\"version\":\"1.0.0\"}' > \"$last/plugin.json\" ;;\nesac\n"
+ if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+ root := t.TempDir()
+ l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+ for _, mode := range []string{"missing", "malformed", "invalid"} {
+ t.Setenv("FAKE_MODE", mode)
+ if _, err := l.InstallFromGit("https://host/acme/repo.git"); err == nil {
+ t.Errorf("mode %q unexpectedly succeeded", mode)
+ }
+ }
+ t.Setenv("FAKE_MODE", "valid")
+ pluginCopyDir = func(string, string) error { return errors.New("copy") }
+ if _, err := l.InstallFromGit("https://host/acme/repo.git"); err == nil {
+ t.Fatal("expected git install copy error")
+ }
+ pluginCopyDir = oldCopyDir
+
+ t.Setenv("FAKE_MODE", "build")
+ pluginRunBuild = func(string, *BuildConfig) error { return errors.New("build") }
+ if _, err := l.InstallFromGit("https://host/acme/repo.git"); err == nil {
+ t.Fatal("expected git install build error")
+ }
+}
+
+func TestParseGitURLEdges(t *testing.T) {
+ for _, raw := range []string{"/tmp/x", "./x", "git@host", "git@host:single", "ftp://host/org/repo", "://bad"} {
+ if _, _, err := parseGitURL(raw); err == nil {
+ t.Errorf("parseGitURL(%q) unexpectedly succeeded", raw)
+ }
+ }
+ if ws, repo, err := parseGitURL(" http://host/org/repo/ "); err != nil || ws != "org" || repo != "repo" {
+ t.Fatalf("trimmed HTTP URL = %q/%q, %v", ws, repo, err)
+ }
+}
+
+func TestSyncSkillsEdges(t *testing.T) {
+ oldHome := pluginUserHomeDir
+ t.Cleanup(func() { pluginUserHomeDir = oldHome })
+ SyncSkills(nil)
+ pluginUserHomeDir = func() (string, error) { return "", errors.New("no home") }
+ SyncSkills([]*Plugin{{Manifest: Manifest{Name: "plugin-one"}, Root: t.TempDir()}})
+
+ home := t.TempDir()
+ pluginUserHomeDir = func() (string, error) { return home, nil }
+ if err := os.Mkdir(filepath.Join(home, ".agents"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ skills := filepath.Join(root, "skills")
+ nested := filepath.Join(skills, "nested")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(skills, "SKILL.md"), []byte("root"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nested, "child.md"), []byte("child"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ missing := &Plugin{Manifest: Manifest{Name: "missing-plugin"}, Root: t.TempDir()}
+ p := &Plugin{Manifest: Manifest{Name: "plugin-one"}, Root: root}
+ SyncSkills([]*Plugin{missing, p})
+ base := filepath.Join(home, ".agents", "skills", "dws", "plugins", "plugin-one")
+ for path, want := range map[string]string{
+ filepath.Join(base, "SKILL.md"): "root",
+ filepath.Join(base, "nested", "child.md"): "child",
+ } {
+ data, err := os.ReadFile(path)
+ if err != nil || string(data) != want {
+ t.Fatalf("synced %q = %q, %v", path, data, err)
+ }
+ }
+}
+
+func TestCopyAndStaleEdges(t *testing.T) {
+ src := t.TempDir()
+ dst := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(src, "nested"), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "same"), []byte("same"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dst, "same"), []byte("same"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "nested", "new"), []byte("new"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(src, "nested", "new"), filepath.Join(src, "link")); err != nil {
+ t.Fatal(err)
+ }
+ if err := copyDir(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(filepath.Join(dst, "link")); !os.IsNotExist(err) {
+ t.Fatalf("symlink copied: %v", err)
+ }
+ staleFile := filepath.Join(dst, "stale")
+ staleDir := filepath.Join(dst, "stale-dir")
+ if err := os.WriteFile(staleFile, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(staleDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ removeStaleFiles(src, dst)
+ if _, err := os.Stat(staleFile); !os.IsNotExist(err) {
+ t.Fatalf("stale file remains: %v", err)
+ }
+ if _, err := os.Stat(staleDir); !os.IsNotExist(err) {
+ t.Fatalf("stale dir remains: %v", err)
+ }
+ if err := copyDir(filepath.Join(src, "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
+ t.Fatal("expected missing source error")
+ }
+ fileDst := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(fileDst, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := copyDir(src, fileDst); err == nil {
+ t.Fatal("expected destination mkdir error")
+ }
+ removeStaleFiles(filepath.Join(src, "missing"), filepath.Join(dst, "missing"))
+}
+
+func TestCopyAndStaleSystemCallEdges(t *testing.T) {
+ oldMkdirAll := pluginMkdirAll
+ oldReadFile := pluginReadFile
+ oldWriteFile := pluginWriteFile
+ oldRemove := pluginRemove
+ oldWalk := pluginWalk
+ oldRel := pluginRel
+ t.Cleanup(func() {
+ pluginMkdirAll = oldMkdirAll
+ pluginReadFile = oldReadFile
+ pluginWriteFile = oldWriteFile
+ pluginRemove = oldRemove
+ pluginWalk = oldWalk
+ pluginRel = oldRel
+ })
+
+ src := t.TempDir()
+ dst := filepath.Join(t.TempDir(), "dst")
+ file := filepath.Join(src, "file")
+ if err := os.WriteFile(file, []byte("data"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ pluginWalk = func(string, filepath.WalkFunc) error { return errors.New("walk") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected walk error")
+ }
+ pluginWalk = func(_ string, fn filepath.WalkFunc) error {
+ return fn(file, info, errors.New("visit"))
+ }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected visit error")
+ }
+ pluginWalk = func(_ string, fn filepath.WalkFunc) error { return fn(file, info, nil) }
+ pluginRel = func(string, string) (string, error) { return "", errors.New("rel") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected rel error")
+ }
+ pluginRel = func(string, string) (string, error) { return "../escape", nil }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected traversal error")
+ }
+ pluginRel = oldRel
+ pluginReadFile = func(string) ([]byte, error) { return nil, errors.New("read") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected source read error")
+ }
+ pluginReadFile = oldReadFile
+ pluginWriteFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected destination write error")
+ }
+ pluginWriteFile = oldWriteFile
+ pluginWalk = oldWalk
+
+ pluginMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected initial mkdir error")
+ }
+ pluginMkdirAll = oldMkdirAll
+
+ removeStaleFiles(src, t.TempDir())
+ pluginWalk = func(root string, fn filepath.WalkFunc) error {
+ return fn(root, nil, errors.New("walk item"))
+ }
+ removeStaleFiles(src, dst)
+
+ call := 0
+ pluginWalk = func(root string, fn filepath.WalkFunc) error {
+ call++
+ rootInfo, statErr := os.Stat(root)
+ if statErr != nil {
+ return statErr
+ }
+ return fn(root, rootInfo, nil)
+ }
+ pluginRel = func(string, string) (string, error) { return "", errors.New("rel") }
+ removeStaleFiles(src, dst)
+ if call != 2 {
+ t.Fatalf("walk calls = %d", call)
+ }
+
+ pluginWalk = oldWalk
+ pluginRel = oldRel
+ staleDst := t.TempDir()
+ stale := filepath.Join(staleDst, "stale")
+ if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ pluginRemove = func(string) error { return errors.New("remove") }
+ removeStaleFiles(src, staleDst)
+}
+
+func TestCopyAndStaleSystemCallEdgesDuplicate(t *testing.T) {
+ oldMkdirAll := pluginMkdirAll
+ oldReadFile := pluginReadFile
+ oldWriteFile := pluginWriteFile
+ oldRemove := pluginRemove
+ oldWalk := pluginWalk
+ oldRel := pluginRel
+ t.Cleanup(func() {
+ pluginMkdirAll = oldMkdirAll
+ pluginReadFile = oldReadFile
+ pluginWriteFile = oldWriteFile
+ pluginRemove = oldRemove
+ pluginWalk = oldWalk
+ pluginRel = oldRel
+ })
+
+ src := t.TempDir()
+ dst := filepath.Join(t.TempDir(), "dst")
+ file := filepath.Join(src, "file")
+ if err := os.WriteFile(file, []byte("data"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ pluginWalk = func(string, filepath.WalkFunc) error { return errors.New("walk") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected walk error")
+ }
+ pluginWalk = func(_ string, fn filepath.WalkFunc) error {
+ return fn(file, info, errors.New("visit"))
+ }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected visit error")
+ }
+ pluginWalk = func(_ string, fn filepath.WalkFunc) error { return fn(file, info, nil) }
+ pluginRel = func(string, string) (string, error) { return "", errors.New("rel") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected rel error")
+ }
+ pluginRel = func(string, string) (string, error) { return "../escape", nil }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected traversal error")
+ }
+ pluginRel = oldRel
+ pluginReadFile = func(string) ([]byte, error) { return nil, errors.New("read") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected source read error")
+ }
+ pluginReadFile = oldReadFile
+ pluginWriteFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected destination write error")
+ }
+ pluginWriteFile = oldWriteFile
+ pluginWalk = oldWalk
+
+ pluginMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
+ if err := copyDir(src, dst); err == nil {
+ t.Fatal("expected initial mkdir error")
+ }
+ pluginMkdirAll = oldMkdirAll
+
+ removeStaleFiles(src, t.TempDir())
+ pluginWalk = func(root string, fn filepath.WalkFunc) error {
+ return fn(root, nil, errors.New("walk item"))
+ }
+ removeStaleFiles(src, dst)
+
+ call := 0
+ pluginWalk = func(root string, fn filepath.WalkFunc) error {
+ call++
+ rootInfo, statErr := os.Stat(root)
+ if statErr != nil {
+ return statErr
+ }
+ return fn(root, rootInfo, nil)
+ }
+ pluginRel = func(string, string) (string, error) { return "", errors.New("rel") }
+ removeStaleFiles(src, dst)
+ if call != 2 {
+ t.Fatalf("walk calls = %d", call)
+ }
+
+ pluginWalk = oldWalk
+ pluginRel = oldRel
+ staleDst := t.TempDir()
+ stale := filepath.Join(staleDst, "stale")
+ if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ pluginRemove = func(string) error { return errors.New("remove") }
+ removeStaleFiles(src, staleDst)
+}
diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go
index 308eec8e..cbb8a677 100644
--- a/internal/plugin/loader.go
+++ b/internal/plugin/loader.go
@@ -39,9 +39,24 @@ type Loader struct {
CLIVersion string
}
+var (
+ pluginUserHomeDir = os.UserHomeDir
+ pluginReadDir = os.ReadDir
+ pluginMkdirTemp = os.MkdirTemp
+ pluginRemoveAll = os.RemoveAll
+ pluginMkdirAll = os.MkdirAll
+ pluginReadFile = os.ReadFile
+ pluginWriteFile = os.WriteFile
+ pluginRemove = os.Remove
+ pluginWalk = filepath.Walk
+ pluginRel = filepath.Rel
+ pluginCopyDir = copyDir
+ pluginRunBuild = runBuild
+)
+
// NewLoader creates a Loader with default paths.
func NewLoader(cliVersion string) *Loader {
- home, _ := os.UserHomeDir()
+ home, _ := pluginUserHomeDir()
return &Loader{
PluginsDir: filepath.Join(home, ".dws", "plugins"),
CLIVersion: cliVersion,
@@ -63,7 +78,7 @@ func (l *Loader) LoadUser() []*Plugin {
var plugins []*Plugin
// User plugins may be nested: user/{workspace}/{name}/
- entries, err := os.ReadDir(userDir)
+ entries, err := pluginReadDir(userDir)
if err != nil {
if !os.IsNotExist(err) {
slog.Debug("plugin: cannot read user dir", "path", userDir, "error", err)
@@ -87,7 +102,7 @@ func (l *Loader) LoadUser() []*Plugin {
}
// Otherwise treat as workspace directory: user/{workspace}/{name}/
- subEntries, err := os.ReadDir(entryPath)
+ subEntries, err := pluginReadDir(entryPath)
if err != nil {
continue
}
@@ -235,7 +250,7 @@ func (l *Loader) collectUserPluginInfos(dir, prefix string, settings *Settings,
return
}
// Workspace: dir is a workspace, iterate sub-plugins
- subEntries, err := os.ReadDir(dir)
+ subEntries, err := pluginReadDir(dir)
if err != nil {
return
}
@@ -273,7 +288,7 @@ func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) {
}
destDir := filepath.Join(l.PluginsDir, "user", manifest.Name)
- if err := copyDir(srcDir, destDir); err != nil {
+ if err := pluginCopyDir(srcDir, destDir); err != nil {
return nil, fmt.Errorf("install failed: %w", err)
}
@@ -282,7 +297,7 @@ func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) {
// Run build if configured (compile server to binary).
if manifest.Build != nil {
- if err := runBuild(destDir, manifest.Build); err != nil {
+ if err := pluginRunBuild(destDir, manifest.Build); err != nil {
// Clean up on build failure.
_ = os.RemoveAll(destDir)
return nil, fmt.Errorf("plugin build failed: %w", err)
@@ -307,11 +322,11 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
}
// Clone to temp directory.
- tmpDir, err := os.MkdirTemp("", "dws-plugin-git-*")
+ tmpDir, err := pluginMkdirTemp("", "dws-plugin-git-*")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
- defer os.RemoveAll(tmpDir)
+ defer pluginRemoveAll(tmpDir)
cloneDir := filepath.Join(tmpDir, repoName)
cmd := exec.Command("git", "clone", "--depth", "1", gitURL, cloneDir)
@@ -336,17 +351,17 @@ func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
destDir := filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
// Remove .git directory before copying.
- _ = os.RemoveAll(filepath.Join(cloneDir, ".git"))
+ _ = pluginRemoveAll(filepath.Join(cloneDir, ".git"))
- if err := copyDir(cloneDir, destDir); err != nil {
+ if err := pluginCopyDir(cloneDir, destDir); err != nil {
return nil, fmt.Errorf("install failed: %w", err)
}
// Run build if configured (compile server to binary).
if manifest.Build != nil {
- if err := runBuild(destDir, manifest.Build); err != nil {
+ if err := pluginRunBuild(destDir, manifest.Build); err != nil {
// Clean up on build failure.
- _ = os.RemoveAll(destDir)
+ _ = pluginRemoveAll(destDir)
return nil, fmt.Errorf("plugin build failed: %w", err)
}
}
@@ -413,13 +428,13 @@ func (l *Loader) RemovePlugin(name string, keepData bool) error {
return fmt.Errorf("plugin %q not found", name)
}
- if err := os.RemoveAll(pluginDir); err != nil {
+ if err := pluginRemoveAll(pluginDir); err != nil {
return fmt.Errorf("failed to remove plugin: %w", err)
}
if !keepData {
dataDir := filepath.Join(l.PluginsDir, config.PluginDataDir, name)
- _ = os.RemoveAll(dataDir)
+ _ = pluginRemoveAll(dataDir)
}
l.purgePluginFromSettings(name)
@@ -457,19 +472,12 @@ func (l *Loader) SetEnabled(name string, enabled bool) error {
}
func (l *Loader) findUserPluginDir(name string) string {
- // Try direct: user/{name}/
+ // name may be direct ("plugin") or qualified ("workspace/plugin").
+ // filepath.Join handles both forms.
dir := filepath.Join(l.PluginsDir, "user", name)
if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err == nil {
return dir
}
- // Try workspace: user/{workspace}/{plugin}/
- parts := strings.SplitN(name, "/", 2)
- if len(parts) == 2 {
- dir = filepath.Join(l.PluginsDir, "user", parts[0], parts[1])
- if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err == nil {
- return dir
- }
- }
return ""
}
@@ -663,7 +671,7 @@ func SyncSkills(plugins []*Plugin) {
return
}
- homeDir, err := os.UserHomeDir()
+ homeDir, err := pluginUserHomeDir()
if err != nil {
slog.Debug("plugin: cannot get home dir for skill sync", "error", err)
return
@@ -685,7 +693,7 @@ func SyncSkills(plugins []*Plugin) {
}
// Walk the plugin's skills directory and copy files to each agent dir.
- entries, err := os.ReadDir(skillsDir)
+ entries, err := pluginReadDir(skillsDir)
if err != nil {
continue
}
@@ -703,7 +711,7 @@ func SyncSkills(plugins []*Plugin) {
// Place plugin skills under dws/plugins/{plugin-name}/
dest := filepath.Join(agentBase, "dws", "plugins", p.Manifest.Name, entry.Name())
if entry.IsDir() {
- _ = copyDir(src, dest)
+ _ = pluginCopyDir(src, dest)
} else {
_ = os.MkdirAll(filepath.Dir(dest), 0o755)
data, readErr := os.ReadFile(src)
@@ -751,12 +759,7 @@ func runBuild(pluginDir string, build *BuildConfig) error {
slog.Info("plugin: building", "dir", pluginDir, "command", build.Command)
- var cmd *exec.Cmd
- if runtime.GOOS == "windows" {
- cmd = exec.Command("cmd", "/C", build.Command)
- } else {
- cmd = exec.Command("sh", "-c", build.Command)
- }
+ cmd := buildCommandFor(runtime.GOOS, build.Command)
cmd.Dir = pluginDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -785,16 +788,23 @@ func runBuild(pluginDir string, build *BuildConfig) error {
return nil
}
+func buildCommandFor(goos, command string) *exec.Cmd {
+ if goos == "windows" {
+ return exec.Command("cmd", "/C", command)
+ }
+ return exec.Command("sh", "-c", command)
+}
+
// copyDir recursively copies src to dst, skipping files whose content
// is identical to the destination. This avoids overwriting locked
// executables (e.g. a running stdio plugin on Windows).
// Symlinks are skipped for security (prevents path traversal attacks).
func copyDir(src, dst string) error {
cleanDst := filepath.Clean(dst) + string(os.PathSeparator)
- if err := os.MkdirAll(dst, 0o755); err != nil {
+ if err := pluginMkdirAll(dst, 0o755); err != nil {
return err
}
- return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+ return pluginWalk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
@@ -802,7 +812,7 @@ func copyDir(src, dst string) error {
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
- rel, err := filepath.Rel(src, path)
+ rel, err := pluginRel(src, path)
if err != nil {
return err
}
@@ -812,9 +822,9 @@ func copyDir(src, dst string) error {
return fmt.Errorf("path traversal detected: %s", rel)
}
if info.IsDir() {
- return os.MkdirAll(target, info.Mode())
+ return pluginMkdirAll(target, info.Mode())
}
- data, err := os.ReadFile(path)
+ data, err := pluginReadFile(path)
if err != nil {
return err
}
@@ -824,7 +834,7 @@ func copyDir(src, dst string) error {
return nil
}
}
- return os.WriteFile(target, data, info.Mode())
+ return pluginWriteFile(target, data, info.Mode())
})
}
@@ -832,11 +842,11 @@ func copyDir(src, dst string) error {
// Best-effort: errors are logged but do not fail the install.
func removeStaleFiles(src, dst string) {
srcSet := make(map[string]struct{})
- _ = filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+ _ = pluginWalk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
- rel, relErr := filepath.Rel(src, path)
+ rel, relErr := pluginRel(src, path)
if relErr != nil {
return nil
}
@@ -844,11 +854,11 @@ func removeStaleFiles(src, dst string) {
return nil
})
- _ = filepath.Walk(dst, func(path string, info os.FileInfo, err error) error {
+ _ = pluginWalk(dst, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
- rel, relErr := filepath.Rel(dst, path)
+ rel, relErr := pluginRel(dst, path)
if relErr != nil {
return nil
}
@@ -857,10 +867,10 @@ func removeStaleFiles(src, dst string) {
}
if _, exists := srcSet[rel]; !exists {
if info.IsDir() {
- _ = os.RemoveAll(path)
+ _ = pluginRemoveAll(path)
return filepath.SkipDir
}
- if removeErr := os.Remove(path); removeErr != nil {
+ if removeErr := pluginRemove(path); removeErr != nil {
slog.Debug("plugin: failed to remove stale file", "path", path, "error", removeErr)
}
}
diff --git a/internal/recovery/coverage_edges_test.go b/internal/recovery/coverage_edges_test.go
new file mode 100644
index 00000000..b61ce482
--- /dev/null
+++ b/internal/recovery/coverage_edges_test.go
@@ -0,0 +1,821 @@
+package recovery
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
+)
+
+func TestRecoveryModelEdges(t *testing.T) {
+ typedCases := []struct {
+ name string
+ err error
+ raw string
+ want string
+ }{
+ {"validation required", apperrors.NewValidation("required value"), "", cliMissingParamCode},
+ {"validation invalid", apperrors.NewValidation("bad json"), "", cliInvalidJSONCode},
+ {"auth permission reason", apperrors.NewAuth("denied", apperrors.WithReason("http_403")), "", cliPermissionCode},
+ {"auth forbidden", apperrors.NewAuth("forbidden"), "", cliPermissionCode},
+ {"auth expired", apperrors.NewAuth("expired"), "", cliAuthExpiredCode},
+ {"api rate reason", apperrors.NewAPI("limited", apperrors.WithReason("http_429")), "", cliRateLimitCode},
+ {"api rate message", apperrors.NewAPI("rate limit", apperrors.WithRetryable(true)), "", cliRateLimitCode},
+ {"api timeout reason", apperrors.NewAPI("failed", apperrors.WithReason("request_timeout")), "", cliTimeoutCode},
+ {"api timeout message", apperrors.NewDiscovery("connection reset"), "", cliTimeoutCode},
+ {"api invalid params", apperrors.NewAPI("failed", apperrors.WithReason("invalid_params")), "", cliInvalidJSONCode},
+ {"api method missing", apperrors.NewAPI("failed", apperrors.WithReason("method_not_found")), "", cliResourceNotFoundCode},
+ {"api 404", apperrors.NewAPI("failed", apperrors.WithReason("http_404")), "", cliResourceNotFoundCode},
+ {"raw auth", nil, "user_token_illegal", cliAuthExpiredCode},
+ {"raw permission", nil, "permission denied", cliPermissionCode},
+ {"raw rate", nil, "too many requests", cliRateLimitCode},
+ {"raw timeout", nil, "deadline exceeded", cliTimeoutCode},
+ {"raw missing", nil, "base_not_found", cliResourceNotFoundCode},
+ {"unknown", nil, "strange", ""},
+ }
+ for _, tt := range typedCases {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := canonicalCLIErrorCode(tt.err, tt.raw); got != tt.want {
+ t.Fatalf("canonicalCLIErrorCode = %q, want %q", got, tt.want)
+ }
+ })
+ }
+
+ for _, name := range []string{"get_x", "list_x", "search_x", "query_x", "status_x", "download_x"} {
+ if InferOperationKind(name) != OperationRead {
+ t.Errorf("%q should be read", name)
+ }
+ }
+ for _, name := range []string{"create_x", "update_x", "delete_x", "send_x", "generate_image", "edit_image", "upscale", "isolate"} {
+ if InferOperationKind(name) != OperationWrite {
+ t.Errorf("%q should be write", name)
+ }
+ }
+ if InferOperationKind("") != OperationUnknown || InferOperationKind("execute") != OperationUnknown {
+ t.Fatal("unknown operation inference failed")
+ }
+
+ input := map[string]any{
+ "short": "value",
+ "long": strings.Repeat("x", 121),
+ "text": "secret body",
+ "names": []string{"a", "b"},
+ "textItems": []string{"a"},
+ "items": []any{"a", map[string]any{"x": 1}},
+ "records": []map[string]any{{"name": "n"}},
+ "objects": []map[string]any{{"name": "n", "token": "drop"}},
+ "payload": map[string]any{"name": "n", "token": "drop"},
+ "fields": map[string]any{"z": 1, "a": 2},
+ "stringMap": map[string]string{"name": "n", "token": "drop"},
+ "jsonMap": map[string]string{"z": "1", "a": "2"},
+ "number": 7,
+ "token": "drop",
+ }
+ summary := SummarizeArgs(input)
+ if len(summary) == 0 || summary["number"] != 7 {
+ t.Fatalf("summary = %#v", summary)
+ }
+ replayed := sanitizeReplayMap(input)
+ if _, ok := replayed["token"]; ok {
+ t.Fatal("sensitive replay field retained")
+ }
+ for _, value := range []any{
+ map[string]any{"safe": "x", "token": "drop"},
+ map[string]string{"safe": "x", "token": "drop"},
+ []any{map[string]any{"safe": "x"}},
+ []string{"a"},
+ []map[string]any{{"safe": "x"}},
+ 42,
+ } {
+ if sanitizeReplayValue(value) == nil {
+ t.Fatalf("sanitizeReplayValue(%T) returned nil", value)
+ }
+ }
+ if sanitizeReplayMap(nil) != nil || sanitizeReplayStringMap(nil) != nil {
+ t.Fatal("empty replay maps should be nil")
+ }
+ if got := sanitizeReplayField("data", map[string]string{"safe": "x"}); got == nil {
+ t.Fatal("string map sanitization failed")
+ }
+ if got := sanitizeReplayField("data", []map[string]any{{"safe": "x"}}); got == nil {
+ t.Fatal("map slice sanitization failed")
+ }
+ if got := sanitizeReplayField("data", strings.Repeat("x", 121)); got == nil {
+ t.Fatal("long string sanitization failed")
+ }
+
+ argv := sanitizeArgv([]string{
+ "dws", "--token=one", "--auth-code=two", "--refresh-token=three",
+ "--auth-code", "four", "--refresh-token", "five", "--plain",
+ })
+ if len(argv) != 9 || strings.Contains(strings.Join(argv, " "), "one") {
+ t.Fatalf("sanitized argv = %#v", argv)
+ }
+ if sanitizeArgv(nil) != nil {
+ t.Fatal("nil argv should remain nil")
+ }
+ if value, ok := redactSensitiveArg("--plain=x"); ok || value != "" {
+ t.Fatalf("plain arg redacted: %q %v", value, ok)
+ }
+
+ if keys := sortedKeys(map[string]any{"b": 1, "a": 2}); strings.Join(keys, ",") != "a,b" {
+ t.Fatalf("sorted keys = %#v", keys)
+ }
+ if keys := sortedStringKeys(map[string]string{"b": "1", "a": "2"}); strings.Join(keys, ",") != "a,b" {
+ t.Fatalf("sorted string keys = %#v", keys)
+ }
+ normalized := normalizeRawError(" HTTPS://example.test/x?q=secret 123456 2026-07-13T01:02:03Z abcdefghijklmnopqrstuvwxyz ")
+ if strings.Contains(normalized, "secret") || strings.Contains(normalized, "123456") {
+ t.Fatalf("normalizeRawError = %q", normalized)
+ }
+
+ ctx := BuildContext(CaptureInput{ToolName: "get_x"})
+ if ctx.OperationKind != OperationRead || ctx.RawError != "" {
+ t.Fatalf("BuildContext defaults = %#v", ctx)
+ }
+ replay := BuildReplay(CaptureInput{ToolName: "create_x"})
+ if replay.OperationKind != OperationWrite || replay.RedactedCommand != "" {
+ t.Fatalf("BuildReplay defaults = %#v", replay)
+ }
+}
+
+func TestPlannerRuleAndSearchEdges(t *testing.T) {
+ rules := []struct {
+ name string
+ rc RecoveryContext
+ category string
+ retry bool
+ }{
+ {"input", RecoveryContext{CLIErrorCode: cliInvalidJSONCode}, "input", false},
+ {"resource suffix", RecoveryContext{CLIErrorCode: "THING_NOT_FOUND"}, "resource", false},
+ {"permission", RecoveryContext{HTTPStatus: 403}, "permission", false},
+ {"rate read", RecoveryContext{HTTPStatus: 429, OperationKind: OperationRead}, "rate_limit", true},
+ {"rate write http", RecoveryContext{HTTPStatus: 429, OperationKind: OperationWrite, CallStage: "http"}, "rate_limit", true},
+ {"rate write non-http", RecoveryContext{HTTPStatus: 429, OperationKind: OperationWrite, CallStage: "decode"}, "rate_limit", false},
+ {"network read", RecoveryContext{HTTPStatus: 503, OperationKind: OperationRead}, "network", true},
+ {"network write", RecoveryContext{RawError: "timeout", OperationKind: OperationWrite, CallStage: "http"}, "network", false},
+ }
+ for _, tt := range rules {
+ t.Run(tt.name, func(t *testing.T) {
+ plan := NewPlanner(nil).PlanWithOptions(context.Background(), tt.rc, PlanOptions{EnableDocSearch: false})
+ if plan.Category != tt.category || plan.ShouldRetry != tt.retry {
+ t.Fatalf("plan = %#v", plan)
+ }
+ })
+ }
+ if safeToRetry(RecoveryContext{OperationKind: OperationUnknown}, "network") {
+ t.Fatal("unknown operation should not retry")
+ }
+ if normalizeDecisionOwner(RecoveryPlan{DecisionOwner: DecisionOwnerAgent}) != DecisionOwnerAgent ||
+ normalizeDecisionOwner(RecoveryPlan{Category: "unknown"}) != DecisionOwnerAgent ||
+ normalizeDecisionOwner(RecoveryPlan{Category: "auth"}) != DecisionOwnerBuiltinRule {
+ t.Fatal("decision owner normalization failed")
+ }
+
+ queryRC := RecoveryContext{CLIErrorCode: "CODE", ToolName: "get_x", CommandPath: []string{"product", "get"}, RawError: "Error alpha alpha beta 123456"}
+ if query := BuildFallbackQuery(queryRC); !strings.Contains(query, "CODE") || !strings.Contains(query, "alpha") {
+ t.Fatalf("fallback query = %q", query)
+ }
+ if got := stableKeywords(""); got != nil {
+ t.Fatalf("empty stable keywords = %#v", got)
+ }
+ many := stableKeywords("one two three four five six seven eight")
+ if len(many) != 6 {
+ t.Fatalf("stable keyword cap = %#v", many)
+ }
+ if containsAny("Hello WORLD", "none", "world") != true || containsAny("hello", "x") {
+ t.Fatal("containsAny failed")
+ }
+ if got := normalizeSafeActions([]string{"wait_and_retry", "unsafe", "wait_and_retry"}); len(got) != 1 {
+ t.Fatalf("safe actions = %#v", got)
+ }
+
+ hit := KBHit{
+ Source: "docs", Title: "errors", URL: "https://docs",
+ Snippet: "| httpCode | code | message | reason | action |\n| --- | --- | --- | --- | --- |\n| 403 | Forbidden | denied | permission | Request access |\n| 404 | Missing | gone | deleted | %s |\n| bad | short | row |",
+ }
+ retrieval := KnowledgeRetrieval{
+ KBHits: []KBHit{hit, hit},
+ DocSearch: DocSearch{
+ Request: &ToolCallRecord{ServerID: "docs", ToolName: "search", Arguments: map[string]any{"q": "x"}},
+ Response: &ToolResponse{Content: []ToolResponseBlock{{Type: "text", Text: "ok"}}},
+ Items: []DocSearchItem{{Title: "one"}},
+ },
+ }
+ plan := NewPlanner(fakeRetriever{retrieval: retrieval}).Plan(context.Background(), RecoveryContext{RawError: "unknown issue"})
+ if plan.DocSearch.Status != "success" || len(plan.KBHits) != 1 || len(plan.DocActions) != 1 {
+ t.Fatalf("retrieved plan = %#v", plan)
+ }
+ if !plan.DecisionHints.PermissionSensitive || !plan.DecisionHints.ResourceStateRelated {
+ t.Fatalf("decision hints = %#v", plan.DecisionHints)
+ }
+
+ emptyPlan := NewPlanner(fakeRetriever{}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
+ if emptyPlan.DocSearch.Status != "empty" {
+ t.Fatalf("empty search = %#v", emptyPlan.DocSearch)
+ }
+ errPlan := NewPlanner(fakeRetriever{err: errors.New("docs down")}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
+ if errPlan.DocSearch.Status != "error" || errPlan.DocSearch.Error != "docs down" {
+ t.Fatalf("error search = %#v", errPlan.DocSearch)
+ }
+ customErr := NewPlanner(fakeRetriever{
+ retrieval: KnowledgeRetrieval{DocSearch: DocSearch{Status: "error", Error: "custom"}},
+ err: errors.New("docs down"),
+ }).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
+ if customErr.DocSearch.Error != "custom" {
+ t.Fatalf("custom search error = %#v", customErr.DocSearch)
+ }
+ emptyErr := NewPlanner(fakeRetriever{
+ retrieval: KnowledgeRetrieval{DocSearch: DocSearch{Status: "error"}},
+ }).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
+ if emptyErr.DocSearch.Status != "error" {
+ t.Fatalf("empty search error = %#v", emptyErr.DocSearch)
+ }
+ skipped := NewPlanner(nil).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
+ if skipped.DocSearch.Status != "skipped" {
+ t.Fatalf("nil retriever = %#v", skipped.DocSearch)
+ }
+ var nilPlanner *Planner
+ if got := nilPlanner.searchKnowledge(context.Background(), "query", RecoveryContext{}); got.DocSearch.Status != "skipped" {
+ t.Fatalf("nil planner search = %#v", got)
+ }
+ if got := NewPlanner(nil).searchKnowledge(context.Background(), "", RecoveryContext{}); got.DocSearch.Status != "skipped" {
+ t.Fatalf("empty query search = %#v", got)
+ }
+
+ actions := extractDocActions([]KBHit{{Snippet: ""}, hit})
+ if len(actions) != 1 || len(splitTableRow("| a | b |")) != 2 {
+ t.Fatalf("doc actions = %#v", actions)
+ }
+ if len(dedupeDocActions(append(actions, actions...))) != 1 ||
+ len(dedupeKBHits([]KBHit{hit, hit})) != 1 {
+ t.Fatal("dedupe failed")
+ }
+ if cloneDocActions(nil) != nil || cloneKBHits(nil) != nil {
+ t.Fatal("nil clone should remain nil")
+ }
+ if got := cloneDocSearch(retrieval.DocSearch); got.Request == retrieval.DocSearch.Request || got.Response == retrieval.DocSearch.Response {
+ t.Fatal("doc search clone retained pointers")
+ }
+ if got := stableKeywords("a ok https://example.test/path?q=x valid"); len(got) != 1 || got[0] != "valid" {
+ t.Fatalf("placeholder/small keyword filtering = %#v", got)
+ }
+ if got := uniqueStrings([]string{" ", "one", "one"}); len(got) != 1 {
+ t.Fatalf("unique strings = %#v", got)
+ }
+ if got := extractDocActions([]KBHit{{Snippet: "ordinary text\n| 1 | 2 | 3 | 4 | act |"}}); len(got) != 1 {
+ t.Fatalf("non-table doc line handling = %#v", got)
+ }
+ var nilPlan *RecoveryPlan
+ HydratePlanForEvent("evt", RecoveryContext{}, Replay{}, nilPlan)
+}
+
+type fakeRecoveryInvoker struct {
+ result *transport.ToolCallResult
+ err error
+}
+
+func (f fakeRecoveryInvoker) CallToolDirect(context.Context, string, string, map[string]any) (*transport.ToolCallResult, error) {
+ return f.result, f.err
+}
+
+func TestExecutorAndProbeEdges(t *testing.T) {
+ last := LastError{
+ EventID: "evt-1",
+ Context: RecoveryContext{
+ CommandPath: []string{"aitable", "base", "get"},
+ ServerID: "aitable",
+ ToolName: "get_base",
+ OperationKind: OperationRead,
+ ArgsSummary: map[string]any{"baseId": "base_1"},
+ RawError: "unknown",
+ },
+ Replay: Replay{
+ ServerID: "aitable",
+ ToolName: "get_base",
+ OperationKind: OperationRead,
+ ToolArgs: map[string]any{"baseId": "base_1"},
+ RedactedCommand: "dws aitable base get",
+ },
+ }
+ if got := (*Executor)(nil).Execute(context.Background(), last); got.Status != "analysis_failed" {
+ t.Fatalf("nil executor = %#v", got)
+ }
+ if got := (&Executor{}).Execute(context.Background(), last); got.Status != "analysis_failed" {
+ t.Fatalf("nil planner executor = %#v", got)
+ }
+
+ result := &transport.ToolCallResult{Blocks: []transport.ContentBlock{{Type: "text", Text: "{\"bases\":[1]}"}}}
+ executor := NewExecutor(NewPlanner(fakeRetriever{}), fakeRecoveryInvoker{result: result})
+ bundle := executor.Execute(context.Background(), last)
+ if bundle.Status != "needs_agent_action" || len(bundle.ProbeResults) != 2 ||
+ bundle.Plan.AgentRoute.Payload == nil || len(bundle.Plan.AgentRoute.Payload.ProbeResults) != 2 {
+ t.Fatalf("successful bundle = %#v", bundle)
+ }
+ if bundle.FinalizeHint.Command == "" || len(bundle.AgentTask.MustReadRefs) != 4 {
+ t.Fatalf("agent handoff metadata = %#v %#v", bundle.FinalizeHint, bundle.AgentTask)
+ }
+
+ failing := NewExecutor(NewPlanner(fakeRetriever{err: errors.New("docs")}), fakeRecoveryInvoker{err: errors.New("probe")})
+ failedBundle := failing.Execute(context.Background(), LastError{EventID: "evt-2", Context: last.Context})
+ if failedBundle.Status != "analysis_failed" ||
+ !strings.Contains(failedBundle.AnalysisError, "replay data missing") ||
+ !strings.Contains(failedBundle.AnalysisError, "doc search failed") {
+ t.Fatalf("failed bundle = %#v", failedBundle)
+ }
+
+ executor.probes = nil
+ if got := executor.runProbes(context.Background(), last.Context, last.Replay, RecoveryPlan{}); got != nil {
+ t.Fatalf("empty probes = %#v", got)
+ }
+ executor.probes = []Probe{
+ func(context.Context, ToolInvoker, RecoveryContext, Replay, RecoveryPlan) *ProbeResult { return nil },
+ func(context.Context, ToolInvoker, RecoveryContext, Replay, RecoveryPlan) *ProbeResult {
+ return &ProbeResult{Name: "custom", Status: "success"}
+ },
+ }
+ if got := executor.runProbes(context.Background(), last.Context, last.Replay, RecoveryPlan{}); len(got) != 1 {
+ t.Fatalf("custom probes = %#v", got)
+ }
+
+ if got := probeUnknownContextAudit(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{Category: "auth"}); got != nil {
+ t.Fatalf("built-in plan audit = %#v", got)
+ }
+ audit := probeUnknownContextAudit(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{Category: "unknown"})
+ if audit == nil || !strings.Contains(audit.Summary, "no identifier") {
+ t.Fatalf("empty audit = %#v", audit)
+ }
+ fullAudit := probeUnknownContextAudit(
+ context.Background(),
+ nil,
+ RecoveryContext{
+ CommandPath: []string{"x"},
+ ArgsSummary: map[string]any{"empty_id": "", "name": "n", "record_id": "r1"},
+ },
+ Replay{ToolArgs: map[string]any{"uuid": "u1"}, RedactedCommand: "dws x"},
+ RecoveryPlan{
+ Category: "unknown",
+ DocSearch: DocSearch{Status: "success"},
+ KBHits: []KBHit{{Title: "hit"}},
+ DocActions: []DocAction{{Action: "act"}},
+ },
+ )
+ if fullAudit == nil || !strings.Contains(fullAudit.Summary, "found 2") {
+ t.Fatalf("full audit = %#v", fullAudit)
+ }
+ if got := probeAITableBaseCatalog(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{}); got != nil {
+ t.Fatalf("unrelated probe = %#v", got)
+ }
+ if got := probeAITableBaseCatalog(context.Background(), nil, RecoveryContext{CommandPath: []string{"aitable", "base", "list"}}, Replay{}, RecoveryPlan{}); got.Status != "skipped" {
+ t.Fatalf("unsupported aitable probe = %#v", got)
+ }
+ if got := probeAITableBaseCatalog(context.Background(), nil, last.Context, last.Replay, RecoveryPlan{}); got.Status != "skipped" {
+ t.Fatalf("nil invoker probe = %#v", got)
+ }
+
+ outputs := []struct {
+ result *transport.ToolCallResult
+ summary string
+ }{
+ {nil, "no output"},
+ {&transport.ToolCallResult{Blocks: []transport.ContentBlock{{Type: "image"}, {Type: "text", Text: " plain "}}}, "text payload"},
+ {&transport.ToolCallResult{Content: map[string]any{"x": 1}}, "content payload"},
+ {&transport.ToolCallResult{StructuredContent: map[string]any{"x": 1}}, "non-text payload"},
+ }
+ for _, tt := range outputs {
+ _, summary := summarizeProbeOutput(tt.result)
+ if !strings.Contains(summary, tt.summary) {
+ t.Errorf("summarizeProbeOutput = %q, want %q", summary, tt.summary)
+ }
+ }
+ if len(buildMustReadRefs(nil)) != 3 || productReferenceFor("unknown") != "" {
+ t.Fatal("must-read references failed")
+ }
+ for _, product := range []string{"aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "report", "todo", "workbench", "approval"} {
+ if productReferenceFor(product) == "" {
+ t.Errorf("missing product reference for %q", product)
+ }
+ }
+ if cloneProbeResults(nil) != nil {
+ t.Fatal("nil probe clone should remain nil")
+ }
+}
+
+func TestStoreRuntimeAndPruningEdges(t *testing.T) {
+ ResetRuntimeState()
+ if LatestCapture() != nil {
+ t.Fatal("latest capture should start nil")
+ }
+ setLatestCapture(nil)
+ last := &LastError{
+ EventID: "evt",
+ Context: RecoveryContext{
+ CommandPath: []string{"x"},
+ ArgsSummary: map[string]any{"nested": map[string]any{"x": 1}},
+ },
+ Replay: Replay{
+ ToolArgs: map[string]any{"slice": []any{map[string]any{"x": 1}}, "strings": []string{"x"}},
+ RedactedArgv: []string{"dws"},
+ },
+ }
+ setLatestCapture(last)
+ got := LatestCapture()
+ if got == last || got.Context.ArgsSummary == nil || got.Replay.ToolArgs == nil {
+ t.Fatalf("LatestCapture clone = %#v", got)
+ }
+ ResetRuntimeState()
+ if cloneMap(nil) != nil || cloneSlice(nil) != nil {
+ t.Fatal("nil clones should remain nil")
+ }
+ if cloneValue(7) != 7 {
+ t.Fatal("scalar clone changed")
+ }
+ if !isEmptyReplay(Replay{}) || isEmptyReplay(Replay{ToolName: "x"}) {
+ t.Fatal("empty replay detection failed")
+ }
+
+ var disabled *Store
+ if disabled.Enabled() || NewStore("").Enabled() {
+ t.Fatal("disabled store reported enabled")
+ }
+ if captured, err := disabled.Capture(RecoveryContext{}); err != nil || captured != nil {
+ t.Fatalf("disabled capture = %#v, %v", captured, err)
+ }
+ for name, call := range map[string]func() error{
+ "load": func() error { _, err := disabled.LoadLastError(); return err },
+ "event": func() error { _, err := disabled.LoadErrorByEvent("evt"); return err },
+ "ensure": disabled.ensureDir,
+ } {
+ if err := call(); err == nil {
+ t.Errorf("disabled %s unexpectedly succeeded", name)
+ }
+ }
+ if err := disabled.SavePlan("evt", RecoveryPlan{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := disabled.SaveAnalysis("evt", RecoveryPlan{}, RecoveryBundle{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := disabled.Finalize("evt", "failed", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := disabled.pruneExpiredArtifacts(time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ dir := t.TempDir()
+ store := NewStore(dir)
+ if _, err := store.LoadErrorByEvent(""); err == nil {
+ t.Fatal("empty event id succeeded")
+ }
+ if _, err := store.LoadErrorByEvent("missing"); err == nil {
+ t.Fatal("missing event id succeeded")
+ }
+ if _, err := store.LoadLastError(); err == nil {
+ t.Fatal("missing last error succeeded")
+ }
+ badLast := store.lastErrorPath()
+ if err := os.MkdirAll(store.dir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(badLast, []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.LoadLastError(); err == nil {
+ t.Fatal("malformed last error succeeded")
+ }
+
+ cutoff := time.Now().UTC().Add(-recoveryMaxAge)
+ old := cutoff.Add(-time.Hour).Format(time.RFC3339Nano)
+ recent := cutoff.Add(time.Hour).Format(time.RFC3339Nano)
+ if _, ok := parseRecordedAt(""); ok {
+ t.Fatal("empty timestamp parsed")
+ }
+ if _, ok := parseRecordedAt("invalid"); ok {
+ t.Fatal("invalid timestamp parsed")
+ }
+ if _, ok := parseRecordedAt(time.Now().UTC().Format(time.RFC3339)); !ok {
+ t.Fatal("RFC3339 timestamp did not parse")
+ }
+
+ if err := os.WriteFile(badLast, []byte("{\"recorded_at\":\""+old+"\"}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.pruneLastError(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(badLast); !os.IsNotExist(err) {
+ t.Fatalf("old last error remains: %v", err)
+ }
+ if err := os.WriteFile(badLast, []byte("{\"recorded_at\":\""+recent+"\"}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.pruneLastError(cutoff); err != nil {
+ t.Fatal(err)
+ }
+
+ events := strings.Join([]string{
+ "",
+ "{",
+ "{\"event_id\":\"old\",\"recorded_at\":\"" + old + "\"}",
+ "{\"event_id\":\"recent\",\"recorded_at\":\"" + recent + "\"}",
+ }, "\n") + "\n"
+ if err := os.WriteFile(store.eventsPath(), []byte(events), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.pruneEvents(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(store.eventsPath())
+ if err != nil || strings.Contains(string(data), "old") || !strings.Contains(string(data), "recent") {
+ t.Fatalf("pruned events = %q, %v", data, err)
+ }
+ if err := os.WriteFile(store.eventsPath(), []byte("{\"event_id\":\"old\",\"recorded_at\":\""+old+"\"}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.pruneEvents(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(store.eventsPath()); !os.IsNotExist(err) {
+ t.Fatalf("empty events file remains: %v", err)
+ }
+
+ oldArtifact := filepath.Join(store.dir(), "old-artifact")
+ recentArtifact := filepath.Join(store.dir(), "recent-artifact")
+ if err := os.MkdirAll(oldArtifact, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(recentArtifact, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ oldTime := cutoff.Add(-time.Hour)
+ if err := os.Chtimes(oldArtifact, oldTime, oldTime); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.pruneOtherArtifacts(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(oldArtifact); !os.IsNotExist(err) {
+ t.Fatalf("old artifact remains: %v", err)
+ }
+ if _, err := os.Stat(recentArtifact); err != nil {
+ t.Fatalf("recent artifact removed: %v", err)
+ }
+
+ emptyReplay, err := store.Capture(RecoveryContext{RawError: "x"})
+ if err != nil || emptyReplay == nil {
+ t.Fatalf("empty replay capture = %#v, %v", emptyReplay, err)
+ }
+ if _, err := store.LoadErrorByEvent(emptyReplay.EventID); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStoreMarshalErrors(t *testing.T) {
+ store := NewStore(t.TempDir())
+ if err := store.writeJSON(filepath.Join(store.dir(), "bad.json"), map[string]any{"bad": func() {}}); err == nil {
+ t.Fatal("expected writeJSON marshal error")
+ }
+ event := RecoveryEvent{
+ EventID: "evt",
+ Bundle: &RecoveryBundle{
+ ProbeResults: []ProbeResult{{Output: func() {}}},
+ },
+ }
+ if err := store.appendEvent(event); err == nil {
+ t.Fatal("expected appendEvent marshal error")
+ }
+}
+
+func TestLoadErrorByEventFallback(t *testing.T) {
+ store := NewStore(t.TempDir())
+ last := LastError{EventID: "fallback", RecordedAt: time.Now().UTC().Format(time.RFC3339Nano)}
+ data, err := json.Marshal(last)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(store.dir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.lastErrorPath(), data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.LoadErrorByEvent("fallback"); err != nil || got.EventID != "fallback" {
+ t.Fatalf("fallback event = %#v, %v", got, err)
+ }
+ otherEvent := RecoveryEvent{
+ EventID: "other",
+ Phase: "planned",
+ RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
+ }
+ eventData, err := json.Marshal(otherEvent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.eventsPath(), append(eventData, '\n'), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.LoadErrorByEvent("fallback"); err != nil || got.EventID != "fallback" {
+ t.Fatalf("EOF fallback event = %#v, %v", got, err)
+ }
+}
+
+func TestStoreSystemCallEdges(t *testing.T) {
+ oldReadFile := recoveryReadFile
+ oldWriteFile := recoveryWriteFile
+ oldOpen := recoveryOpen
+ oldOpenFile := recoveryOpenFile
+ oldMkdirAll := recoveryMkdirAll
+ oldReadDir := recoveryReadDir
+ oldStat := recoveryStat
+ oldRemove := recoveryRemove
+ oldRemoveAll := recoveryRemoveAll
+ oldFileWrite := recoveryFileWrite
+ t.Cleanup(func() {
+ recoveryReadFile = oldReadFile
+ recoveryWriteFile = oldWriteFile
+ recoveryOpen = oldOpen
+ recoveryOpenFile = oldOpenFile
+ recoveryMkdirAll = oldMkdirAll
+ recoveryReadDir = oldReadDir
+ recoveryStat = oldStat
+ recoveryRemove = oldRemove
+ recoveryRemoveAll = oldRemoveAll
+ recoveryFileWrite = oldFileWrite
+ })
+ reset := func() {
+ recoveryReadFile = oldReadFile
+ recoveryWriteFile = oldWriteFile
+ recoveryOpen = oldOpen
+ recoveryOpenFile = oldOpenFile
+ recoveryMkdirAll = oldMkdirAll
+ recoveryReadDir = oldReadDir
+ recoveryStat = oldStat
+ recoveryRemove = oldRemove
+ recoveryRemoveAll = oldRemoveAll
+ recoveryFileWrite = oldFileWrite
+ }
+
+ store := NewStore(t.TempDir())
+ recoveryWriteFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
+ if _, err := store.Capture(RecoveryContext{}); err == nil {
+ t.Fatal("expected capture snapshot write error")
+ }
+ reset()
+
+ recoveryOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open event") }
+ if _, err := store.Capture(RecoveryContext{}); err == nil {
+ t.Fatal("expected capture event append error")
+ }
+ reset()
+
+ recoveryReadFile = func(string) ([]byte, error) { return nil, errors.New("prune") }
+ if _, err := store.LoadLastError(); err == nil {
+ t.Fatal("expected LoadLastError prune error")
+ }
+ if _, err := store.LoadErrorByEvent("evt"); err == nil {
+ t.Fatal("expected LoadErrorByEvent prune error")
+ }
+ if err := store.pruneExpiredArtifacts(time.Now()); err == nil {
+ t.Fatal("expected last-error prune failure")
+ }
+ reset()
+
+ recoveryReadFile = func(path string) ([]byte, error) {
+ if path == store.lastErrorPath() {
+ return nil, os.ErrNotExist
+ }
+ if path == store.eventsPath() {
+ return nil, errors.New("events")
+ }
+ return oldReadFile(path)
+ }
+ if err := store.pruneExpiredArtifacts(time.Now()); err == nil {
+ t.Fatal("expected event prune failure")
+ }
+ reset()
+
+ recoveryOpen = func(string) (*os.File, error) { return nil, errors.New("open") }
+ if _, err := store.LoadErrorByEvent("evt"); err == nil {
+ t.Fatal("expected event open error")
+ }
+ reset()
+
+ if err := os.MkdirAll(store.dir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(store.eventsPath(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ recoveryReadFile = func(path string) ([]byte, error) {
+ if path == store.lastErrorPath() || path == store.eventsPath() {
+ return nil, os.ErrNotExist
+ }
+ return oldReadFile(path)
+ }
+ if _, err := store.LoadErrorByEvent("evt"); err == nil || os.IsNotExist(err) {
+ t.Fatalf("expected non-EOF reader error, got %v", err)
+ }
+ if err := os.RemoveAll(store.eventsPath()); err != nil {
+ t.Fatal(err)
+ }
+ reset()
+
+ recoveryMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
+ if err := store.writeJSON(store.lastErrorPath(), map[string]any{"x": 1}); err == nil {
+ t.Fatal("expected writeJSON ensure error")
+ }
+ if err := store.appendEvent(RecoveryEvent{}); err == nil {
+ t.Fatal("expected appendEvent ensure error")
+ }
+ if err := store.ensureDir(); err == nil {
+ t.Fatal("expected ensureDir mkdir error")
+ }
+ reset()
+
+ recoveryOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open file") }
+ if err := store.appendEvent(RecoveryEvent{}); err == nil {
+ t.Fatal("expected appendEvent open error")
+ }
+ reset()
+
+ recoveryFileWrite = func(*os.File, []byte) (int, error) { return 0, errors.New("write event") }
+ if err := store.appendEvent(RecoveryEvent{}); err == nil {
+ t.Fatal("expected appendEvent write error")
+ }
+ reset()
+
+ cutoff := time.Now().UTC().Add(-recoveryMaxAge)
+ if err := os.MkdirAll(store.dir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.lastErrorPath(), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ oldTime := cutoff.Add(-time.Hour)
+ if err := os.Chtimes(store.lastErrorPath(), oldTime, oldTime); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.pruneLastError(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ reset()
+
+ if err := os.WriteFile(store.lastErrorPath(), []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ recoveryStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
+ if err := store.pruneLastError(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ recoveryStat = func(string) (os.FileInfo, error) { return nil, errors.New("stat") }
+ if err := store.pruneLastError(cutoff); err == nil {
+ t.Fatal("expected last-error stat error")
+ }
+ reset()
+
+ recoveryReadDir = func(string) ([]os.DirEntry, error) { return nil, errors.New("read dir") }
+ if err := store.pruneOtherArtifacts(cutoff); err == nil {
+ t.Fatal("expected artifact read error")
+ }
+ reset()
+
+ artifact := filepath.Join(store.dir(), "artifact")
+ if err := os.WriteFile(artifact, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ recoveryStat = func(path string) (os.FileInfo, error) {
+ if path == artifact {
+ return nil, os.ErrNotExist
+ }
+ return oldStat(path)
+ }
+ if err := store.pruneOtherArtifacts(cutoff); err != nil {
+ t.Fatal(err)
+ }
+ recoveryStat = func(path string) (os.FileInfo, error) {
+ if path == artifact {
+ return nil, errors.New("stat")
+ }
+ return oldStat(path)
+ }
+ if err := store.pruneOtherArtifacts(cutoff); err == nil {
+ t.Fatal("expected artifact stat error")
+ }
+ reset()
+
+ if err := os.Chtimes(artifact, oldTime, oldTime); err != nil {
+ t.Fatal(err)
+ }
+ recoveryRemoveAll = func(string) error { return errors.New("remove") }
+ if err := store.pruneOtherArtifacts(cutoff); err == nil {
+ t.Fatal("expected artifact removal error")
+ }
+}
diff --git a/internal/recovery/planner.go b/internal/recovery/planner.go
index 666343ce..d6eefea7 100644
--- a/internal/recovery/planner.go
+++ b/internal/recovery/planner.go
@@ -267,7 +267,7 @@ func stableKeywords(raw string) []string {
return nil
}
parts := strings.FieldsFunc(normalized, func(r rune) bool {
- return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_'
+ return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '<' && r != '>'
})
out := make([]string, 0, len(parts))
for _, part := range parts {
diff --git a/internal/recovery/store.go b/internal/recovery/store.go
index fa2a28a3..9a026be8 100644
--- a/internal/recovery/store.go
+++ b/internal/recovery/store.go
@@ -28,6 +28,21 @@ var runtimeState struct {
lastCapture *LastError
}
+var (
+ recoveryReadFile = os.ReadFile
+ recoveryWriteFile = os.WriteFile
+ recoveryOpen = os.Open
+ recoveryOpenFile = os.OpenFile
+ recoveryMkdirAll = os.MkdirAll
+ recoveryReadDir = os.ReadDir
+ recoveryStat = os.Stat
+ recoveryRemove = os.Remove
+ recoveryRemoveAll = os.RemoveAll
+ recoveryFileWrite = func(file *os.File, data []byte) (int, error) {
+ return file.Write(data)
+ }
+)
+
func NewStore(configDir string) *Store {
return &Store{baseDir: configDir}
}
@@ -77,7 +92,7 @@ func (s *Store) LoadLastError() (*LastError, error) {
if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil {
return nil, err
}
- data, err := os.ReadFile(s.lastErrorPath())
+ data, err := recoveryReadFile(s.lastErrorPath())
if err != nil {
return nil, err
}
@@ -99,7 +114,7 @@ func (s *Store) LoadErrorByEvent(eventID string) (*LastError, error) {
return nil, err
}
- file, err := os.Open(s.eventsPath())
+ file, err := recoveryOpen(s.eventsPath())
if err != nil {
if !os.IsNotExist(err) {
return nil, err
@@ -187,27 +202,24 @@ func (s *Store) writeJSON(path string, payload any) error {
if err != nil {
return err
}
- return os.WriteFile(path, append(data, '\n'), 0o600)
+ return recoveryWriteFile(path, append(data, '\n'), 0o600)
}
func (s *Store) appendEvent(event RecoveryEvent) error {
if err := s.ensureDir(); err != nil {
return err
}
- file, err := os.OpenFile(s.eventsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ file, err := recoveryOpenFile(s.eventsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer file.Close()
- writer := bufio.NewWriter(file)
- defer writer.Flush()
-
data, err := json.Marshal(event)
if err != nil {
return err
}
- if _, err := writer.Write(append(data, '\n')); err != nil {
+ if _, err := recoveryFileWrite(file, append(data, '\n')); err != nil {
return err
}
return nil
@@ -217,7 +229,7 @@ func (s *Store) ensureDir() error {
if !s.Enabled() {
return fmt.Errorf("recovery store disabled")
}
- if err := os.MkdirAll(s.dir(), 0o700); err != nil {
+ if err := recoveryMkdirAll(s.dir(), 0o700); err != nil {
return err
}
return s.pruneExpiredArtifacts(time.Now().UTC())
@@ -354,7 +366,7 @@ func (s *Store) pruneExpiredArtifacts(now time.Time) error {
func (s *Store) pruneLastError(cutoff time.Time) error {
path := s.lastErrorPath()
- data, err := os.ReadFile(path)
+ data, err := recoveryReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
@@ -365,12 +377,12 @@ func (s *Store) pruneLastError(cutoff time.Time) error {
var last LastError
if err := json.Unmarshal(data, &last); err == nil {
if recordedAt, ok := parseRecordedAt(last.RecordedAt); ok && recordedAt.Before(cutoff) {
- return os.Remove(path)
+ return recoveryRemove(path)
}
return nil
}
- info, statErr := os.Stat(path)
+ info, statErr := recoveryStat(path)
if statErr != nil {
if os.IsNotExist(statErr) {
return nil
@@ -378,14 +390,14 @@ func (s *Store) pruneLastError(cutoff time.Time) error {
return statErr
}
if info.ModTime().Before(cutoff) {
- return os.Remove(path)
+ return recoveryRemove(path)
}
return nil
}
func (s *Store) pruneEvents(cutoff time.Time) error {
path := s.eventsPath()
- data, err := os.ReadFile(path)
+ data, err := recoveryReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
@@ -413,14 +425,14 @@ func (s *Store) pruneEvents(cutoff time.Time) error {
}
if len(kept) == 0 {
- return os.Remove(path)
+ return recoveryRemove(path)
}
content := strings.Join(kept, "\n") + "\n"
- return os.WriteFile(path, []byte(content), 0o600)
+ return recoveryWriteFile(path, []byte(content), 0o600)
}
func (s *Store) pruneOtherArtifacts(cutoff time.Time) error {
- entries, err := os.ReadDir(s.dir())
+ entries, err := recoveryReadDir(s.dir())
if err != nil {
if os.IsNotExist(err) {
return nil
@@ -434,7 +446,7 @@ func (s *Store) pruneOtherArtifacts(cutoff time.Time) error {
continue
}
path := filepath.Join(s.dir(), name)
- info, err := os.Stat(path)
+ info, err := recoveryStat(path)
if err != nil {
if os.IsNotExist(err) {
continue
@@ -442,7 +454,7 @@ func (s *Store) pruneOtherArtifacts(cutoff time.Time) error {
return err
}
if info.ModTime().Before(cutoff) {
- if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
+ if err := recoveryRemoveAll(path); err != nil && !os.IsNotExist(err) {
return err
}
}
diff --git a/internal/registry/embed_test.go b/internal/registry/embed_test.go
new file mode 100644
index 00000000..7f0d7c98
--- /dev/null
+++ b/internal/registry/embed_test.go
@@ -0,0 +1,24 @@
+package registry
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestEmbeddedRegistryDataReturnsDefensiveCopies(t *testing.T) {
+ personas := PersonasYAML()
+ recipes := RecipesYAML()
+ if len(personas) == 0 || len(recipes) == 0 {
+ t.Fatal("embedded registry data is empty")
+ }
+ want := append([]byte(nil), personas...)
+ personas[0] ^= 0xff
+ if !bytes.Equal(PersonasYAML(), want) {
+ t.Fatal("PersonasYAML() returned shared mutable storage")
+ }
+ want = append([]byte(nil), recipes...)
+ recipes[0] ^= 0xff
+ if !bytes.Equal(RecipesYAML(), want) {
+ t.Fatal("RecipesYAML() returned shared mutable storage")
+ }
+}
diff --git a/internal/safety/content_scan.go b/internal/safety/content_scan.go
index 87c703ef..257a393a 100644
--- a/internal/safety/content_scan.go
+++ b/internal/safety/content_scan.go
@@ -87,9 +87,6 @@ func (s *ContentScanner) ScanPayload(payload any) Report {
findings := make([]Finding, 0, 4)
s.walkPayload("$", payload, &findings)
- if len(findings) > s.maxFindings {
- findings = findings[:s.maxFindings]
- }
return Report{
Scanned: true,
Findings: findings,
diff --git a/internal/safety/content_scan_test.go b/internal/safety/content_scan_test.go
index 9def0b71..31830d16 100644
--- a/internal/safety/content_scan_test.go
+++ b/internal/safety/content_scan_test.go
@@ -101,3 +101,24 @@ func TestSanitizeSnippetNormalizesWhitespaceAndLength(t *testing.T) {
t.Fatalf("sanitizeSnippet() = %q, want trailing ...", value)
}
}
+
+func TestContentScannerInternalNilAndEmptyGuards(t *testing.T) {
+ t.Parallel()
+ var scanner *ContentScanner
+ findings := []Finding{}
+ scanner.walkPayload("$", "text", &findings)
+ scanner.scanString("$", "text", &findings)
+
+ scanner = NewContentScanner()
+ scanner.walkPayload("$", "text", nil)
+ scanner.scanString("$", "text", nil)
+ scanner.scanString("$", " ", &findings)
+ scanner.maxFindings = 0
+ scanner.walkPayload("$", "ignore previous instructions", &findings)
+ if len(findings) != 0 {
+ t.Fatalf("guarded scans produced findings: %#v", findings)
+ }
+ if got := sanitizeSnippet("short\rtext"); got != "short text" {
+ t.Fatalf("short sanitizeSnippet() = %q", got)
+ }
+}
diff --git a/internal/security/coverage_edges_test.go b/internal/security/coverage_edges_test.go
new file mode 100644
index 00000000..895d710e
--- /dev/null
+++ b/internal/security/coverage_edges_test.go
@@ -0,0 +1,144 @@
+package security
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net"
+ "os"
+ "strings"
+ "testing"
+)
+
+type alwaysErrorReader struct{ err error }
+
+func (r alwaysErrorReader) Read([]byte) (int, error) { return 0, r.err }
+
+func TestEncryptRandomSourceErrors(t *testing.T) {
+ old := cryptoRandReader
+ t.Cleanup(func() { cryptoRandReader = old })
+ wantErr := errors.New("entropy unavailable")
+ cryptoRandReader = alwaysErrorReader{err: wantErr}
+ if _, err := Encrypt([]byte("data"), []byte("password")); !errors.Is(err, wantErr) {
+ t.Fatalf("salt error = %v", err)
+ }
+ cryptoRandReader = io.MultiReader(bytes.NewReader(make([]byte, SaltSize)), alwaysErrorReader{err: wantErr})
+ if _, err := Encrypt([]byte("data"), []byte("password")); !errors.Is(err, wantErr) {
+ t.Fatalf("nonce error = %v", err)
+ }
+}
+
+func TestGetMACAddressEnumerationError(t *testing.T) {
+ old := networkInterfaces
+ t.Cleanup(func() { networkInterfaces = old })
+ wantErr := errors.New("enumeration failed")
+ networkInterfaces = func() ([]net.Interface, error) { return nil, wantErr }
+ if _, err := GetMACAddress(); !errors.Is(err, wantErr) {
+ t.Fatalf("GetMACAddress error = %v", err)
+ }
+}
+
+type scriptedTokenTempFile struct {
+ writeErr error
+ syncErr error
+ closeErr error
+}
+
+func (f *scriptedTokenTempFile) Write(p []byte) (int, error) {
+ if f.writeErr != nil {
+ return 0, f.writeErr
+ }
+ return len(p), nil
+}
+func (f *scriptedTokenTempFile) Sync() error { return f.syncErr }
+func (f *scriptedTokenTempFile) Close() error { return f.closeErr }
+
+func TestSecureTokenStorageFailureBoundaries(t *testing.T) {
+ oldMkdir, oldMarshal, oldEncrypt := mkdirTokenDir, marshalTokenData, encryptTokenData
+ oldOpen, oldRemove, oldRename := openTokenTempFile, removeTokenFile, renameTokenFile
+ oldRead, oldDecrypt, oldUnmarshal := readTokenFile, decryptTokenData, unmarshalTokenData
+ t.Cleanup(func() {
+ mkdirTokenDir, marshalTokenData, encryptTokenData = oldMkdir, oldMarshal, oldEncrypt
+ openTokenTempFile, removeTokenFile, renameTokenFile = oldOpen, oldRemove, oldRename
+ readTokenFile, decryptTokenData, unmarshalTokenData = oldRead, oldDecrypt, oldUnmarshal
+ })
+ wantErr := errors.New("synthetic storage failure")
+ storage := NewSecureTokenStorage("config", "fallback", "mac")
+ data := &TokenData{AccessToken: "token"}
+
+ resetSave := func() *scriptedTokenTempFile {
+ file := &scriptedTokenTempFile{}
+ mkdirTokenDir = func(string, os.FileMode) error { return nil }
+ marshalTokenData = func(any) ([]byte, error) { return []byte(`{}`), nil }
+ encryptTokenData = func([]byte, []byte) ([]byte, error) { return []byte("encrypted"), nil }
+ openTokenTempFile = func(string, int, os.FileMode) (tokenTempFile, error) { return file, nil }
+ removeTokenFile = func(string) error { return nil }
+ renameTokenFile = func(string, string) error { return nil }
+ return file
+ }
+
+ resetSave()
+ mkdirTokenDir = func(string, os.FileMode) error { return wantErr }
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("mkdir error = %v", err)
+ }
+ resetSave()
+ marshalTokenData = func(any) ([]byte, error) { return nil, wantErr }
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("marshal error = %v", err)
+ }
+ resetSave()
+ encryptTokenData = func([]byte, []byte) ([]byte, error) { return nil, wantErr }
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("encrypt error = %v", err)
+ }
+ resetSave()
+ openTokenTempFile = func(string, int, os.FileMode) (tokenTempFile, error) { return nil, wantErr }
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("open error = %v", err)
+ }
+ file := resetSave()
+ file.writeErr = wantErr
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("write error = %v", err)
+ }
+ file = resetSave()
+ file.syncErr = wantErr
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("sync error = %v", err)
+ }
+ file = resetSave()
+ file.closeErr = wantErr
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("close error = %v", err)
+ }
+ resetSave()
+ renameTokenFile = func(string, string) error { return wantErr }
+ if err := storage.SaveToken(data); !errors.Is(err, wantErr) {
+ t.Fatalf("rename error = %v", err)
+ }
+
+ readTokenFile = func(string) ([]byte, error) { return []byte("encrypted"), nil }
+ decryptTokenData = func([]byte, []byte) ([]byte, error) { return nil, wantErr }
+ if _, err := storage.LoadToken(); !errors.Is(err, wantErr) {
+ t.Fatalf("decrypt error = %v", err)
+ }
+ decryptTokenData = func([]byte, []byte) ([]byte, error) { return []byte(`{}`), nil }
+ unmarshalTokenData = func([]byte, any) error { return wantErr }
+ if _, err := storage.LoadToken(); !errors.Is(err, wantErr) {
+ t.Fatalf("unmarshal error = %v", err)
+ }
+
+ removeTokenFile = func(path string) error {
+ if strings.HasSuffix(path, ".tmp") {
+ return nil
+ }
+ return wantErr
+ }
+ if err := storage.DeleteToken(); !errors.Is(err, wantErr) {
+ t.Fatalf("DeleteToken error = %v", err)
+ }
+ if err := DeleteEncryptedData("config", "fallback"); !errors.Is(err, wantErr) {
+ t.Fatalf("DeleteEncryptedData error = %v", err)
+ }
+}
diff --git a/internal/security/crypto.go b/internal/security/crypto.go
index b304a56e..ac7c6f51 100644
--- a/internal/security/crypto.go
+++ b/internal/security/crypto.go
@@ -38,6 +38,8 @@ const (
Iterations = 600_000
)
+var cryptoRandReader io.Reader = rand.Reader
+
// DeriveKey derives a KeySize-byte key from password and salt using PBKDF2-SHA256.
func DeriveKey(password, salt []byte) []byte {
return pbkdf2.Key(password, salt, Iterations, KeySize, sha256.New)
@@ -47,20 +49,14 @@ func DeriveKey(password, salt []byte) []byte {
// Output format: salt(32) || nonce(12) || ciphertext+tag.
func Encrypt(plaintext, password []byte) ([]byte, error) {
salt := make([]byte, SaltSize)
- if _, err := io.ReadFull(rand.Reader, salt); err != nil {
+ if _, err := io.ReadFull(cryptoRandReader, salt); err != nil {
return nil, fmt.Errorf("generating salt: %w", err)
}
key := DeriveKey(password, salt)
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("creating cipher: %w", err)
- }
- gcm, err := cipher.NewGCMWithNonceSize(block, NonceSize)
- if err != nil {
- return nil, fmt.Errorf("creating GCM: %w", err)
- }
+ block, _ := aes.NewCipher(key) // KeySize is fixed to an AES-supported size.
+ gcm, _ := cipher.NewGCMWithNonceSize(block, NonceSize)
nonce := make([]byte, NonceSize)
- if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ if _, err := io.ReadFull(cryptoRandReader, nonce); err != nil {
return nil, fmt.Errorf("generating nonce: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
@@ -82,14 +78,8 @@ func Decrypt(data, password []byte) ([]byte, error) {
nonce := data[SaltSize : SaltSize+NonceSize]
sealed := data[SaltSize+NonceSize:]
key := DeriveKey(password, salt)
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("creating cipher: %w", err)
- }
- gcm, err := cipher.NewGCMWithNonceSize(block, NonceSize)
- if err != nil {
- return nil, fmt.Errorf("creating GCM: %w", err)
- }
+ block, _ := aes.NewCipher(key) // KeySize is fixed to an AES-supported size.
+ gcm, _ := cipher.NewGCMWithNonceSize(block, NonceSize)
plain, err := gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return nil, fmt.Errorf("decryption or integrity check failed: %w", err)
diff --git a/internal/security/fingerprint.go b/internal/security/fingerprint.go
index f5021082..8716c955 100644
--- a/internal/security/fingerprint.go
+++ b/internal/security/fingerprint.go
@@ -34,6 +34,8 @@ var virtualMACPrefixes = []string{
"52:54:00",
}
+var networkInterfaces = net.Interfaces
+
func isVirtualMAC(mac string) bool {
for _, p := range virtualMACPrefixes {
if strings.HasPrefix(mac, p) {
@@ -78,7 +80,7 @@ func selectMAC(ifaces []net.Interface) (string, error) {
// found (e.g. inside a Docker container), it falls back to the first
// non-loopback virtual MAC address so that token encryption still works.
func GetMACAddress() (string, error) {
- ifaces, err := net.Interfaces()
+ ifaces, err := networkInterfaces()
if err != nil {
return "", fmt.Errorf("enumerating network interfaces: %w", err)
}
diff --git a/internal/security/storage.go b/internal/security/storage.go
index bd066720..4a138b78 100644
--- a/internal/security/storage.go
+++ b/internal/security/storage.go
@@ -48,6 +48,26 @@ type SecureTokenStorage struct {
macAddr string
}
+type tokenTempFile interface {
+ Write([]byte) (int, error)
+ Sync() error
+ Close() error
+}
+
+var (
+ mkdirTokenDir = os.MkdirAll
+ marshalTokenData = json.Marshal
+ encryptTokenData = Encrypt
+ openTokenTempFile = func(path string, flag int, perm os.FileMode) (tokenTempFile, error) {
+ return os.OpenFile(path, flag, perm)
+ }
+ removeTokenFile = os.Remove
+ renameTokenFile = os.Rename
+ readTokenFile = os.ReadFile
+ decryptTokenData = Decrypt
+ unmarshalTokenData = json.Unmarshal
+)
+
// NewSecureTokenStorage creates a new secure storage instance.
// fallbackDir may be empty.
func NewSecureTokenStorage(configDir, fallbackDir, macAddr string) *SecureTokenStorage {
@@ -106,14 +126,14 @@ func DataFileExistsInAny(dirs ...string) bool {
// SaveToken encrypts and persists token data using atomic write with fsync.
func (s *SecureTokenStorage) SaveToken(data *TokenData) error {
- if err := os.MkdirAll(s.configDir, config.DirPerm); err != nil {
+ if err := mkdirTokenDir(s.configDir, config.DirPerm); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
- b, err := json.Marshal(data)
+ b, err := marshalTokenData(data)
if err != nil {
return fmt.Errorf("marshaling token: %w", err)
}
- enc, err := Encrypt(b, s.password())
+ enc, err := encryptTokenData(b, s.password())
if err != nil {
return fmt.Errorf("encrypting token: %w", err)
}
@@ -121,7 +141,7 @@ func (s *SecureTokenStorage) SaveToken(data *TokenData) error {
tmpPath := finalPath + ".tmp"
// Atomic write with fsync to ensure data durability
- tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
+ tmpFile, err := openTokenTempFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
@@ -130,7 +150,7 @@ func (s *SecureTokenStorage) SaveToken(data *TokenData) error {
defer func() {
if !writeSuccess {
tmpFile.Close()
- _ = os.Remove(tmpPath)
+ _ = removeTokenFile(tmpPath)
}
}()
@@ -143,8 +163,8 @@ func (s *SecureTokenStorage) SaveToken(data *TokenData) error {
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
- if err := os.Rename(tmpPath, finalPath); err != nil {
- _ = os.Remove(tmpPath)
+ if err := renameTokenFile(tmpPath, finalPath); err != nil {
+ _ = removeTokenFile(tmpPath)
return fmt.Errorf("renaming to final: %w", err)
}
writeSuccess = true
@@ -155,19 +175,19 @@ func (s *SecureTokenStorage) SaveToken(data *TokenData) error {
func (s *SecureTokenStorage) LoadToken() (*TokenData, error) {
var raw []byte
var err error
- raw, err = os.ReadFile(dataPath(s.configDir))
+ raw, err = readTokenFile(dataPath(s.configDir))
if err != nil && s.fallbackDir != "" {
- raw, err = os.ReadFile(dataPath(s.fallbackDir))
+ raw, err = readTokenFile(dataPath(s.fallbackDir))
}
if err != nil {
return nil, fmt.Errorf("reading encrypted file: %w", err)
}
- plain, err := Decrypt(raw, s.password())
+ plain, err := decryptTokenData(raw, s.password())
if err != nil {
return nil, err
}
var data TokenData
- if err := json.Unmarshal(plain, &data); err != nil {
+ if err := unmarshalTokenData(plain, &data); err != nil {
return nil, fmt.Errorf("parsing token JSON: %w", err)
}
return &data, nil
@@ -181,10 +201,10 @@ func (s *SecureTokenStorage) DeleteToken() error {
continue
}
p := dataPath(dir)
- if err := os.Remove(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
+ if err := removeTokenFile(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
firstErr = fmt.Errorf("deleting %s: %w", p, err)
}
- _ = os.Remove(p + ".tmp")
+ _ = removeTokenFile(p + ".tmp")
}
return firstErr
}
@@ -198,10 +218,10 @@ func DeleteEncryptedData(configDir string, fallbackDirs ...string) error {
continue
}
p := dataPath(dir)
- if err := os.Remove(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
+ if err := removeTokenFile(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
firstErr = fmt.Errorf("deleting %s: %w", p, err)
}
- _ = os.Remove(p + ".tmp")
+ _ = removeTokenFile(p + ".tmp")
}
return firstErr
}
diff --git a/internal/syncdata/syncdata_test.go b/internal/syncdata/syncdata_test.go
new file mode 100644
index 00000000..f65c9168
--- /dev/null
+++ b/internal/syncdata/syncdata_test.go
@@ -0,0 +1,29 @@
+package syncdata
+
+import "testing"
+
+func TestGeneratedSyncDataIsSelfConsistent(t *testing.T) {
+ servers := StaticServers()
+ routes := CmdToProduct()
+ if len(servers) == 0 || len(routes) == 0 {
+ t.Fatal("generated sync data is empty")
+ }
+ serverIDs := make(map[string]bool, len(servers))
+ for _, server := range servers {
+ if server.ID == "" || server.Name == "" || server.Endpoint == "" {
+ t.Errorf("incomplete server: %#v", server)
+ }
+ if serverIDs[server.ID] {
+ t.Errorf("duplicate server ID %q", server.ID)
+ }
+ serverIDs[server.ID] = true
+ }
+ for command, product := range routes {
+ if command == "" || product == "" {
+ t.Errorf("invalid route %q -> %q", command, product)
+ }
+ if !serverIDs[product] {
+ t.Errorf("route %q targets unknown product %q", command, product)
+ }
+ }
+}
diff --git a/internal/transport/client.go b/internal/transport/client.go
index 8b9a2d8a..fe9b9748 100644
--- a/internal/transport/client.go
+++ b/internal/transport/client.go
@@ -784,9 +784,6 @@ func (c *Client) isEndpointTrusted(endpoint string) bool {
}
host := parsed.Hostname()
domains := c.trustedDomainsList()
- if len(domains) == 0 {
- return true
- }
for _, pattern := range domains {
if pattern == "*" {
c.warnWildcardDomains()
diff --git a/internal/transport/coverage_edges_test.go b/internal/transport/coverage_edges_test.go
new file mode 100644
index 00000000..c59e45ba
--- /dev/null
+++ b/internal/transport/coverage_edges_test.go
@@ -0,0 +1,521 @@
+package transport
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+)
+
+type edgeWriteCloser struct {
+ bytes.Buffer
+ err error
+ closeErr error
+ closeCall int
+}
+
+func (w *edgeWriteCloser) Write(p []byte) (int, error) {
+ if w.err != nil {
+ return 0, w.err
+ }
+ return w.Buffer.Write(p)
+}
+
+func (w *edgeWriteCloser) Close() error {
+ w.closeCall++
+ return w.closeErr
+}
+
+type edgeReadCloser struct {
+ io.Reader
+ closeErr error
+}
+
+func (r edgeReadCloser) Close() error { return r.closeErr }
+
+type edgeErrorReader struct{}
+
+func (edgeErrorReader) Read([]byte) (int, error) { return 0, errors.New("read") }
+
+func edgeResponse(status int, body string) *http.Response {
+ return &http.Response{
+ StatusCode: status,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+}
+
+func edgeClient(fn roundTripFunc) *Client {
+ c := NewClient(&http.Client{Transport: fn})
+ c.MaxRetries = 0
+ c.RetryDelay = time.Millisecond
+ c.RetryMaxDelay = 2 * time.Millisecond
+ return c
+}
+
+func TestCallErrorAndRedirectEdges(t *testing.T) {
+ httpClient := &http.Client{}
+ if got := NewClient(httpClient); got.HTTPClient.Transport == nil || got.HTTPClient.CheckRedirect == nil {
+ t.Fatalf("NewClient did not fill HTTP defaults: %#v", got.HTTPClient)
+ }
+ var nilErr *CallError
+ if nilErr.Error() != "" || nilErr.Unwrap() != nil {
+ t.Fatal("nil call error did not stay empty")
+ }
+ cause := errors.New("cause")
+ if got := (&CallError{Cause: cause}).Error(); got != "cause" {
+ t.Fatalf("cause error = %q", got)
+ }
+ if !errors.Is(&CallError{Cause: cause}, cause) {
+ t.Fatal("call error did not unwrap")
+ }
+ for _, tc := range []struct {
+ err *CallError
+ want string
+ }{
+ {&CallError{Stage: CallStageHTTP, HTTPStatus: 503}, "http failure: http 503"},
+ {&CallError{Stage: CallStageJSONRPC, RPCCode: -32601}, "jsonrpc failure: rpc -32601"},
+ {&CallError{Stage: CallStageRequest}, "request failure"},
+ } {
+ if got := tc.err.Error(); got != tc.want {
+ t.Fatalf("Error() = %q, want %q", got, tc.want)
+ }
+ }
+
+ request := func(raw string) *http.Request {
+ req, err := http.NewRequest(http.MethodGet, raw, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "secret")
+ req.Header.Set("x-user-access-token", "secret")
+ return req
+ }
+ if err := safeRedirectPolicy(request("https://one.test"), nil); err != nil {
+ t.Fatal(err)
+ }
+ same := request("https://one.test/next")
+ if err := safeRedirectPolicy(same, []*http.Request{request("https://one.test/start")}); err != nil || same.Header.Get("Authorization") == "" {
+ t.Fatalf("same-host redirect = %v, %#v", err, same.Header)
+ }
+ cross := request("https://two.test")
+ if err := safeRedirectPolicy(cross, []*http.Request{request("https://one.test")}); err != nil || cross.Header.Get("Authorization") != "" || cross.Header.Get("x-user-access-token") != "" {
+ t.Fatalf("cross-host redirect = %v, %#v", err, cross.Header)
+ }
+ via := make([]*http.Request, 10)
+ if err := safeRedirectPolicy(request("https://one.test"), via); err == nil {
+ t.Fatal("redirect limit succeeded")
+ }
+
+ base := NewClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return edgeResponse(http.StatusOK, "{}"), nil
+ })})
+ base.AuthToken = "token"
+ base.ExtraHeaders = map[string]string{"X": "Y"}
+ copy := base.WithExecutionId("exec-1")
+ if copy == base || copy.ExecutionId != "exec-1" || copy.AuthToken != "token" || copy.ExtraHeaders["X"] != "Y" {
+ t.Fatalf("WithExecutionId = %#v", copy)
+ }
+}
+
+func TestToolCallResultUnmarshalEdges(t *testing.T) {
+ for _, raw := range []string{"[]", `{"content":42}`} {
+ var result ToolCallResult
+ if err := json.Unmarshal([]byte(raw), &result); err == nil {
+ t.Fatalf("invalid result %q succeeded", raw)
+ }
+ }
+ checks := []struct {
+ raw string
+ check func(ToolCallResult) bool
+ }{
+ {`{"structuredContent":{"x":1},"isError":true}`, func(r ToolCallResult) bool {
+ return r.IsError && r.Content["x"] != nil && r.StructuredContent["x"] != nil
+ }},
+ {`{}`, func(r ToolCallResult) bool { return r.Content == nil && r.Blocks == nil }},
+ {`{"content":{"x":1}}`, func(r ToolCallResult) bool { return r.Content["x"] != nil }},
+ {`{"content":[{"type":"text","text":"ignored"}],"structuredContent":{"x":1}}`, func(r ToolCallResult) bool { return len(r.Blocks) == 1 && r.Content["x"] != nil }},
+ {`{"content":[{"type":"text","text":" "},{"type":"text","text":"bad"},{"type":"text","text":"{\"x\":1}"}]}`, func(r ToolCallResult) bool { return r.Content["x"] != nil }},
+ {`{"content":[{"type":"text","text":"plain"}]}`, func(r ToolCallResult) bool { return len(r.Blocks) == 1 && r.Content == nil }},
+ }
+ for _, tc := range checks {
+ var result ToolCallResult
+ if err := json.Unmarshal([]byte(tc.raw), &result); err != nil || !tc.check(result) {
+ t.Fatalf("UnmarshalJSON(%s) = %#v, %v", tc.raw, result, err)
+ }
+ }
+}
+
+func TestClientProtocolAndValidationEdges(t *testing.T) {
+ original := supportedProtocolVersions
+ supportedProtocolVersions = nil
+ if _, err := edgeClient(func(*http.Request) (*http.Response, error) { return edgeResponse(http.StatusOK, "{}"), nil }).Initialize(context.Background(), "https://x.test"); err == nil {
+ t.Fatal("empty protocol versions succeeded")
+ }
+ supportedProtocolVersions = original
+ t.Cleanup(func() { supportedProtocolVersions = original })
+
+ rpcFailure := edgeClient(func(*http.Request) (*http.Response, error) {
+ return edgeResponse(http.StatusOK, `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"unsupported"}}`), nil
+ })
+ if _, err := rpcFailure.Initialize(context.Background(), "https://x.test"); err == nil {
+ t.Fatal("all-protocol failure succeeded")
+ }
+
+ c := edgeClient(func(*http.Request) (*http.Response, error) {
+ return edgeResponse(http.StatusOK, `{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}`), nil
+ })
+ got, err := c.Initialize(context.Background(), "https://x.test")
+ if err != nil || got.ProtocolVersion == "" || got.RequestedProtocolVersion == "" {
+ t.Fatalf("Initialize fallback version = %#v, %v", got, err)
+ }
+
+ for _, args := range []map[string]any{
+ {"nested": map[string]any{"bad": "x\x00"}},
+ {"list": []any{"ok", "x\x00"}},
+ {"list": []any{map[string]any{"bad": "x\x00"}}},
+ } {
+ if err := validateCallArguments(args); err == nil {
+ t.Fatalf("invalid args %#v succeeded", args)
+ }
+ }
+ if err := validateCallArguments(map[string]any{"ok": []any{1, map[string]any{"v": "safe"}}}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.CallTool(context.Background(), "https://x.test", "bad\x00name", nil); err == nil {
+ t.Fatal("control-character tool succeeded")
+ }
+ if _, err := c.CallTool(context.Background(), "https://x.test", "ok", map[string]any{"bad": "x\x00"}); err == nil {
+ t.Fatal("control-character argument succeeded")
+ }
+}
+
+func TestCallJSONRPCEdges(t *testing.T) {
+ c := edgeClient(func(*http.Request) (*http.Response, error) { return edgeResponse(http.StatusOK, "{}"), nil })
+ if err := c.callJSONRPC(context.Background(), "https://x.test", requestEnvelope{Method: "x", Params: func() {}}, true, nil); err == nil {
+ t.Fatal("unencodable request succeeded")
+ }
+
+ c = edgeClient(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"X-Trace-Id": {"trace"}}, Body: edgeReadCloser{Reader: edgeErrorReader{}}}, nil
+ })
+ if err := c.callJSONRPC(context.Background(), "https://x.test", requestEnvelope{Method: "tools/list"}, true, nil); err == nil {
+ t.Fatal("response read error succeeded")
+ }
+
+ for _, tc := range []struct {
+ body string
+ out any
+ }{
+ {"not-json", &map[string]any{}},
+ {`{"jsonrpc":"2.0","id":1}`, &map[string]any{}},
+ {`{"jsonrpc":"2.0","id":1,"result":"bad"}`, &map[string]any{}},
+ } {
+ c = edgeClient(func(*http.Request) (*http.Response, error) { return edgeResponse(http.StatusOK, tc.body), nil })
+ if err := c.callJSONRPC(context.Background(), "https://x.test", requestEnvelope{Method: "tools/list"}, true, tc.out); err == nil {
+ t.Fatalf("bad response %q succeeded", tc.body)
+ }
+ }
+
+ c = edgeClient(func(*http.Request) (*http.Response, error) {
+ return edgeResponse(http.StatusOK, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`), nil
+ })
+ if err := c.callJSONRPC(context.Background(), "https://x.test", requestEnvelope{Method: "x"}, true, nil); err != nil {
+ t.Fatalf("nil output: %v", err)
+ }
+ if err := c.callJSONRPC(context.Background(), "https://x.test", requestEnvelope{Method: "notify"}, false, nil); err != nil {
+ t.Fatalf("notification: %v", err)
+ }
+}
+
+func TestRetryAndFailureEdges(t *testing.T) {
+ if _, err := edgeClient(func(*http.Request) (*http.Response, error) { return nil, nil }).doWithRetry(context.Background(), "://", nil, ""); err == nil {
+ t.Fatal("invalid endpoint succeeded")
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ seen := false
+ c := edgeClient(func(req *http.Request) (*http.Response, error) {
+ seen = req.Header.Get(HeaderExecutionId) == "exec" && req.Header.Get("X-Test") == "value" && req.Header.Get("Authorization") != ""
+ return edgeResponse(http.StatusOK, "{}"), nil
+ })
+ c.ExecutionId = "exec"
+ c.AuthToken = " token "
+ c.ExtraHeaders = map[string]string{"": "ignored", "X-Empty": "", "X-Test": "value"}
+ c.TrustedDomains = []string{"x.test"}
+ c.FileLogger = logger
+ if resp, err := c.doWithRetry(context.Background(), "https://x.test", nil, " "); err != nil || resp == nil || !seen {
+ t.Fatalf("header request = %#v, %v, seen=%v", resp, err, seen)
+ }
+
+ closeCalls := 0
+ attempts := 0
+ c = edgeClient(func(*http.Request) (*http.Response, error) {
+ attempts++
+ resp := edgeResponse(http.StatusServiceUnavailable, "busy")
+ resp.Header.Set("Retry-After", "1")
+ resp.Body = edgeReadCloser{Reader: strings.NewReader("busy"), closeErr: nil}
+ if attempts == 2 {
+ return edgeResponse(http.StatusOK, "{}"), nil
+ }
+ return resp, nil
+ })
+ c.MaxRetries = 1
+ c.RetryMaxDelay = time.Millisecond
+ c.sleep = func(context.Context, time.Duration) error { closeCalls++; return nil }
+ if resp, err := c.doWithRetry(context.Background(), "https://x.test", nil, "tools/list"); err != nil || resp.StatusCode != http.StatusOK || attempts != 2 || closeCalls != 1 {
+ t.Fatalf("retry response = %#v, %v attempts=%d sleeps=%d", resp, err, attempts, closeCalls)
+ }
+
+ for _, method := range []string{"tools/call", "tools/list"} {
+ c = edgeClient(func(*http.Request) (*http.Response, error) { return nil, errors.New("connection refused") })
+ c.MaxRetries = 1
+ c.sleep = func(context.Context, time.Duration) error { return context.Canceled }
+ if _, err := c.doWithRetry(context.Background(), "https://x.test", nil, method); err == nil {
+ t.Fatalf("cancelled retry for %s succeeded", method)
+ }
+ }
+
+ for _, method := range []string{"tools/call", "tools/list"} {
+ c = edgeClient(func(*http.Request) (*http.Response, error) { return nil, errors.New("no such host") })
+ if _, err := c.doWithRetry(context.Background(), "https://x.test", nil, method); err == nil {
+ t.Fatalf("request failure for %s succeeded", method)
+ }
+ }
+ c = edgeClient(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
+ c.MaxRetries = 3
+ if _, err := c.doWithRetry(context.Background(), "https://x.test", nil, "tools/list"); err == nil {
+ t.Fatal("timeout request succeeded")
+ }
+}
+
+func TestRetryHelperAndTrustEdges(t *testing.T) {
+ for _, err := range []error{nil, context.DeadlineExceeded, context.Canceled, os.ErrDeadlineExceeded, errors.New("Client.Timeout exceeded"), errors.New("TLS handshake timeout"), errors.New("other")} {
+ _ = isTimeoutError(err)
+ _, _ = classifyRequestFailure(err)
+ }
+ for _, err := range []error{errors.New("connection refused"), errors.New("no such host"), errors.New("i/o timeout")} {
+ reason, _ := classifyRequestFailure(err)
+ if reason == "request_failed" {
+ t.Fatalf("unclassified error %v", err)
+ }
+ }
+ if respRetryAfter(nil) != "" {
+ t.Fatal("nil retry-after was not empty")
+ }
+ resp := edgeResponse(http.StatusTooManyRequests, "")
+ resp.Header.Set("Retry-After", " 2 ")
+ if respRetryAfter(resp) != "2" {
+ t.Fatal("retry-after was not trimmed")
+ }
+ c := &Client{}
+ if got := c.retryDelayForAttempt(0, "1"); got != 80*time.Millisecond {
+ // With defaults derived from an empty client, Retry-After is capped.
+ if got != 80*time.Millisecond {
+ t.Fatalf("retry delay = %v", got)
+ }
+ }
+ c.RetryDelay = time.Second
+ c.RetryMaxDelay = 2 * time.Second
+ if got := c.retryDelayForAttempt(5, ""); got != 2*time.Second {
+ t.Fatalf("capped delay = %v", got)
+ }
+ if got := c.retryDelayForAttempt(0, "0"); got != time.Second {
+ t.Fatalf("zero retry delay = %v", got)
+ }
+ if _, ok := parseRetryAfter("bad"); ok {
+ t.Fatal("bad retry-after parsed")
+ }
+ if got, ok := parseRetryAfter(time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat)); !ok || got != 0 {
+ t.Fatalf("past retry-after = %v, %v", got, ok)
+ }
+ if got, ok := parseRetryAfter(time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)); !ok || got <= 0 {
+ t.Fatalf("future retry-after = %v, %v", got, ok)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := (&Client{}).sleepForRetry(ctx, time.Hour); !errors.Is(err, context.Canceled) {
+ t.Fatalf("sleep cancellation = %v", err)
+ }
+ if err := (&Client{}).sleepForRetry(context.Background(), time.Nanosecond); err != nil {
+ t.Fatalf("short sleep = %v", err)
+ }
+
+ for _, endpoint := range []string{"%", "ftp://x.test", "http://remote.test"} {
+ t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
+ if (&Client{TrustedDomains: []string{"*"}}).isEndpointTrusted(endpoint) {
+ t.Fatalf("endpoint %q trusted", endpoint)
+ }
+ }
+ t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
+ if (&Client{TrustedDomains: []string{"*"}}).isEndpointTrusted("http://localhost") {
+ t.Fatal("HTTP endpoint trusted without opt-in")
+ }
+ t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
+ if !(&Client{TrustedDomains: []string{"localhost"}}).isEndpointTrusted("http://localhost") {
+ t.Fatal("loopback endpoint not trusted")
+ }
+ var warning bytes.Buffer
+ wild := &Client{TrustedDomains: []string{"*"}, Stderr: &warning}
+ if !wild.isEndpointTrusted("https://anything.test") || !wild.isEndpointTrusted("https://anything.test") || strings.Count(warning.String(), "WARN") != 1 {
+ t.Fatalf("wildcard warning = %q", warning.String())
+ }
+ (&Client{TrustedDomains: []string{"*"}}).warnWildcardDomains()
+ if (&Client{TrustedDomains: []string{"none.test"}}).isEndpointTrusted("https://x.test") {
+ t.Fatal("unlisted domain trusted")
+ }
+ if matchDomain("x.test", "") {
+ t.Fatal("empty domain pattern matched")
+ }
+ if shouldPreserveEndpointQuery(nil) || shouldPreserveEndpointQuery(&url.URL{Scheme: "http", Host: "mcp-gw.dingtalk.com"}) {
+ t.Fatal("invalid gateway preserved query")
+ }
+}
+
+func TestHTTPStatusAndDiagnosticsEdges(t *testing.T) {
+ for _, tc := range []struct {
+ method string
+ code int
+ }{
+ {"tools/list", http.StatusBadRequest},
+ {"tools/call", http.StatusBadRequest},
+ {"tools/call", http.StatusServiceUnavailable},
+ {"tools/list", http.StatusServiceUnavailable},
+ } {
+ if err := httpStatusError(tc.method, "https://x.test?q=secret", tc.code, "snap", "trace"); err == nil {
+ t.Fatalf("HTTP %d returned nil", tc.code)
+ }
+ }
+ if got := networkActions("snap"); len(got) != 3 {
+ t.Fatalf("network actions = %#v", got)
+ }
+ if err := jsonrpcEnvelopeError("tools/list", &RPCError{Code: -1, Message: "failed"}, "", "header-trace"); err == nil {
+ t.Fatal("JSON-RPC trace fallback returned nil")
+ }
+
+ deep := map[string]any{"errorCode": "ERROR", "data": []any{"skip", map[string]any{"result": map[string]any{"code": "REAL"}}}}
+ if got := serverErrorCodeFromMap(deep, 0); got != "REAL" {
+ t.Fatalf("nested code = %q", got)
+ }
+ if got := serverErrorCodeFromMap(map[string]any{"errorCode": "ERROR"}, 0); got != "ERROR" {
+ t.Fatalf("wrapper fallback = %q", got)
+ }
+ if got := serverErrorCodeFromMap(nil, 0); got != "" {
+ t.Fatalf("nil code = %q", got)
+ }
+ if got := serverErrorCodeFromMap(map[string]any{"code": "x"}, 9); got != "" {
+ t.Fatalf("depth-limited code = %q", got)
+ }
+ if got := nestedServerErrorCode(map[string]any{"content": []any{map[string]any{"code": "NEST"}}}, 0); got != "NEST" {
+ t.Fatalf("array nested code = %q", got)
+ }
+ if coalesceStr("", "x") != "x" || coalesceStr("", "") != "" || stringFromMap(map[string]any{"x": 1}, "x") != "" {
+ t.Fatal("string helpers failed")
+ }
+}
+
+func TestStdioCallAndStartEdges(t *testing.T) {
+ makeClient := func(response string) (*StdioClient, *edgeWriteCloser) {
+ stdin := &edgeWriteCloser{}
+ return &StdioClient{started: true, stdin: stdin, stdout: bufio.NewReader(strings.NewReader(response))}, stdin
+ }
+
+ client, stdin := makeClient(`{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n")
+ if _, err := client.ListTools(context.Background()); err != nil || stdin.Len() == 0 {
+ t.Fatalf("ListTools = %v, request=%q", err, stdin.String())
+ }
+ if err := client.Start(context.Background()); err != nil {
+ t.Fatalf("second Start: %v", err)
+ }
+ client, _ = makeClient(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"v"}}` + "\n")
+ if _, err := client.Initialize(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ client, _ = makeClient(`{"jsonrpc":"2.0","id":1,"result":{"content":[]}}` + "\n")
+ if _, err := client.CallTool(context.Background(), "x", nil); err != nil {
+ t.Fatal(err)
+ }
+
+ client, _ = makeClient("")
+ if err := client.call(context.Background(), "x", nil, nil); err == nil {
+ t.Fatal("EOF response succeeded")
+ }
+ if _, err := (&StdioClient{}).Initialize(context.Background()); err == nil {
+ t.Fatal("unstarted Initialize succeeded")
+ }
+ if _, err := (&StdioClient{}).ListTools(context.Background()); err == nil {
+ t.Fatal("unstarted ListTools succeeded")
+ }
+ client, _ = makeClient("bad\n")
+ if err := client.call(context.Background(), "x", nil, nil); err == nil {
+ t.Fatal("invalid response succeeded")
+ }
+ client, _ = makeClient(`{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"bad"}}` + "\n")
+ if err := client.call(context.Background(), "x", nil, nil); err == nil {
+ t.Fatal("RPC error succeeded")
+ }
+ client, _ = makeClient(`{"jsonrpc":"2.0","id":1,"result":"bad"}` + "\n")
+ if err := client.call(context.Background(), "x", nil, &map[string]any{}); err == nil {
+ t.Fatal("invalid result succeeded")
+ }
+ client, _ = makeClient(`{"jsonrpc":"2.0","id":1,"result":null}` + "\n")
+ if err := client.call(context.Background(), "x", nil, nil); err != nil {
+ t.Fatalf("nil result target: %v", err)
+ }
+ client, _ = makeClient("")
+ client.stdin = &edgeWriteCloser{err: errors.New("write")}
+ if err := client.call(context.Background(), "x", nil, nil); err == nil {
+ t.Fatal("write error succeeded")
+ }
+ client, _ = makeClient("")
+ if err := client.call(context.Background(), "x", func() {}, nil); err == nil {
+ t.Fatal("marshal error succeeded")
+ }
+ client, _ = makeClient("")
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := client.call(ctx, "x", nil, nil); err == nil {
+ t.Fatal("cancelled call succeeded")
+ }
+
+ stopped := &StdioClient{started: true, cmd: &exec.Cmd{}, stdin: &edgeWriteCloser{}}
+ if err := stopped.Stop(); err == nil || stopped.started {
+ t.Fatalf("Stop without process = %v", err)
+ }
+ if err := (&StdioClient{}).Stop(); err != nil {
+ t.Fatal(err)
+ }
+
+ oldCommand := stdioCommandContext
+ t.Cleanup(func() { stdioCommandContext = oldCommand })
+ for _, stream := range []string{"stdin", "stdout", "stderr"} {
+ stdioCommandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^$")
+ switch stream {
+ case "stdin":
+ cmd.Stdin = strings.NewReader("")
+ case "stdout":
+ cmd.Stdout = io.Discard
+ case "stderr":
+ cmd.Stderr = io.Discard
+ }
+ return cmd
+ }
+ if err := NewStdioClient("ignored", nil, map[string]string{"EDGE": "1"}).Start(context.Background()); err == nil {
+ t.Fatalf("%s pipe failure succeeded", stream)
+ }
+ }
+
+ stderr := &StdioClient{command: "edge", stderr: io.NopCloser(strings.NewReader("\nline\n"))}
+ stderr.drainStderr()
+}
diff --git a/internal/transport/stdio.go b/internal/transport/stdio.go
index 4c142347..a87d7acb 100644
--- a/internal/transport/stdio.go
+++ b/internal/transport/stdio.go
@@ -48,6 +48,8 @@ type StdioClient struct {
initResult InitializeResult
}
+var stdioCommandContext = exec.CommandContext
+
// NewStdioClient creates a StdioClient for the given command.
// The subprocess is not started until Start() is called.
func NewStdioClient(command string, args []string, env map[string]string) *StdioClient {
@@ -67,7 +69,7 @@ func (s *StdioClient) Start(ctx context.Context) error {
return nil
}
- cmd := exec.CommandContext(ctx, s.command, s.args...)
+ cmd := stdioCommandContext(ctx, s.command, s.args...)
// Build environment: inherit current env + merge plugin-specific vars.
cmd.Env = os.Environ()
diff --git a/internal/transport/stdio_integration_test.go b/internal/transport/stdio_integration_test.go
index 63a47805..d1556106 100644
--- a/internal/transport/stdio_integration_test.go
+++ b/internal/transport/stdio_integration_test.go
@@ -43,7 +43,7 @@ func TestStdioClientEndToEnd(t *testing.T) {
}
defer client.Stop()
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Initialize
diff --git a/internal/tui/coverage_edges_test.go b/internal/tui/coverage_edges_test.go
new file mode 100644
index 00000000..bedd501f
--- /dev/null
+++ b/internal/tui/coverage_edges_test.go
@@ -0,0 +1,85 @@
+package tui
+
+import (
+ "bytes"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestThemePresentationHelpers(t *testing.T) {
+ for name, render := range map[string]func(string) string{
+ "brand": Brand, "blue": Blue, "cyan": Cyan, "white": White, "gray": Gray,
+ "success": Success, "warning": Warning, "danger": Danger, "bold": Bold, "dim": Dim,
+ } {
+ if got := render("value"); !strings.Contains(got, "value") {
+ t.Errorf("%s renderer = %q", name, got)
+ }
+ }
+ if !strings.Contains(Header(" title ", " "), "title") || !strings.Contains(Header("title", " subtitle "), "subtitle") {
+ t.Fatal("header rendering changed")
+ }
+ for _, got := range []string{Section("section"), Key("key"), Bullet(), Arrow(), Border(), SoftBorder()} {
+ if got == "" {
+ t.Fatal("presentation helper returned empty text")
+ }
+ }
+ for _, kind := range []string{"ok", "success", "pass", "warn", "warning", "retryable", "error", "danger", "failed", "fail", "other"} {
+ if StateMark(kind) == "" {
+ t.Errorf("StateMark(%q) returned empty", kind)
+ }
+ }
+ if PlainRuneWidth(Rule(0)) != MinPanelWidth || PlainRuneWidth(Rule(MaxPanelWidth+1)) != MaxPanelWidth {
+ t.Fatal("rule width clamping changed")
+ }
+ if got := PadRightANSI("long", 2); got != "long" {
+ t.Fatalf("negative padding = %q", got)
+ }
+ for _, r := range []rune{'\u200d', '\ufe00', '\U000e0100', '\u0301', '\u0488'} {
+ if !isZeroWidthRune(r) {
+ t.Errorf("%U should be zero width", r)
+ }
+ }
+ if got := PlainRuneWidth("a\u200db"); got != 2 {
+ t.Fatalf("zero-width rune count = %d", got)
+ }
+ for _, r := range []rune{'\u1100', '\u2329', '\u2e80', '\uac00', '\uf900', '\ufe10', '\ufe30', '\uff00', '\uffe0', '\U0001f1e6', '\U0001f300'} {
+ if !isWideRune(r) {
+ t.Errorf("%U should be wide", r)
+ }
+ }
+}
+
+func TestPanelSuccessAndWriteFailures(t *testing.T) {
+ var out bytes.Buffer
+ if err := Panel(&out, "Title", []string{"line", strings.Repeat("wide", MaxPanelWidth)}); err != nil {
+ t.Fatalf("Panel(): %v", err)
+ }
+ if !strings.Contains(out.String(), "Title") || !strings.Contains(out.String(), "line") {
+ t.Fatalf("panel output = %q", out.String())
+ }
+ if err := Panel(&out, "", nil); err != nil {
+ t.Fatalf("empty Panel(): %v", err)
+ }
+ for failAt := 1; failAt <= 5; failAt++ {
+ writer := &failNthWriter{failAt: failAt}
+ if err := Panel(writer, "Title", []string{"line"}); !errors.Is(err, errPanelWrite) {
+ t.Errorf("Panel failAt=%d error = %v", failAt, err)
+ }
+ }
+}
+
+var errPanelWrite = errors.New("panel write failure")
+
+type failNthWriter struct {
+ writes int
+ failAt int
+}
+
+func (w *failNthWriter) Write(p []byte) (int, error) {
+ w.writes++
+ if w.writes == w.failAt {
+ return 0, errPanelWrite
+ }
+ return len(p), nil
+}
diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go
new file mode 100644
index 00000000..9ea6734c
--- /dev/null
+++ b/internal/upgrade/coverage_edges_test.go
@@ -0,0 +1,924 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+package upgrade
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "syscall"
+ "testing"
+ "time"
+)
+
+type upgradeRoundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f upgradeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+type upgradeFailWriter struct{}
+
+func (upgradeFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
+
+func TestDownloaderEdgeCases(t *testing.T) {
+ badParent := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(badParent, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DownloadWithConfig(t.Context(), "http://example.test", filepath.Join(badParent, "out"), DefaultDownloadConfig()); err == nil {
+ t.Fatal("download mkdir error was ignored")
+ }
+ if _, err := doDownload(t.Context(), ":", filepath.Join(t.TempDir(), "out"), DefaultDownloadConfig()); err == nil {
+ t.Fatal("invalid download URL accepted")
+ }
+
+ okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Length", "4")
+ _, _ = io.WriteString(w, "data")
+ }))
+ defer okServer.Close()
+ cfg := DefaultDownloadConfig()
+ cfg.MaxRetries = 0
+ if n, err := DownloadWithConfig(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "out"), cfg); err != nil || n != 4 {
+ t.Fatalf("download with normalized retries = %d, %v", n, err)
+ }
+ if _, err := doDownload(t.Context(), okServer.URL, t.TempDir(), DefaultDownloadConfig()); err == nil {
+ t.Fatal("download to directory accepted")
+ }
+ statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "bad", http.StatusBadGateway)
+ }))
+ defer statusServer.Close()
+ if _, err := doDownload(t.Context(), statusServer.URL, filepath.Join(t.TempDir(), "out"), DefaultDownloadConfig()); err == nil {
+ t.Fatal("bad HTTP status accepted")
+ }
+
+ var retries atomic.Int32
+ retryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ if retries.Add(1) == 1 {
+ w.Header().Set("Content-Length", "10")
+ _, _ = io.WriteString(w, "x")
+ return
+ }
+ _, _ = io.WriteString(w, "done")
+ }))
+ defer retryServer.Close()
+ cfg = DefaultDownloadConfig()
+ cfg.MaxRetries = 2
+ if _, err := DownloadWithConfig(t.Context(), retryServer.URL, filepath.Join(t.TempDir(), "out"), cfg); err != nil {
+ t.Fatalf("retry download: %v", err)
+ }
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Length", "10")
+ _, _ = io.WriteString(w, "x")
+ cancel()
+ }))
+ defer cancelServer.Close()
+ cfg = DefaultDownloadConfig()
+ cfg.MaxRetries = 2
+ if _, err := DownloadWithConfig(ctx, cancelServer.URL, filepath.Join(t.TempDir(), "out"), cfg); !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancelled retry = %v", err)
+ }
+
+ callbacks := 0
+ cfg = DefaultDownloadConfig()
+ cfg.ProgressInterval = -time.Second
+ cfg.ProgressCallback = func(downloaded, total int64) { callbacks++ }
+ if _, err := DownloadWithConfig(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "progress"), cfg); err != nil || callbacks < 2 {
+ t.Fatalf("progress callbacks = %d, %v", callbacks, err)
+ }
+ var shown bool
+ if _, err := DownloadWithProgress(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "show"), func(percent float64, downloaded, total int64) {
+ shown = percent == 100 && downloaded == total
+ }); err != nil || !shown {
+ t.Fatalf("progress display = %v, %v", shown, err)
+ }
+ if _, err := DownloadWithProgress(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "nil-show"), nil); err != nil {
+ t.Fatal(err)
+ }
+
+ pw := &progressWriter{writer: upgradeFailWriter{}, callback: func(int64, int64) {}, interval: -time.Second}
+ if _, err := pw.Write([]byte("x")); err == nil {
+ t.Fatal("progress writer ignored underlying error")
+ }
+ if !isRetriable(&os.PathError{Op: "read", Path: "x", Err: syscall.ETIMEDOUT}) || isRetriable(errors.New("permanent")) {
+ t.Fatal("retry classification failed")
+ }
+}
+
+func TestGitHubClientEdgeCases(t *testing.T) {
+ t.Setenv("DWS_UPGRADE_URL", "http://mirror.test/")
+ t.Setenv("DWS_UPGRADE_REPOSITORY", "owner/repo")
+ client := NewClient()
+ if client.baseURL != "http://mirror.test" || client.owner != "owner" {
+ t.Fatalf("environment client = %#v", client)
+ }
+ if _, err := client.FetchLatestReleaseForTrack("unknown"); err == nil {
+ t.Fatal("unknown release track accepted")
+ }
+ if _, err := FindBinaryAsset(nil); err == nil {
+ t.Fatal("missing host binary asset accepted")
+ }
+ if got := binaryNameFor("windows"); got != "dws.exe" || binaryNameFor("linux") != "dws" {
+ t.Fatalf("binary names = %q / %q", got, binaryNameFor("linux"))
+ }
+ for tag := range map[string]bool{"1.2": true, "1..3": true, "1.x.3": true, "1.2.3.4": true} {
+ if isVersionLikeTag(tag) {
+ t.Fatalf("invalid version-like tag %q accepted", tag)
+ }
+ }
+ if truncateBytes([]byte("abcd"), 2) != "ab..." || truncateBytes([]byte("ab"), 2) != "ab" {
+ t.Fatal("byte truncation failed")
+ }
+
+ releases := `[
+ {"tag_name":"v1.2.3","prerelease":false,"draft":false},
+ {"tag_name":"v1.3.0-beta.1","prerelease":true,"draft":false},
+ {"tag_name":"nightly","prerelease":true,"draft":false},
+ {"tag_name":"v9.0.0","prerelease":false,"draft":true}
+ ]`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/releases/latest"):
+ _, _ = io.WriteString(w, `{"tag_name":"v2.0.0"}`)
+ case strings.Contains(r.URL.Path, "/releases/tags/"):
+ _, _ = io.WriteString(w, `{"tag_name":"v1.0.0"}`)
+ default:
+ _, _ = io.WriteString(w, releases)
+ }
+ }))
+ defer server.Close()
+ client = NewClientWithBaseURL(server.URL)
+ if _, err := client.FetchLatestReleaseForTrack(ReleaseTrackRelease); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.FetchLatestReleaseForTrack(ReleaseTrackBeta); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.FetchLatestReleaseForTrack(""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.FetchReleaseByTag("1.0.0"); err != nil {
+ t.Fatal(err)
+ }
+ if versions, err := client.FetchReleaseVersions("unknown"); err != nil || len(versions) != 0 {
+ t.Fatalf("unknown track versions = %#v, %v", versions, err)
+ }
+
+ emptyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `[]`)
+ }))
+ defer emptyServer.Close()
+ client = NewClientWithBaseURL(emptyServer.URL)
+ if _, err := client.FetchLatestStableRelease(); err == nil {
+ t.Fatal("missing stable release accepted")
+ }
+ if _, err := client.FetchLatestPrerelease(); err == nil {
+ t.Fatal("missing beta release accepted")
+ }
+
+ t.Setenv("DWS_UPGRADE_REPOSITORY", "invalid")
+ invalidConfig := NewClient()
+ if _, err := invalidConfig.FetchLatestStableRelease(); err == nil {
+ t.Fatal("invalid repository config accepted for stable fetch")
+ }
+ if _, err := invalidConfig.FetchLatestPrerelease(); err == nil {
+ t.Fatal("invalid repository config accepted for beta fetch")
+ }
+ if _, err := invalidConfig.FetchReleaseVersions(ReleaseTrackAll); err == nil {
+ t.Fatal("invalid repository config accepted for versions")
+ }
+ if _, err := invalidConfig.FetchReleaseByTag("v1"); err == nil {
+ t.Fatal("invalid repository config accepted for tag")
+ }
+}
+
+func TestGitHubGetJSONMatrix(t *testing.T) {
+ client := NewClientWithBaseURL("")
+ if err := client.getJSON(":", &map[string]any{}); err == nil {
+ t.Fatal("invalid request URL accepted")
+ }
+ client.httpClient = &http.Client{Transport: upgradeRoundTripFunc(func(*http.Request) (*http.Response, error) {
+ return nil, errors.New("dial failed")
+ })}
+ if err := client.getJSON("http://example.test", &map[string]any{}); err == nil {
+ t.Fatal("transport error ignored")
+ }
+ for name, handler := range map[string]http.HandlerFunc{
+ "rate": func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("X-RateLimit-Remaining", "0")
+ http.Error(w, "limited", http.StatusForbidden)
+ },
+ "not-found": func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "missing", http.StatusNotFound) },
+ "server": func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, strings.Repeat("x", 250), http.StatusInternalServerError)
+ },
+ "invalid-json": func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{`) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := httptest.NewServer(handler)
+ defer server.Close()
+ c := NewClientWithBaseURL(server.URL)
+ if err := c.getJSON(server.URL, &map[string]any{}); err == nil {
+ t.Fatal("getJSON error response accepted")
+ }
+ })
+ }
+}
+
+func TestUpgradePathsAndSkillsEdges(t *testing.T) {
+ originalHome := upgradeUserHomeDir
+ originalDirs := append([]string(nil), knownSkillDirs...)
+ t.Cleanup(func() {
+ upgradeUserHomeDir = originalHome
+ knownSkillDirs = originalDirs
+ })
+ failure := errors.New("home failed")
+ upgradeUserHomeDir = func() (string, error) { return "", failure }
+ if _, err := UpgradeSkillLocations(t.TempDir()); !errors.Is(err, failure) {
+ t.Fatalf("skill home error = %v", err)
+ }
+ if err := EnsureUpgradeDirectories(); !errors.Is(err, failure) {
+ t.Fatalf("directory home error = %v", err)
+ }
+
+ home := t.TempDir()
+ upgradeUserHomeDir = func() (string, error) { return home, nil }
+ source := t.TempDir()
+ if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("skill"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(home, ".present"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
+ result, err := UpgradeSkillLocations(source)
+ if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
+ t.Fatalf("skill upgrade = %#v, %v", result, err)
+ }
+ knownSkillDirs = []string{".real/skills"}
+ result, err = UpgradeSkillLocations(source)
+ if err != nil || len(result.Succeeded()) != 1 {
+ t.Fatalf("skill fallback = %#v, %v", result, err)
+ }
+ if _, err := UpgradeSkillLocations(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Fatal("failed skill fallback accepted")
+ }
+ if err := EnsureUpgradeDirectories(); err != nil {
+ t.Fatal(err)
+ }
+ if got := DownloadCacheDir(); !strings.HasPrefix(got, home) {
+ t.Fatalf("download cache dir = %q", got)
+ }
+
+ modeDir := filepath.Join(t.TempDir(), "mode")
+ if err := os.Mkdir(modeDir, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ if err := ensureDir(modeDir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ blocker := filepath.Join(t.TempDir(), "blocker")
+ if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := ensureDir(filepath.Join(blocker, "child"), 0o700); err == nil {
+ t.Fatal("ensureDir stat error ignored")
+ }
+}
+
+func TestExecutablePathEdges(t *testing.T) {
+ originalExecutable := upgradeExecutable
+ originalEval := upgradeEvalSymlinks
+ t.Cleanup(func() {
+ upgradeExecutable = originalExecutable
+ upgradeEvalSymlinks = originalEval
+ })
+ failure := errors.New("injected failure")
+ upgradeExecutable = func() (string, error) { return "", failure }
+ if _, err := CurrentBinaryPath(); !errors.Is(err, failure) {
+ t.Fatalf("executable error = %v", err)
+ }
+ upgradeExecutable = func() (string, error) { return "/tmp/exe", nil }
+ upgradeEvalSymlinks = func(string) (string, error) { return "", failure }
+ if _, err := CurrentBinaryPath(); !errors.Is(err, failure) {
+ t.Fatalf("symlink error = %v", err)
+ }
+}
+
+func TestReplacerAndZipEdges(t *testing.T) {
+ originalExecutable := upgradeExecutable
+ originalEval := upgradeEvalSymlinks
+ originalRename := upgradeRename
+ originalChmod := upgradeChmod
+ originalCopy := upgradeCopyFile
+ originalSync := upgradeSyncParentDir
+ t.Cleanup(func() {
+ upgradeExecutable = originalExecutable
+ upgradeEvalSymlinks = originalEval
+ upgradeRename = originalRename
+ upgradeChmod = originalChmod
+ upgradeCopyFile = originalCopy
+ upgradeSyncParentDir = originalSync
+ })
+ failure := errors.New("injected failure")
+ current := filepath.Join(t.TempDir(), "dws")
+ if err := os.WriteFile(current, []byte("old"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ upgradeExecutable = func() (string, error) { return current, nil }
+ upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
+ newBinary := filepath.Join(t.TempDir(), "new")
+ if err := os.WriteFile(newBinary, []byte("new"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := ReplaceSelf(newBinary); err != nil {
+ t.Fatal(err)
+ }
+ if data, _ := os.ReadFile(current); string(data) != "new" {
+ t.Fatalf("replaced binary = %q", data)
+ }
+ upgradeExecutable = func() (string, error) { return "", failure }
+ if err := ReplaceSelf("x"); err == nil {
+ t.Fatal("executable failure ignored")
+ }
+ upgradeExecutable = func() (string, error) { return current, nil }
+ upgradeEvalSymlinks = func(string) (string, error) { return "", failure }
+ if err := ReplaceSelf("x"); err == nil {
+ t.Fatal("symlink failure ignored")
+ }
+ upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
+ upgradeChmod = func(string, os.FileMode) error { return failure }
+ if err := ReplaceSelf("x"); err == nil {
+ t.Fatal("chmod failure ignored")
+ }
+ upgradeChmod = originalChmod
+ upgradeRename = func(string, string) error { return failure }
+ upgradeCopyFile = func(string, string, os.FileMode) error { return failure }
+ if err := replaceExeFile("src", "dst"); !errors.Is(err, failure) {
+ t.Fatalf("copy fallback error = %v", err)
+ }
+
+ upgradeRename = originalRename
+ upgradeCopyFile = originalCopy
+ src := filepath.Join(t.TempDir(), "src")
+ dst := filepath.Join(t.TempDir(), "dst")
+ if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := windowsReplace(src, dst); err != nil {
+ t.Fatalf("windows replacement success path: %v", err)
+ }
+ upgradeRename = func(string, string) error { return failure }
+ if err := windowsReplace("src", "dst"); err == nil {
+ t.Fatal("windows move failure ignored")
+ }
+
+ upgradeExecutable = func() (string, error) { return "", failure }
+ CleanupStaleFiles()
+ upgradeExecutable = func() (string, error) { return current, nil }
+ CleanupStaleFiles()
+
+ zipPath := filepath.Join(t.TempDir(), "test.zip")
+ var zipData bytes.Buffer
+ zw := zip.NewWriter(&zipData)
+ _, _ = zw.Create("dir/")
+ f, _ := zw.Create("dir/file.txt")
+ _, _ = f.Write([]byte("ok"))
+ evil, _ := zw.Create("../evil")
+ _, _ = evil.Write([]byte("bad"))
+ if err := zw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(zipPath, zipData.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ target := t.TempDir()
+ if err := ExtractZip(zipPath, target); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(target, "dir", "file.txt")); err != nil {
+ t.Fatal(err)
+ }
+ if err := ExtractZip(filepath.Join(t.TempDir(), "missing"), target); err == nil {
+ t.Fatal("missing zip accepted")
+ }
+ if err := copyFile("missing", filepath.Join(t.TempDir(), "x"), 0o600); err == nil {
+ t.Fatal("missing copy source accepted")
+ }
+ file := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := copyFile(file, t.TempDir(), 0o600); err == nil {
+ t.Fatal("directory copy destination accepted")
+ }
+ syncParentDir(filepath.Join(t.TempDir(), "file"))
+ syncParentDir("/definitely/missing/file")
+}
+
+func TestRollbackLifecycleEdges(t *testing.T) {
+ originalExecutable := upgradeExecutable
+ originalEval := upgradeEvalSymlinks
+ originalHome := upgradeUserHomeDir
+ t.Cleanup(func() {
+ upgradeExecutable = originalExecutable
+ upgradeEvalSymlinks = originalEval
+ upgradeUserHomeDir = originalHome
+ })
+ failure := errors.New("injected failure")
+ upgradeUserHomeDir = func() (string, error) { return "", failure }
+ if got := NewRollbackManager(); !strings.HasPrefix(got.backupDir, ".dws") {
+ t.Fatalf("fallback rollback dir = %q", got.backupDir)
+ }
+
+ current := filepath.Join(t.TempDir(), "dws")
+ if err := os.WriteFile(current, []byte("current"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ upgradeExecutable = func() (string, error) { return current, nil }
+ upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
+ manager := NewRollbackManagerWithDir(t.TempDir())
+ backupPath, err := manager.Backup("1.2.3")
+ if err != nil || backupPath == "" {
+ t.Fatalf("backup = %q, %v", backupPath, err)
+ }
+ backups, err := manager.ListBackups()
+ if err != nil || len(backups) != 1 {
+ t.Fatalf("backups = %#v, %v", backups, err)
+ }
+ if err := os.WriteFile(current, []byte("changed"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := manager.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if data, _ := os.ReadFile(current); string(data) != "current" {
+ t.Fatalf("rolled-back binary = %q", data)
+ }
+ if err := manager.RollbackTo(BackupInfo{Path: t.TempDir()}); err == nil {
+ t.Fatal("missing backup binary accepted")
+ }
+ empty := NewRollbackManagerWithDir(filepath.Join(t.TempDir(), "missing"))
+ if err := empty.Rollback(); err == nil {
+ t.Fatal("empty rollback accepted")
+ }
+
+ for i := 0; i < 3; i++ {
+ name := "v0.0." + string(rune('1'+i)) + "-20260101-00000" + string(rune('1'+i))
+ if err := os.MkdirAll(filepath.Join(manager.backupDir, name), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := manager.Cleanup(1); err != nil {
+ t.Fatal(err)
+ }
+ if err := manager.Cleanup(0); err != nil {
+ t.Fatal(err)
+ }
+ badDir := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(badDir, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewRollbackManagerWithDir(badDir).ListBackups(); err == nil {
+ t.Fatal("backup read error ignored")
+ }
+ syncFileData(current)
+ syncFileData("/missing/file")
+}
+
+func TestVersionPrereleaseEdges(t *testing.T) {
+ cases := []struct {
+ a, b string
+ want int
+ }{
+ {"", "", 0}, {"", "alpha", 1}, {"alpha", "", -1},
+ {"alpha", "alpha.1", -1}, {"alpha.1", "alpha", 1},
+ {"1", "2", -1}, {"2", "1", 1}, {"1", "1", 0},
+ {"1", "alpha", -1}, {"alpha", "1", 1},
+ {"alpha", "beta", -1}, {"beta", "alpha", 1}, {"alpha", "alpha", 0},
+ }
+ for _, tc := range cases {
+ if got := comparePrerelease(tc.a, tc.b); got != tc.want {
+ t.Fatalf("comparePrerelease(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
+ }
+ }
+ if _, ok := parseNumericIdentifier(""); ok {
+ t.Fatal("empty numeric identifier accepted")
+ }
+}
+
+func TestDownloaderRetryControlEdges(t *testing.T) {
+ originalAfter := downloadAfter
+ t.Cleanup(func() { downloadAfter = originalAfter })
+
+ truncated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Length", "10")
+ _, _ = io.WriteString(w, "x")
+ }))
+ defer truncated.Close()
+ cfg := DefaultDownloadConfig()
+ cfg.MaxRetries = 1
+ if _, err := DownloadWithConfig(t.Context(), truncated.URL, filepath.Join(t.TempDir(), "out"), cfg); err == nil || !strings.Contains(err.Error(), "重试 1 次") {
+ t.Fatalf("retry exhaustion = %v", err)
+ }
+
+ ready := make(chan time.Time)
+ close(ready)
+ downloadAfter = func(time.Duration) <-chan time.Time { return ready }
+ cfg.MaxRetries = 7
+ if _, err := DownloadWithConfig(t.Context(), "http://127.0.0.1:1", filepath.Join(t.TempDir(), "out"), cfg); err == nil {
+ t.Fatal("retry exhaustion with capped backoff succeeded")
+ }
+
+ ctx, cancel := context.WithCancel(t.Context())
+ downloadAfter = func(time.Duration) <-chan time.Time {
+ cancel()
+ return make(chan time.Time)
+ }
+ cfg.MaxRetries = 2
+ if _, err := DownloadWithConfig(ctx, "http://127.0.0.1:1", filepath.Join(t.TempDir(), "out"), cfg); !errors.Is(err, context.Canceled) {
+ t.Fatalf("backoff cancellation = %v", err)
+ }
+}
+
+func TestGitHubFetchReleasesError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "failure", http.StatusInternalServerError)
+ }))
+ defer server.Close()
+ if _, err := NewClientWithBaseURL(server.URL).FetchAllReleases(); err == nil {
+ t.Fatal("release list HTTP error ignored")
+ }
+}
+
+func TestUpgradePathInjectedFailures(t *testing.T) {
+ originalHome := upgradeUserHomeDir
+ originalDirs := append([]string(nil), knownSkillDirs...)
+ originalCopy := upgradeCopyDir
+ originalEnsure := upgradeEnsureDir
+ t.Cleanup(func() {
+ upgradeUserHomeDir = originalHome
+ knownSkillDirs = originalDirs
+ upgradeCopyDir = originalCopy
+ upgradeEnsureDir = originalEnsure
+ })
+ home := t.TempDir()
+ upgradeUserHomeDir = func() (string, error) { return home, nil }
+ knownSkillDirs = []string{".agents/skills"}
+ calls := 0
+ upgradeCopyDir = func(src, dst string) error {
+ calls++
+ if calls == 1 {
+ return errors.New("primary failed")
+ }
+ return originalCopy(src, dst)
+ }
+ source := t.TempDir()
+ if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ result, err := UpgradeSkillLocations(source)
+ if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
+ t.Fatalf("replaced primary fallback = %#v, %v", result, err)
+ }
+ failure := errors.New("ensure failed")
+ upgradeEnsureDir = func(string, os.FileMode) error { return failure }
+ if err := EnsureUpgradeDirectories(); !errors.Is(err, failure) {
+ t.Fatalf("ensure upgrade directory error = %v", err)
+ }
+}
+
+func TestReplacerInjectedIOEdges(t *testing.T) {
+ originalExecutable := upgradeExecutable
+ originalEval := upgradeEvalSymlinks
+ originalRename := upgradeRename
+ originalChmod := upgradeChmod
+ originalCopy := upgradeCopyFile
+ originalSyncParent := upgradeSyncParentDir
+ originalOpenEntry := upgradeOpenZipEntry
+ originalOpenFile := upgradeOpenFile
+ originalCopyIO := upgradeIOCopy
+ originalSync := upgradeFileSync
+ t.Cleanup(func() {
+ upgradeExecutable = originalExecutable
+ upgradeEvalSymlinks = originalEval
+ upgradeRename = originalRename
+ upgradeChmod = originalChmod
+ upgradeCopyFile = originalCopy
+ upgradeSyncParentDir = originalSyncParent
+ upgradeOpenZipEntry = originalOpenEntry
+ upgradeOpenFile = originalOpenFile
+ upgradeIOCopy = originalCopyIO
+ upgradeFileSync = originalSync
+ })
+ failure := errors.New("injected failure")
+ current := filepath.Join(t.TempDir(), "current")
+ newBinary := filepath.Join(t.TempDir(), "new")
+ for _, path := range []string{current, newBinary} {
+ if err := os.WriteFile(path, []byte("x"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ upgradeExecutable = func() (string, error) { return current, nil }
+ upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
+ upgradeRename = func(string, string) error { return failure }
+ upgradeCopyFile = func(string, string, os.FileMode) error { return failure }
+ if err := ReplaceSelf(newBinary); !errors.Is(err, failure) {
+ t.Fatalf("ReplaceSelf replacement error = %v", err)
+ }
+
+ if err := replaceExeFileFor("src", "dst", "windows"); err == nil {
+ t.Fatal("windows replace path unexpectedly succeeded")
+ }
+ renameCalls := 0
+ upgradeRename = func(src, dst string) error {
+ renameCalls++
+ if renameCalls == 1 {
+ return originalRename(src, dst)
+ }
+ return failure
+ }
+ upgradeCopyFile = originalCopy
+ src := filepath.Join(t.TempDir(), "src")
+ dst := filepath.Join(t.TempDir(), "dst")
+ if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := windowsReplace(src, dst); err != nil {
+ t.Fatalf("windows copy fallback: %v", err)
+ }
+ renameCalls = 0
+ upgradeCopyFile = func(string, string, os.FileMode) error { return failure }
+ src = filepath.Join(t.TempDir(), "src")
+ dst = filepath.Join(t.TempDir(), "dst")
+ _ = os.WriteFile(src, []byte("new"), 0o600)
+ _ = os.WriteFile(dst, []byte("old"), 0o600)
+ if err := windowsReplace(src, dst); err == nil {
+ t.Fatal("windows copy fallback failure ignored")
+ }
+
+ var zipData bytes.Buffer
+ zw := zip.NewWriter(&zipData)
+ entryWriter, _ := zw.Create("file")
+ _, _ = entryWriter.Write([]byte("x"))
+ _ = zw.Close()
+ zipPath := filepath.Join(t.TempDir(), "one.zip")
+ _ = os.WriteFile(zipPath, zipData.Bytes(), 0o600)
+ reader, err := zip.OpenReader(zipPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ entry := reader.File[0]
+ upgradeOpenZipEntry = func(*zip.File) (io.ReadCloser, error) { return nil, failure }
+ if err := extractZipEntry(entry, filepath.Join(t.TempDir(), "out")); !errors.Is(err, failure) {
+ t.Fatalf("zip entry open error = %v", err)
+ }
+ upgradeOpenZipEntry = originalOpenEntry
+ upgradeOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, failure }
+ if err := extractZipEntry(entry, filepath.Join(t.TempDir(), "out")); !errors.Is(err, failure) {
+ t.Fatalf("zip destination open error = %v", err)
+ }
+ upgradeOpenFile = originalOpenFile
+ upgradeIOCopy = func(io.Writer, io.Reader) (int64, error) { return 0, failure }
+ if err := extractZipEntry(entry, filepath.Join(t.TempDir(), "out")); !errors.Is(err, failure) {
+ t.Fatalf("zip copy error = %v", err)
+ }
+ file := filepath.Join(t.TempDir(), "file")
+ _ = os.WriteFile(file, []byte("x"), 0o600)
+ if err := copyFile(file, filepath.Join(t.TempDir(), "out"), 0o600); !errors.Is(err, failure) {
+ t.Fatalf("file copy error = %v", err)
+ }
+ upgradeIOCopy = originalCopyIO
+ upgradeFileSync = func(*os.File) error { return failure }
+ if err := copyFile(file, filepath.Join(t.TempDir(), "out"), 0o600); !errors.Is(err, failure) {
+ t.Fatalf("file sync error = %v", err)
+ }
+
+ target := t.TempDir()
+ if err := os.Mkdir(filepath.Join(target, "file"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ upgradeFileSync = originalSync
+ if err := ExtractZip(zipPath, target); err == nil {
+ t.Fatal("zip extraction destination error ignored")
+ }
+}
+
+func TestRollbackInjectedFailures(t *testing.T) {
+ originalExecutable := upgradeExecutable
+ originalEval := upgradeEvalSymlinks
+ originalStat := rollbackStat
+ originalMkdir := rollbackMkdirAll
+ originalCopy := rollbackCopyFile
+ originalReadDir := rollbackReadDir
+ originalRemove := rollbackRemoveAll
+ originalMarshal := rollbackMarshalIndent
+ originalWrite := rollbackWriteFile
+ originalRead := rollbackReadFile
+ originalReplace := rollbackReplaceFile
+ t.Cleanup(func() {
+ upgradeExecutable = originalExecutable
+ upgradeEvalSymlinks = originalEval
+ rollbackStat = originalStat
+ rollbackMkdirAll = originalMkdir
+ rollbackCopyFile = originalCopy
+ rollbackReadDir = originalReadDir
+ rollbackRemoveAll = originalRemove
+ rollbackMarshalIndent = originalMarshal
+ rollbackWriteFile = originalWrite
+ rollbackReadFile = originalRead
+ rollbackReplaceFile = originalReplace
+ })
+ failure := errors.New("injected failure")
+ manager := NewRollbackManagerWithDir(t.TempDir())
+ upgradeExecutable = func() (string, error) { return "", failure }
+ if _, err := manager.Backup("1"); err == nil {
+ t.Fatal("backup executable error ignored")
+ }
+ if err := manager.RollbackTo(BackupInfo{}); err == nil {
+ t.Fatal("rollback executable error ignored")
+ }
+ upgradeExecutable = func() (string, error) { return "/current", nil }
+ upgradeEvalSymlinks = func(string) (string, error) { return "", failure }
+ if _, err := manager.Backup("1"); err == nil {
+ t.Fatal("backup symlink error ignored")
+ }
+ if err := manager.RollbackTo(BackupInfo{}); err == nil {
+ t.Fatal("rollback symlink error ignored")
+ }
+ upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
+ rollbackStat = func(string) (os.FileInfo, error) { return nil, failure }
+ if _, err := manager.Backup("1"); err == nil {
+ t.Fatal("backup stat error ignored")
+ }
+
+ current := filepath.Join(t.TempDir(), "current")
+ backup := filepath.Join(t.TempDir(), "backup")
+ _ = os.WriteFile(current, []byte("old"), 0o700)
+ _ = os.WriteFile(backup, []byte("new"), 0o700)
+ upgradeExecutable = func() (string, error) { return current, nil }
+ rollbackStat = originalStat
+ for failAt := 1; failAt <= 3; failAt++ {
+ calls := 0
+ rollbackMkdirAll = func(path string, mode os.FileMode) error {
+ calls++
+ if calls == failAt {
+ return failure
+ }
+ return originalMkdir(path, mode)
+ }
+ if _, err := NewRollbackManagerWithDir(filepath.Join(t.TempDir(), "backups")).Backup("1"); err == nil {
+ t.Fatalf("backup mkdir stage %d error ignored", failAt)
+ }
+ }
+ rollbackMkdirAll = originalMkdir
+ rollbackCopyFile = func(string, string, os.FileMode) error { return failure }
+ if _, err := manager.Backup("1"); err == nil {
+ t.Fatal("backup copy error ignored")
+ }
+ if err := manager.RollbackTo(BackupInfo{BinaryPath: backup}); err == nil {
+ t.Fatal("rollback preparation copy error ignored")
+ }
+ rollbackCopyFile = originalCopy
+ rollbackReplaceFile = func(string, string) error { return failure }
+ if err := manager.RollbackTo(BackupInfo{BinaryPath: backup}); err == nil {
+ t.Fatal("rollback replacement error ignored")
+ }
+
+ rollbackReadDir = func(string) ([]os.DirEntry, error) { return nil, failure }
+ if err := manager.Rollback(); err == nil {
+ t.Fatal("rollback list error ignored")
+ }
+ if err := manager.Cleanup(1); err == nil {
+ t.Fatal("cleanup list error ignored")
+ }
+ rollbackReadDir = originalReadDir
+ rollbackRemoveAll = func(string) error { return failure }
+ _ = rollbackRemoveAll
+ rollbackMarshalIndent = func(any, string, string) ([]byte, error) { return nil, failure }
+ manager.saveBackupInfo(BackupInfo{Path: t.TempDir()})
+ rollbackMarshalIndent = originalMarshal
+ rollbackWriteFile = func(string, []byte, os.FileMode) error { return failure }
+ manager.saveBackupInfo(BackupInfo{Path: t.TempDir()})
+ rollbackWriteFile = originalWrite
+ rollbackReadFile = func(string) ([]byte, error) { return nil, failure }
+ if _, err := manager.loadBackupInfo(t.TempDir()); !errors.Is(err, failure) {
+ t.Fatalf("backup info read error = %v", err)
+ }
+}
+
+func TestCopyDirAndVerifyErrors(t *testing.T) {
+ if err := copyDir("missing", t.TempDir()); err == nil {
+ t.Fatal("missing source directory accepted")
+ }
+ sourceFile := filepath.Join(t.TempDir(), "source")
+ _ = os.WriteFile(sourceFile, []byte("x"), 0o600)
+ if err := copyDir(sourceFile, t.TempDir()); err == nil {
+ t.Fatal("file source directory accepted")
+ }
+ blocker := filepath.Join(t.TempDir(), "blocker")
+ _ = os.WriteFile(blocker, []byte("x"), 0o600)
+ if err := copyDir(t.TempDir(), filepath.Join(blocker, "child")); err == nil {
+ t.Fatal("copy directory mkdir error ignored")
+ }
+ source := t.TempDir()
+ if err := os.Symlink(filepath.Join(source, "missing"), filepath.Join(source, "dangling")); err == nil {
+ if err := copyDir(source, t.TempDir()); err == nil {
+ t.Fatal("dangling source entry accepted")
+ }
+ }
+ if err := VerifySHA256("missing", "deadbeef"); err == nil {
+ t.Fatal("missing checksum file accepted")
+ }
+ if _, err := ComputeSHA256("missing"); err == nil {
+ t.Fatal("missing SHA file accepted")
+ }
+}
+
+func TestCopyDirAndBackupEntryInjectedEdges(t *testing.T) {
+ originalRollbackInfo := rollbackEntryInfo
+ originalStat := upgradeDirStat
+ originalMkdir := upgradeDirMkdirAll
+ originalReadDir := upgradeDirReadDir
+ originalInfo := upgradeDirEntryInfo
+ originalOpen := upgradeDirOpen
+ originalOpenFile := upgradeDirOpenFile
+ originalCopy := upgradeDirCopy
+ t.Cleanup(func() {
+ rollbackEntryInfo = originalRollbackInfo
+ upgradeDirStat = originalStat
+ upgradeDirMkdirAll = originalMkdir
+ upgradeDirReadDir = originalReadDir
+ upgradeDirEntryInfo = originalInfo
+ upgradeDirOpen = originalOpen
+ upgradeDirOpenFile = originalOpenFile
+ upgradeDirCopy = originalCopy
+ })
+ failure := errors.New("injected failure")
+
+ backupDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(backupDir, "v1-20260101"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ rollbackEntryInfo = func(os.DirEntry) (os.FileInfo, error) { return nil, failure }
+ backups, err := NewRollbackManagerWithDir(backupDir).ListBackups()
+ if err != nil || len(backups) != 0 {
+ t.Fatalf("failed backup entry info = %#v, %v", backups, err)
+ }
+
+ source := t.TempDir()
+ if err := os.Mkdir(filepath.Join(source, "sub"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "file"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ upgradeDirReadDir = func(path string) ([]os.DirEntry, error) {
+ if strings.HasSuffix(path, "sub") {
+ return nil, failure
+ }
+ return originalReadDir(path)
+ }
+ if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
+ t.Fatalf("recursive copy error = %v", err)
+ }
+ upgradeDirReadDir = originalReadDir
+ upgradeDirEntryInfo = func(os.DirEntry) (os.FileInfo, error) { return nil, failure }
+ if err := copyDir(source, t.TempDir()); err != nil {
+ t.Fatalf("entry info errors should be skipped: %v", err)
+ }
+ upgradeDirEntryInfo = originalInfo
+ upgradeDirOpen = func(string) (*os.File, error) { return nil, failure }
+ if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
+ t.Fatalf("source open error = %v", err)
+ }
+ upgradeDirOpen = originalOpen
+ upgradeDirOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, failure }
+ if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
+ t.Fatalf("destination open error = %v", err)
+ }
+ upgradeDirOpenFile = originalOpenFile
+ upgradeDirCopy = func(io.Writer, io.Reader) (int64, error) { return 0, failure }
+ if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
+ t.Fatalf("directory file copy error = %v", err)
+ }
+}
diff --git a/internal/upgrade/downloader.go b/internal/upgrade/downloader.go
index 38584429..52f26996 100644
--- a/internal/upgrade/downloader.go
+++ b/internal/upgrade/downloader.go
@@ -22,6 +22,8 @@ type DownloadConfig struct {
ProgressInterval time.Duration
}
+var downloadAfter = time.After
+
// DefaultDownloadConfig returns sensible download defaults.
func DefaultDownloadConfig() *DownloadConfig {
return &DownloadConfig{
@@ -69,7 +71,7 @@ func DownloadWithConfig(ctx context.Context, url, destPath string, cfg *Download
select {
case <-ctx.Done():
return 0, ctx.Err()
- case <-time.After(backoff):
+ case <-downloadAfter(backoff):
}
}
diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go
index 5412d947..3db2f6f6 100644
--- a/internal/upgrade/paths.go
+++ b/internal/upgrade/paths.go
@@ -49,6 +49,14 @@ var knownSkillDirs = []string{
".hermes/skills",
}
+var (
+ upgradeUserHomeDir = os.UserHomeDir
+ upgradeExecutable = os.Executable
+ upgradeEvalSymlinks = filepath.EvalSymlinks
+ upgradeCopyDir = copyDir
+ upgradeEnsureDir = ensureDir
+)
+
// skillDirBlacklist contains parent directories whose skills are managed by
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
var skillDirBlacklist = []string{
@@ -109,7 +117,7 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
- homeDir, err := os.UserHomeDir()
+ homeDir, err := upgradeUserHomeDir()
if err != nil {
return nil, err
}
@@ -133,7 +141,7 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
}
os.RemoveAll(destDir)
- if err := copyDir(extractedDir, destDir); err != nil {
+ if err := upgradeCopyDir(extractedDir, destDir); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
@@ -144,7 +152,7 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
if len(result.Succeeded()) == 0 {
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
os.MkdirAll(filepath.Dir(dest), dirPermShared)
- if err := copyDir(extractedDir, dest); err != nil {
+ if err := upgradeCopyDir(extractedDir, dest); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
@@ -184,7 +192,7 @@ func LocateSkillMD(extractDir string) string {
// EnsureUpgradeDirectories creates the directories needed for upgrade operations.
func EnsureUpgradeDirectories() error {
- homeDir, err := os.UserHomeDir()
+ homeDir, err := upgradeUserHomeDir()
if err != nil {
return err
}
@@ -201,7 +209,7 @@ func EnsureUpgradeDirectories() error {
}
for _, d := range dirs {
- if err := ensureDir(d.path, d.perm); err != nil {
+ if err := upgradeEnsureDir(d.path, d.perm); err != nil {
return err
}
}
@@ -210,22 +218,26 @@ func EnsureUpgradeDirectories() error {
// DownloadCacheDir returns the path for temporary downloads during upgrade.
func DownloadCacheDir() string {
- homeDir, _ := os.UserHomeDir()
+ homeDir, _ := upgradeUserHomeDir()
return filepath.Join(homeDir, ".dws", "cache", "downloads")
}
// CurrentBinaryPath returns the resolved path of the currently running binary.
func CurrentBinaryPath() (string, error) {
- exe, err := os.Executable()
+ exe, err := upgradeExecutable()
if err != nil {
return "", err
}
- return filepath.EvalSymlinks(exe)
+ return upgradeEvalSymlinks(exe)
}
// BinaryName returns the platform-specific binary name.
func BinaryName() string {
- if runtime.GOOS == "windows" {
+ return binaryNameFor(runtime.GOOS)
+}
+
+func binaryNameFor(goos string) string {
+ if goos == "windows" {
return "dws.exe"
}
return "dws"
diff --git a/internal/upgrade/replacer.go b/internal/upgrade/replacer.go
index c4fd25fd..5108c0c7 100644
--- a/internal/upgrade/replacer.go
+++ b/internal/upgrade/replacer.go
@@ -13,6 +13,17 @@ import (
"strings"
)
+var (
+ upgradeRename = os.Rename
+ upgradeChmod = os.Chmod
+ upgradeCopyFile = copyFile
+ upgradeSyncParentDir = syncParentDir
+ upgradeOpenZipEntry = func(f *zip.File) (io.ReadCloser, error) { return f.Open() }
+ upgradeOpenFile = os.OpenFile
+ upgradeIOCopy = io.Copy
+ upgradeFileSync = func(f *os.File) error { return f.Sync() }
+)
+
// ReplaceSelf atomically replaces the currently running binary with newBinaryPath.
//
// On Unix (macOS / Linux):
@@ -23,16 +34,16 @@ import (
// Strategy: rename running exe → .old, then rename/copy new binary in, then
// clean up .old (best-effort, may be cleaned on next run).
func ReplaceSelf(newBinaryPath string) error {
- currentExe, err := os.Executable()
+ currentExe, err := upgradeExecutable()
if err != nil {
return fmt.Errorf("无法获取当前二进制路径: %w", err)
}
- currentExe, err = filepath.EvalSymlinks(currentExe)
+ currentExe, err = upgradeEvalSymlinks(currentExe)
if err != nil {
return fmt.Errorf("无法解析符号链接: %w", err)
}
- if err := os.Chmod(newBinaryPath, filePermBinary); err != nil {
+ if err := upgradeChmod(newBinaryPath, filePermBinary); err != nil {
return fmt.Errorf("设置权限失败: %w", err)
}
@@ -40,23 +51,27 @@ func ReplaceSelf(newBinaryPath string) error {
return err
}
- syncParentDir(currentExe)
+ upgradeSyncParentDir(currentExe)
return nil
}
// replaceExeFile replaces dst with src, handling Windows file-lock semantics.
func replaceExeFile(src, dst string) error {
+ return replaceExeFileFor(src, dst, runtime.GOOS)
+}
+
+func replaceExeFileFor(src, dst, goos string) error {
// Fast path: atomic rename (works on Unix same-filesystem, or Windows when dst is unlocked)
- if err := os.Rename(src, dst); err == nil {
+ if err := upgradeRename(src, dst); err == nil {
return nil
}
- if runtime.GOOS == "windows" {
+ if goos == "windows" {
return windowsReplace(src, dst)
}
// Unix cross-device fallback
- return copyFile(src, dst, filePermBinary)
+ return upgradeCopyFile(src, dst, filePermBinary)
}
// windowsReplace handles the Windows-specific case where the running exe is locked.
@@ -68,16 +83,16 @@ func windowsReplace(src, dst string) error {
os.Remove(oldPath)
// Move the running (locked) binary out of the way
- if err := os.Rename(dst, oldPath); err != nil {
+ if err := upgradeRename(dst, oldPath); err != nil {
return fmt.Errorf("无法移动正在运行的二进制文件: %w", err)
}
// Place the new binary at the target path
- if err := os.Rename(src, dst); err != nil {
+ if err := upgradeRename(src, dst); err != nil {
// Cross-device fallback
- if cpErr := copyFile(src, dst, filePermBinary); cpErr != nil {
+ if cpErr := upgradeCopyFile(src, dst, filePermBinary); cpErr != nil {
// Attempt to restore the original
- os.Rename(oldPath, dst)
+ upgradeRename(oldPath, dst)
return fmt.Errorf("替换失败: %w", cpErr)
}
}
@@ -157,19 +172,19 @@ func FindBinaryInDir(dir string) string {
func extractZipEntry(f *zip.File, destPath string) error {
os.MkdirAll(filepath.Dir(destPath), dirPermShared)
- rc, err := f.Open()
+ rc, err := upgradeOpenZipEntry(f)
if err != nil {
return fmt.Errorf("读取 zip 条目失败: %w", err)
}
defer rc.Close()
- out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermConfig)
+ out, err := upgradeOpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermConfig)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
- _, err = io.Copy(out, rc)
+ _, err = upgradeIOCopy(out, rc)
return err
}
@@ -180,16 +195,16 @@ func copyFile(src, dst string, perm os.FileMode) error {
}
defer in.Close()
- out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
+ out, err := upgradeOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
defer out.Close()
- if _, err = io.Copy(out, in); err != nil {
+ if _, err = upgradeIOCopy(out, in); err != nil {
return err
}
- return out.Sync()
+ return upgradeFileSync(out)
}
func syncParentDir(path string) {
diff --git a/internal/upgrade/rollback.go b/internal/upgrade/rollback.go
index 9431b09b..5898a3c2 100644
--- a/internal/upgrade/rollback.go
+++ b/internal/upgrade/rollback.go
@@ -15,6 +15,26 @@ import (
const defaultMaxBackups = 5
+var (
+ rollbackStat = os.Stat
+ rollbackMkdirAll = os.MkdirAll
+ rollbackCopyFile = copyFile
+ rollbackReadDir = os.ReadDir
+ rollbackRemoveAll = os.RemoveAll
+ rollbackMarshalIndent = json.MarshalIndent
+ rollbackWriteFile = os.WriteFile
+ rollbackReadFile = os.ReadFile
+ rollbackReplaceFile = replaceExeFile
+ rollbackEntryInfo = func(entry os.DirEntry) (os.FileInfo, error) { return entry.Info() }
+ upgradeDirStat = os.Stat
+ upgradeDirMkdirAll = os.MkdirAll
+ upgradeDirReadDir = os.ReadDir
+ upgradeDirEntryInfo = func(entry os.DirEntry) (os.FileInfo, error) { return entry.Info() }
+ upgradeDirOpen = os.Open
+ upgradeDirOpenFile = os.OpenFile
+ upgradeDirCopy = io.Copy
+)
+
// BackupInfo contains information about a single backup.
type BackupInfo struct {
Path string `json:"path"`
@@ -32,7 +52,7 @@ type RollbackManager struct {
// NewRollbackManager creates a rollback manager using the standard backup directory.
func NewRollbackManager() *RollbackManager {
- homeDir, err := os.UserHomeDir()
+ homeDir, err := upgradeUserHomeDir()
if err != nil {
homeDir = "."
}
@@ -53,21 +73,21 @@ func NewRollbackManagerWithDir(backupDir string) *RollbackManager {
// Backup creates a backup of the currently running binary.
// Returns the backup directory path.
func (r *RollbackManager) Backup(currentVersion string) (string, error) {
- currentExe, err := os.Executable()
+ currentExe, err := upgradeExecutable()
if err != nil {
return "", fmt.Errorf("无法获取当前二进制路径: %w", err)
}
- currentExe, err = filepath.EvalSymlinks(currentExe)
+ currentExe, err = upgradeEvalSymlinks(currentExe)
if err != nil {
return "", fmt.Errorf("无法解析符号链接: %w", err)
}
- info, err := os.Stat(currentExe)
+ info, err := rollbackStat(currentExe)
if err != nil {
return "", fmt.Errorf("无法读取当前二进制信息: %w", err)
}
- if err := os.MkdirAll(r.backupDir, dirPermSecure); err != nil {
+ if err := rollbackMkdirAll(r.backupDir, dirPermSecure); err != nil {
return "", fmt.Errorf("创建备份目录失败: %w", err)
}
@@ -75,17 +95,17 @@ func (r *RollbackManager) Backup(currentVersion string) (string, error) {
backupSetName := fmt.Sprintf("v%s-%s", currentVersion, timestamp)
backupSetPath := filepath.Join(r.backupDir, backupSetName)
- if err := os.MkdirAll(backupSetPath, dirPermSecure); err != nil {
+ if err := rollbackMkdirAll(backupSetPath, dirPermSecure); err != nil {
return "", fmt.Errorf("创建备份集目录失败: %w", err)
}
binaryBackupDir := filepath.Join(backupSetPath, "binary")
- if err := os.MkdirAll(binaryBackupDir, dirPermSecure); err != nil {
+ if err := rollbackMkdirAll(binaryBackupDir, dirPermSecure); err != nil {
return "", fmt.Errorf("创建二进制备份目录失败: %w", err)
}
binaryBackupPath := filepath.Join(binaryBackupDir, filepath.Base(currentExe))
- if err := copyFile(currentExe, binaryBackupPath, info.Mode()); err != nil {
+ if err := rollbackCopyFile(currentExe, binaryBackupPath, info.Mode()); err != nil {
return "", fmt.Errorf("备份二进制失败: %w", err)
}
@@ -116,11 +136,11 @@ func (r *RollbackManager) Rollback() error {
// RollbackTo restores a specific backup.
// Uses replaceExeFile to handle Windows file-lock semantics correctly.
func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
- currentExe, err := os.Executable()
+ currentExe, err := upgradeExecutable()
if err != nil {
return fmt.Errorf("无法获取当前二进制路径: %w", err)
}
- currentExe, err = filepath.EvalSymlinks(currentExe)
+ currentExe, err = upgradeEvalSymlinks(currentExe)
if err != nil {
return fmt.Errorf("无法解析符号链接: %w", err)
}
@@ -130,17 +150,17 @@ func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
binaryBackupPath = filepath.Join(backup.Path, "binary", BinaryName())
}
- if _, err := os.Stat(binaryBackupPath); os.IsNotExist(err) {
+ if _, err := rollbackStat(binaryBackupPath); os.IsNotExist(err) {
return fmt.Errorf("备份文件不存在: %s", binaryBackupPath)
}
// Copy backup to a temp file first so replaceExeFile can use rename
tmpPath := currentExe + ".rollback-tmp"
- if err := copyFile(binaryBackupPath, tmpPath, filePermBinary); err != nil {
+ if err := rollbackCopyFile(binaryBackupPath, tmpPath, filePermBinary); err != nil {
return fmt.Errorf("准备回滚文件失败: %w", err)
}
- if err := replaceExeFile(tmpPath, currentExe); err != nil {
+ if err := rollbackReplaceFile(tmpPath, currentExe); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("恢复二进制失败: %w", err)
}
@@ -151,7 +171,7 @@ func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
// ListBackups returns all available backups, newest first.
func (r *RollbackManager) ListBackups() ([]BackupInfo, error) {
- entries, err := os.ReadDir(r.backupDir)
+ entries, err := rollbackReadDir(r.backupDir)
if os.IsNotExist(err) {
return nil, nil
}
@@ -167,7 +187,7 @@ func (r *RollbackManager) ListBackups() ([]BackupInfo, error) {
backupPath := filepath.Join(r.backupDir, entry.Name())
info, err := r.loadBackupInfo(backupPath)
if err != nil {
- fi, statErr := entry.Info()
+ fi, statErr := rollbackEntryInfo(entry)
if statErr != nil {
continue
}
@@ -197,23 +217,23 @@ func (r *RollbackManager) Cleanup(keep int) error {
keep = r.maxBackups
}
for i := keep; i < len(backups); i++ {
- os.RemoveAll(backups[i].Path)
+ rollbackRemoveAll(backups[i].Path)
}
return nil
}
func (r *RollbackManager) saveBackupInfo(info BackupInfo) {
infoPath := filepath.Join(info.Path, "info.json")
- data, err := json.MarshalIndent(info, "", " ")
+ data, err := rollbackMarshalIndent(info, "", " ")
if err != nil {
return
}
- os.WriteFile(infoPath, data, filePermConfig)
+ rollbackWriteFile(infoPath, data, filePermConfig)
}
func (r *RollbackManager) loadBackupInfo(backupSetPath string) (BackupInfo, error) {
infoPath := filepath.Join(backupSetPath, "info.json")
- data, err := os.ReadFile(infoPath)
+ data, err := rollbackReadFile(infoPath)
if err != nil {
return BackupInfo{}, err
}
@@ -252,15 +272,15 @@ func syncFileData(path string) {
// copyDir recursively copies a directory from src to dst.
func copyDir(src, dst string) error {
- srcInfo, err := os.Stat(src)
+ srcInfo, err := upgradeDirStat(src)
if err != nil {
return err
}
- if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
+ if err := upgradeDirMkdirAll(dst, srcInfo.Mode()); err != nil {
return err
}
- entries, err := os.ReadDir(src)
+ entries, err := upgradeDirReadDir(src)
if err != nil {
return err
}
@@ -274,20 +294,20 @@ func copyDir(src, dst string) error {
return err
}
} else {
- info, err := entry.Info()
+ info, err := upgradeDirEntryInfo(entry)
if err != nil {
continue
}
- srcFile, err := os.Open(srcPath)
+ srcFile, err := upgradeDirOpen(srcPath)
if err != nil {
return err
}
- dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
+ dstFile, err := upgradeDirOpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
srcFile.Close()
return err
}
- _, err = io.Copy(dstFile, srcFile)
+ _, err = upgradeDirCopy(dstFile, srcFile)
srcFile.Close()
dstFile.Close()
if err != nil {