mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
d12b39cf46
* feat(vfs): allow absolute paths under a built-in path allowlist Path flags only accepted paths relative to the working directory, so an agent passing a full path (typically under /tmp) failed on its first call and had to retry with a relative one. Absolute paths are now accepted when they resolve inside a built-in allowlist: the working directory, /tmp, and ~/files. A built-in denylist covers system and credential locations and wins over the allowlist, including over the working directory. Both lists are compiled in and read no environment variable, flag, or config file, so the effective policy is fixed by the binary; upgrading is all it takes for the new behavior to apply. Containment is decided by file identity (device and inode) alongside the resolved name, because a single directory has many spellings: APFS folds U+017F onto "s", so ".sshh" spelled with it opens ~/.ssh, and NTFS and APFS both compare case-insensitively. Reads are hardened where the policy applies: O_NOFOLLOW pins the final component, O_NONBLOCK keeps a FIFO from blocking before it can be refused, and the opened descriptor is matched against the inspected object, rejected when it is not a regular file, and rejected when it carries extra hard links. The relaxed local-input tier used by apps upload keeps its own contract (symlinks are legitimate arguments there) and gains the denylist check instead. Two behaviors are deliberate rather than incidental. Working inside a denylisted directory now refuses even relative paths, since the denylist is unconditional. Running as root leaves only the working directory and /tmp, because the home directory is then /root, itself a deny root. Existing tests asserted the old "every absolute path is refused" baseline; they now assert the allowlist. Traversal fixtures escape to the filesystem root, which stays outside every allowed root on Linux, where the temp directory that hosts t.TempDir() is /tmp itself. * fix(vfs): close two paths around the built-in denylist A "~/..." argument had two readings: validation expanded it to the home directory, while a caller that keeps the original string — SafeLocalFlagPath returns it verbatim — opens whatever "~" names in the working directory. A symlink there carried reads past the denylist, confirmed by reading /etc/passwd through it. Every interpretation of an argument is now checked, so the shorthand still reaches ~/files while the literal entry cannot escape. With no LARKSUITE_CLI_CONFIG_DIR and no reachable home directory, core.GetBaseConfigDir keeps credentials in a bare ".lark-cli" resolved against the working directory, which is an allow root. That fallback is now mirrored as a deny root, so containers whose home lookup fails do not expose their stored tokens. * fix(vfs): enforce hard-link checks across readers * fix(vfs): stop an output hard link from rewriting a file outside the allowlist A hard link has no target for name resolution to follow, so a link inside an allowed root looked like an allowed destination while sharing its inode with a file outside every root. A caller that truncated the approved name in place rewrote that outside file: `auth qrcode --output <link>` reported success and replaced a 43-byte JSON file outside the allowlist with its PNG. Output validation now refuses an existing target that carries more than one name, which covers callers that write directly, and auth qrcode commits through a temp file and a rename, which replaces the directory entry and leaves the other names alone. Writers already going through FileIO.Save were never affected, since that path has always committed by rename. * fix(vfs): give the hard-link refusal a workable recovery hint The message told the caller to copy the file into an allowed directory, which answers a question they did not ask: the file that triggers this is normally already inside one, with every one of its names there too. It now states what the check actually cannot do — enumerate the other names a file is reachable by — and offers the step that works, which is to copy the file and use the copy. * test(vfs): pick the denylist fixture for the platform under test Two tests reached for "/etc/passwd" as a denylisted absolute path. That path is not absolute on Windows, so one test met the foreign-path rejection instead of the denylist it was asserting, and the other saw the path joined to the working directory and no rejection at all. Both now ask for a deny root that exists on the platform running them — the credential directories under the account home qualify everywhere — which keeps the denylist covered on Windows rather than skipping it there. Verified on Windows 10.0.19045 by running the package's test binary from this branch and from main: main passed, this branch failed these two, and both pass after the change. The other packages this branch touches were compared the same way and their Windows results are identical on both sides. * fix(vfs): state the hard-link check as the condition it tests The check read as "bail out unless the target can be inspected", which nilerr reads as an error swallowed on the way out. It now names the case it acts on — an existing regular file with more than one name — and the comment carries what the early return used to imply: a target that cannot be inspected has no link count to judge, and the write layer reports the real failure with proper typing. * docs(vfs): scope the policy's environment claim to what holds The header promised that neither list accepts runtime input and that no caller controlling the environment can widen them. Two inputs contradict that: LARKSUITE_CLI_CONFIG_DIR contributes a deny root, and where the account database cannot name the running uid, $HOME decides where ~/files points — reproduced in a container running as an unregistered uid, which wrote into a directory the environment chose. The comments now state the preference and its boundary rather than a guarantee, and record what the boundary costs: a directory named "files" under the named path, with the home directory itself still outside the allowlist and every candidate home still carrying the credential deny roots. The trustedHome note also said the pure-Go lookup falls back to $HOME silently; it does so only when $USER is set as well, and returns an error otherwise, which drops the ~/files root instead of moving it. No behavior change. * fix(auth): keep the mode of a QR output file that already exists Committing the QR write by rename fixed a hard link from rewriting a file outside the allowlist, but it also changed what happens to the target's mode. A rename installs the temp file's inode, mode included, where the previous in-place write left the existing file's mode untouched. Overwriting a target the caller had restricted to 0600 therefore published it as 0644. The mode now comes from the file already at the path; only a path with nothing at it takes the default. Verified against main, which preserved 0600 here, and covered by a test that fails when the fixed mode is restored. * test(sheets): move the csv file-alias tests onto the new path baseline Merging main brought #2559's tests for the --file → --csv alias, written against the policy this branch replaces. Two of them fail on it, both because the verdict they describe moved rather than disappeared. The out-of-tree case used /tmp, which the allowlist now accepts, so the value came back as a missing file instead of an out-of-tree one; it now names a path no allow root can contain. The directory case is refused when the descriptor is inspected, before a read is attempted, so the message reads "not a regular file". What the caller sees of both — the flag named, the cause kept, stdin offered — is unchanged. That message listed the kinds it refuses and omitted directories, which is how it reached a directory test reading as a mismatch. It now names them. * fix(im): let the path policy judge a download target `+messages-resources-download` refused an absolute --output before the shared policy saw it, so the flag stayed relative-only after the policy learned to accept full paths. It is the command behind 99% of a reported 1,189 download path errors in one week, where 97.2% of first calls passed an absolute path and every later success had switched to a relative one. The shape checks are gone. Both call sites already hand the result to ResolveSavePath, which applies the allowlist, the denylist and symlink resolution, so refusing a shape here decided nothing the policy would not decide better — an absolute path is now answered by where it points rather than by how it is written. The file-key checks stay, and they are what the batch caller relies on: it embeds the key in the path, and a key carrying a separator is refused as a malformed key, so a traversal cannot be built from one. Verified against a real tenant: /tmp and ~/files now save, while ~/.ssh, /etc and a path outside every root are still refused. * test(im): pin the download output contract the policy now decides The dry-run suite listed an absolute path among the values --output must refuse. That held while the command rejected the shape itself; now that the built-in policy decides, /tmp is an allowed root and the path is accepted, so the case asserted a rule that no longer exists. It is replaced by the two halves of the real contract: an absolute path inside an allowed root reaches the request, and a path that resolves outside every root — a parent escape from this working directory, or a denylisted directory — is still turned down as a validation error naming --output. * fix(vfs): hold a relative path to the working directory Accepting /tmp as an allow root gave a relative path somewhere new to go. A process whose working directory sits under /tmp — CI runners, containers and agent sandboxes commonly arrange that — could climb out with "../" and still satisfy the allowlist, because the sibling it landed in was also under /tmp. /tmp is world-writable, so that sibling can belong to another user or another session, and the write side commits by rename, which replaces an existing target unconditionally. The previous policy refused this: it required every resolved path to stay under the working directory. Naming a full path and climbing out of the working directory are different acts and no longer share one verdict. An absolute path is judged by the allowlist, which is what this branch set out to allow; a relative one has to resolve inside the working directory, whatever wider root contains it. The home denylist grows at the same time and for the same reason: the working directory is an allow root and running from the home directory is ordinary, so a credential store there is reachable by a relative name unless the list covers it. It now names the common ones — netrc, git and shell credentials, kube, docker, azure, gh, gcloud, the language package registries — and the shell histories, which carry pasted keys as reliably as a credential file. ---------
602 lines
18 KiB
Go
602 lines
18 KiB
Go
// 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/httpmock"
|
|
"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_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
format output.Format
|
|
want string
|
|
}{
|
|
{
|
|
name: "ndjson",
|
|
format: output.FormatNDJSON,
|
|
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
|
|
},
|
|
{
|
|
name: "table",
|
|
format: output.FormatTable,
|
|
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
|
|
},
|
|
{
|
|
name: "csv",
|
|
format: output.FormatCSV,
|
|
want: "id,name\n1,Alice\n2,Bob\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
|
reg := &httpmock.Registry{}
|
|
reg.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "1", "name": "Alice"},
|
|
map[string]interface{}{"id": "2", "name": "Bob"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
|
|
if err != nil {
|
|
t.Fatalf("fixture request failed: %v", err)
|
|
}
|
|
body, err := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
if err != nil {
|
|
t.Fatalf("read fixture response: %v", err)
|
|
}
|
|
resp := &larkcore.ApiResp{
|
|
StatusCode: httpResp.StatusCode,
|
|
Header: httpResp.Header.Clone(),
|
|
RawBody: body,
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
var errOut bytes.Buffer
|
|
err = HandleResponse(resp, ResponseOptions{
|
|
Format: tt.format,
|
|
Identity: core.AsBot,
|
|
Out: &out,
|
|
ErrOut: &errOut,
|
|
CommandPath: "lark-cli api GET",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("HandleResponse() error = %v, want nil", err)
|
|
}
|
|
if got := out.String(); got != tt.want {
|
|
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
|
}
|
|
if got := errOut.String(); got != "" {
|
|
t.Fatalf("stderr bytes = %q, want empty", got)
|
|
}
|
|
reg.Verify(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
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("<html>Bad Gateway</html>"), 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("<html>Bad Gateway</html>"), 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, "/etc/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)
|
|
}
|
|
}
|