From 8633246effb53a372b269b1aaffe805fe18e4b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Mon, 13 Jul 2026 16:52:52 +0800 Subject: [PATCH] test: expand unit coverage --- cmd/main.go | 4 +- cmd/main_test.go | 27 + internal/apiclient/client.go | 4 +- internal/apiclient/coverage_edges_test.go | 293 ++ internal/app/auth_command.go | 15 +- internal/app/coverage_edges_test.go | 2311 +++++++++++++ internal/app/coverage_more_test.go | 433 +++ internal/app/skill_command.go | 6 +- internal/auth/app_config.go | 32 +- internal/auth/app_token.go | 21 +- internal/auth/browser_unix.go | 20 +- internal/auth/coverage_edges_test.go | 2924 +++++++++++++++++ internal/auth/device_flow.go | 67 +- internal/auth/endpoints.go | 11 +- internal/auth/filelock.go | 26 +- internal/auth/identity.go | 26 +- internal/auth/keychain_store.go | 35 +- internal/auth/oauth_helpers.go | 33 +- internal/auth/oauth_provider.go | 80 +- internal/auth/oauth_provider_coverage_test.go | 1104 +++++++ internal/auth/portable_store.go | 80 +- internal/auth/profiles.go | 97 +- internal/auth/secret.go | 9 +- internal/auth/secure_store.go | 55 +- internal/auth/token.go | 134 +- internal/cli/coverage_edges_test.go | 445 +++ internal/errors/coverage_edges_test.go | 228 ++ internal/errors/errors.go | 4 +- internal/errors/validate.go | 18 +- internal/event/bus/coverage_edges_test.go | 507 +++ internal/event/bus/daemon.go | 18 +- internal/event/bus/dropwarn.go | 44 +- internal/event/bus/lockfile.go | 26 +- internal/event/bus/meta.go | 15 +- internal/event/bus/metrics.go | 3 + internal/event/busctl/coverage_edges_test.go | 310 ++ internal/event/busctl/discover.go | 14 +- internal/event/busctl/spawn.go | 11 +- internal/event/busctl/status.go | 20 +- internal/event/busctl/stop.go | 17 +- internal/event/consume/coverage_edges_test.go | 273 ++ internal/event/consume/formatter.go | 12 +- internal/event/consume/router.go | 3 - internal/event/consume/run.go | 4 +- internal/event/consume/sink.go | 15 +- internal/event/endpoint.go | 14 +- internal/event/endpoint_test.go | 18 + internal/event/lock/lock.go | 4 +- internal/event/lock/lock_test.go | 18 + internal/event/personal/client.go | 31 +- .../event/personal/coverage_edges_test.go | 450 +++ internal/event/personal/state.go | 31 +- internal/event/process/process_alive_unix.go | 4 +- .../event/process/process_alive_unix_test.go | 23 + .../event/registry/compact_processors_test.go | 27 + internal/event/source/coverage_edges_test.go | 568 ++++ internal/event/source/dingtalk.go | 19 +- internal/event/source/personal.go | 12 +- internal/event/source/portal_ticket.go | 25 +- .../event/transport/coverage_edges_test.go | 82 + internal/event/transport/frame.go | 5 +- internal/event/transport/transport_unix.go | 19 +- internal/event/types.go | 11 +- internal/event/types_test.go | 11 + internal/executor/invocation_test.go | 70 + internal/helpers/aitable.go | 89 +- .../helpers/aitable_command_edges_test.go | 298 ++ internal/helpers/aitable_core_test.go | 315 ++ internal/helpers/attendance.go | 13 +- .../helpers/attendance_command_edges_test.go | 169 + .../helpers/attendance_coverage_more_test.go | 167 + .../helpers/calendar_coverage_more_test.go | 216 ++ internal/helpers/chat.go | 54 +- internal/helpers/chat_command_edges_test.go | 202 ++ internal/helpers/chat_coverage_more_test.go | 394 +++ internal/helpers/command_matrix_test.go | 382 +++ internal/helpers/connect_approval_card.go | 2 +- .../helpers/connect_approval_card_test.go | 42 + internal/helpers/connect_at_poll.go | 2 +- internal/helpers/connect_card_test.go | 43 + .../helpers/connect_coverage_more_test.go | 387 +++ internal/helpers/connect_daemon.go | 117 +- .../connect_daemon_coverage_more_test.go | 914 ++++++ .../helpers/connect_knowledge_wiki_test.go | 107 + internal/helpers/connect_onboarding_test.go | 14 + internal/helpers/connect_opencode.go | 2 +- internal/helpers/connect_qoder_stream.go | 2 +- internal/helpers/connect_stream.go | 71 +- .../connect_stream_connector_coverage_test.go | 694 ++++ .../connect_stream_edges_coverage_test.go | 101 + internal/helpers/content_splitter.go | 26 +- internal/helpers/content_splitter_test.go | 125 + internal/helpers/coverage_pure_more_test.go | 295 ++ .../helpers/devapp_coverage_edges_test.go | 247 ++ internal/helpers/doc.go | 24 +- .../doc/doc_jsonml_validate_v2_test.go | 170 + internal/helpers/doc_command_edges_test.go | 483 +++ internal/helpers/doc_coverage_more_test.go | 234 ++ internal/helpers/doc_jsonml_pipeline_test.go | 138 + internal/helpers/errors_test.go | 237 ++ internal/helpers/helpers.go | 3 + internal/helpers/helpers_core_test.go | 293 ++ internal/helpers/http_coverage_more_test.go | 208 ++ internal/helpers/mail_coverage_more_test.go | 129 + .../helpers/polling_coverage_more_test.go | 258 ++ internal/helpers/product_examples_test.go | 424 +++ internal/helpers/register_products_test.go | 60 + internal/helpers/report_test.go | 457 +++ internal/helpers/sheet_batch_test.go | 122 + internal/helpers/sheet_export.go | 2 +- .../helpers/sheet_style_coverage_more_test.go | 106 + internal/helpers/sheet_validate_test.go | 131 + internal/helpers/todo_coverage_more_test.go | 87 + internal/helpers/zero_coverage_more_test.go | 58 + internal/i18n/i18n.go | 4 + internal/i18n/i18n_test.go | 77 + internal/jsonutil/json_test.go | 34 + internal/keychain/coverage_edges_test.go | 489 +++ internal/keychain/file_dek.go | 27 +- internal/keychain/keychain_darwin.go | 43 +- internal/keychain/migrate.go | 33 +- internal/logging/coverage_edges_test.go | 173 + internal/logging/logger.go | 8 +- internal/output/coverage_edges_test.go | 367 +++ internal/output/csv.go | 14 +- internal/output/filter.go | 8 +- internal/output/formatter.go | 80 +- internal/output/ndjson.go | 14 +- internal/pat/browser_policy.go | 25 +- internal/pat/chmod.go | 30 +- internal/pat/coverage_edges_test.go | 561 ++++ internal/pipeline/cobra.go | 22 +- internal/pipeline/cobra_test.go | 113 + .../pipeline/handlers/coverage_edges_test.go | 62 + internal/pipeline/handlers/paramvalue.go | 14 +- internal/pipeline/handlers/sticky.go | 4 - internal/plugin/coverage_edges_test.go | 966 ++++++ internal/plugin/loader.go | 98 +- internal/recovery/coverage_edges_test.go | 821 +++++ internal/recovery/planner.go | 2 +- internal/recovery/store.go | 50 +- internal/registry/embed_test.go | 24 + internal/safety/content_scan.go | 3 - internal/safety/content_scan_test.go | 21 + internal/security/coverage_edges_test.go | 144 + internal/security/crypto.go | 26 +- internal/security/fingerprint.go | 4 +- internal/security/storage.go | 50 +- internal/syncdata/syncdata_test.go | 29 + internal/transport/client.go | 3 - internal/transport/coverage_edges_test.go | 521 +++ internal/transport/stdio.go | 4 +- internal/transport/stdio_integration_test.go | 2 +- internal/tui/coverage_edges_test.go | 85 + internal/upgrade/coverage_edges_test.go | 924 ++++++ internal/upgrade/downloader.go | 4 +- internal/upgrade/paths.go | 30 +- internal/upgrade/replacer.go | 49 +- internal/upgrade/rollback.go | 72 +- 159 files changed, 25552 insertions(+), 972 deletions(-) create mode 100644 cmd/main_test.go create mode 100644 internal/apiclient/coverage_edges_test.go create mode 100644 internal/app/coverage_edges_test.go create mode 100644 internal/app/coverage_more_test.go create mode 100644 internal/auth/coverage_edges_test.go create mode 100644 internal/auth/oauth_provider_coverage_test.go create mode 100644 internal/cli/coverage_edges_test.go create mode 100644 internal/errors/coverage_edges_test.go create mode 100644 internal/event/bus/coverage_edges_test.go create mode 100644 internal/event/busctl/coverage_edges_test.go create mode 100644 internal/event/consume/coverage_edges_test.go create mode 100644 internal/event/personal/coverage_edges_test.go create mode 100644 internal/event/process/process_alive_unix_test.go create mode 100644 internal/event/source/coverage_edges_test.go create mode 100644 internal/event/transport/coverage_edges_test.go create mode 100644 internal/executor/invocation_test.go create mode 100644 internal/helpers/aitable_command_edges_test.go create mode 100644 internal/helpers/aitable_core_test.go create mode 100644 internal/helpers/attendance_command_edges_test.go create mode 100644 internal/helpers/attendance_coverage_more_test.go create mode 100644 internal/helpers/calendar_coverage_more_test.go create mode 100644 internal/helpers/chat_command_edges_test.go create mode 100644 internal/helpers/chat_coverage_more_test.go create mode 100644 internal/helpers/command_matrix_test.go create mode 100644 internal/helpers/connect_coverage_more_test.go create mode 100644 internal/helpers/connect_daemon_coverage_more_test.go create mode 100644 internal/helpers/connect_stream_connector_coverage_test.go create mode 100644 internal/helpers/connect_stream_edges_coverage_test.go create mode 100644 internal/helpers/content_splitter_test.go create mode 100644 internal/helpers/coverage_pure_more_test.go create mode 100644 internal/helpers/devapp_coverage_edges_test.go create mode 100644 internal/helpers/doc/doc_jsonml_validate_v2_test.go create mode 100644 internal/helpers/doc_command_edges_test.go create mode 100644 internal/helpers/doc_coverage_more_test.go create mode 100644 internal/helpers/doc_jsonml_pipeline_test.go create mode 100644 internal/helpers/errors_test.go create mode 100644 internal/helpers/helpers_core_test.go create mode 100644 internal/helpers/http_coverage_more_test.go create mode 100644 internal/helpers/mail_coverage_more_test.go create mode 100644 internal/helpers/polling_coverage_more_test.go create mode 100644 internal/helpers/product_examples_test.go create mode 100644 internal/helpers/register_products_test.go create mode 100644 internal/helpers/report_test.go create mode 100644 internal/helpers/sheet_batch_test.go create mode 100644 internal/helpers/sheet_style_coverage_more_test.go create mode 100644 internal/helpers/sheet_validate_test.go create mode 100644 internal/helpers/todo_coverage_more_test.go create mode 100644 internal/helpers/zero_coverage_more_test.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/jsonutil/json_test.go create mode 100644 internal/keychain/coverage_edges_test.go create mode 100644 internal/logging/coverage_edges_test.go create mode 100644 internal/output/coverage_edges_test.go create mode 100644 internal/pat/coverage_edges_test.go create mode 100644 internal/pipeline/cobra_test.go create mode 100644 internal/pipeline/handlers/coverage_edges_test.go create mode 100644 internal/plugin/coverage_edges_test.go create mode 100644 internal/recovery/coverage_edges_test.go create mode 100644 internal/registry/embed_test.go create mode 100644 internal/security/coverage_edges_test.go create mode 100644 internal/syncdata/syncdata_test.go create mode 100644 internal/transport/coverage_edges_test.go create mode 100644 internal/tui/coverage_edges_test.go create mode 100644 internal/upgrade/coverage_edges_test.go 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 {