// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package client import ( "bytes" "encoding/json" "errors" "io" "net/http" "os" "path/filepath" "strings" "testing" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs/localfileio" ) func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp { return newApiRespWithStatus(200, body, headers) } func newApiRespWithStatus(status int, body []byte, headers map[string]string) *larkcore.ApiResp { h := http.Header{} for k, v := range headers { h.Set(k, v) } return &larkcore.ApiResp{ StatusCode: status, Header: h, RawBody: body, } } func TestIsJSONContentType_Extended(t *testing.T) { tests := []struct { ct string want bool }{ {"application/json", true}, {"application/json; charset=utf-8", true}, {"text/json", true}, {"application/octet-stream", false}, {"", false}, } for _, tt := range tests { if got := IsJSONContentType(tt.ct); got != tt.want { t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want) } } } func TestParseJSONResponse(t *testing.T) { body := []byte(`{"code":0,"msg":"ok","data":{"id":"123"}}`) resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) result, err := ParseJSONResponse(resp) if err != nil { t.Fatalf("ParseJSONResponse failed: %v", err) } m, ok := result.(map[string]interface{}) if !ok { t.Fatal("expected map result") } if m["msg"] != "ok" { t.Errorf("expected msg=ok, got %v", m["msg"]) } } func TestParseJSONResponse_Invalid(t *testing.T) { resp := newApiResp([]byte(`not json`), map[string]string{"Content-Type": "application/json"}) _, err := ParseJSONResponse(resp) if err == nil { t.Error("expected error for invalid JSON") } } func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) { resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"}) _, err := ParseJSONResponse(resp) if err == nil { t.Fatal("expected error for empty body") } if !errors.Is(err, io.EOF) { t.Fatalf("expected wrapped io.EOF, got %v", err) } } func TestResolveFilename(t *testing.T) { tests := []struct { name string headers map[string]string want string }{ { "from content-type pdf", map[string]string{"Content-Type": "application/pdf"}, "download.pdf", }, { "from content-type png", map[string]string{"Content-Type": "image/png"}, "download.png", }, { "unknown type", map[string]string{"Content-Type": "application/octet-stream"}, "download.bin", }, { "empty content-type", map[string]string{}, "download.bin", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := newApiResp([]byte("data"), tt.headers) got := ResolveFilename(resp) if got != tt.want { t.Errorf("ResolveFilename() = %q, want %q", got, tt.want) } }) } } func TestMimeToExt_Extended(t *testing.T) { tests := []struct { ct string want string }{ {"application/pdf", ".pdf"}, {"image/png", ".png"}, {"image/jpeg", ".jpg"}, {"image/gif", ".gif"}, {"text/plain", ".txt"}, {"text/csv", ".csv"}, {"text/html", ".html"}, {"application/zip", ".zip"}, {"application/xml", ".xml"}, {"text/xml", ".xml"}, {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"}, {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"}, {"application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx"}, {"application/octet-stream", ".bin"}, {"", ".bin"}, } for _, tt := range tests { if got := mimeToExt(tt.ct); got != tt.want { t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want) } } } func TestSaveResponse(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) body := []byte("hello binary data") resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"}) meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin") if err != nil { t.Fatalf("SaveResponse failed: %v", err) } if meta["size_bytes"] != int64(len(body)) { t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"]) } savedPath, _ := meta["saved_path"].(string) data, err := os.ReadFile(savedPath) if err != nil { t.Fatalf("read saved file: %v", err) } if !bytes.Equal(data, body) { t.Errorf("saved content mismatch") } } func TestSaveResponse_CreatesDir(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin")) if err != nil { t.Fatalf("SaveResponse with nested dir failed: %v", err) } savedPath, _ := meta["saved_path"].(string) if _, err := os.Stat(savedPath); err != nil { t.Errorf("expected file to exist at %s", savedPath) } } func TestHandleResponse_JSON(t *testing.T) { body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`) resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ Identity: core.AsBot, Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse failed: %v", err) } var got map[string]interface{} if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("invalid JSON output: %v\n%s", err, out.String()) } if got["ok"] != true { t.Fatalf("ok = %v, want true; output: %s", got["ok"], out.String()) } if got["identity"] != "bot" { t.Fatalf("identity = %v, want bot; output: %s", got["identity"], out.String()) } if _, hasCode := got["code"]; hasCode { t.Fatalf("success envelope leaked outer code field: %s", out.String()) } data, ok := got["data"].(map[string]interface{}) if !ok { t.Fatalf("data = %T, want object; output: %s", got["data"], out.String()) } if data["id"] != "1" { t.Fatalf("data.id = %v, want 1; output: %s", data["id"], out.String()) } } func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) { body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`) resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ Identity: core.AsBot, JqExpr: ".data.id", Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse failed: %v", err) } if strings.TrimSpace(out.String()) != "1" { t.Fatalf("jq output = %q, want %q", out.String(), "1") } } func TestHandleResponse_JSONWithError(t *testing.T) { body := []byte(`{"code":99991400,"msg":"invalid token"}`) resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}, }) if err == nil { t.Error("expected error for non-zero code") } if _, ok := errs.ProblemOf(err); !ok { t.Fatalf("expected typed error, got %T: %v", err, err) } if strings.Contains(out.String(), `"ok": true`) || strings.Contains(out.String(), `"ok":true`) { t.Fatalf("unexpected success envelope on error path: %s", out.String()) } } func TestHandleResponse_BinaryAutoSave(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse binary failed: %v", err) } if !bytes.Contains(errOut.Bytes(), []byte("binary response detected")) { t.Errorf("expected binary detection message, got: %s", errOut.String()) } } func TestHandleResponse_BinaryWithOutput(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) var out bytes.Buffer var errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ OutputPath: "out.png", Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse with output path failed: %v", err) } data, _ := os.ReadFile("out.png") if string(data) != "PNG DATA" { t.Errorf("expected saved PNG DATA, got: %s", data) } } func TestHandleResponse_NonJSONError_404(t *testing.T) { resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"}) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 404 text/plain") } got := err.Error() if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") { t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got) } var apiErr *errs.APIError if !errors.As(err, &apiErr) { t.Errorf("expected *errs.APIError, got %T", err) } if output.ExitCodeOf(err) != output.ExitAPI { t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err)) } } func TestHandleResponse_NonJSONError_502(t *testing.T) { resp := newApiRespWithStatus(502, []byte("Bad Gateway"), map[string]string{"Content-Type": "text/html"}) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 502 text/html") } got := err.Error() if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") { t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got) } var netErr *errs.NetworkError if !errors.As(err, &netErr) { t.Errorf("expected *errs.NetworkError, got %T", err) } if output.ExitCodeOf(err) != output.ExitNetwork { t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err)) } } // TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP // status error whose JSON body omits a non-zero business code (e.g. 400 + // {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as // success, so without the HTTP-status fallback a 4xx would be served as a // successful result and exit 0. func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) { resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`), map[string]string{"Content-Type": "application/json"}) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String()) } var apiErr *errs.APIError if !errors.As(err, &apiErr) { t.Errorf("expected *errs.APIError, got %T", err) } if !strings.Contains(err.Error(), "HTTP 400") { t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error()) } if output.ExitCodeOf(err) != output.ExitAPI { t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err)) } } // TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body // and no Content-Type header — which falls into the JSON branch and fails to // parse — is classified by HTTP status (api/not_found), not reported as an // internal decode failure. func TestHandleResponse_NoContentTypeError_404(t *testing.T) { resp := newApiRespWithStatus(404, []byte(""), nil) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 404 with empty body and no Content-Type") } var apiErr *errs.APIError if !errors.As(err, &apiErr) { t.Errorf("expected *errs.APIError, got %T", err) } if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound { t.Errorf("subtype = %q, want not_found", apiErr.Subtype) } if output.ExitCodeOf(err) != output.ExitAPI { t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err)) } } // TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON // body and no Content-Type is classified as a NetworkError by status, not an // internal decode failure. func TestHandleResponse_NoContentTypeError_502(t *testing.T) { resp := newApiRespWithStatus(502, []byte("Bad Gateway"), nil) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 502 with non-JSON body and no Content-Type") } var netErr *errs.NetworkError if !errors.As(err, &netErr) { t.Errorf("expected *errs.NetworkError, got %T", err) } if output.ExitCodeOf(err) != output.ExitNetwork { t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err)) } } func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"}) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err != nil { t.Fatalf("expected no error for 200 text/plain, got: %v", err) } if !strings.Contains(errOut.String(), "binary response detected") { t.Errorf("expected binary detection message, got: %s", errOut.String()) } } func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) { resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) var out, errOut bytes.Buffer err := HandleResponse(resp, ResponseOptions{ JqExpr: ".data", Out: &out, ErrOut: &errOut, }) if err == nil { t.Fatal("expected error when --jq is used with non-JSON response") } if !strings.Contains(err.Error(), "--jq requires a JSON response") { t.Errorf("expected '--jq requires a JSON response' error, got: %v", err) } } func TestSaveResponse_RejectsPathTraversal(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) _, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt") if err == nil { t.Fatal("expected error for path traversal") } if !strings.Contains(err.Error(), "unsafe output path") { t.Errorf("expected 'unsafe output path' wrapper, got: %v", err) } } func TestSaveResponse_RejectsAbsolutePath(t *testing.T) { resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) _, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt") if err == nil { t.Fatal("expected error for absolute path") } } func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() os.Chdir(dir) defer os.Chdir(origWd) resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"}) meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt") if err != nil { t.Fatalf("SaveResponse failed: %v", err) } savedPath, _ := meta["saved_path"].(string) if !filepath.IsAbs(savedPath) { t.Errorf("saved_path should be absolute, got %q", savedPath) } }