Files
larksuite-cli/shortcuts/apps/apps_env_test.go
2026-06-25 21:09:44 +08:00

410 lines
14 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface{}) {
t.Helper()
if req.URL.RawQuery != "" {
t.Fatalf("query should be empty, got %q", req.URL.RawQuery)
}
var got map[string]interface{}
if err := json.NewDecoder(req.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("body = %#v, want %#v", got, want)
}
}
func expectedEnvVarSceneJSON() float64 {
return float64(defaultAppsEnvVarScene)
}
func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} {
t.Helper()
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if !envelope.OK {
t.Fatalf("expected ok envelope, got %s", stdout)
}
return envelope.Data
}
func requireEnvVarValidationProblem(t *testing.T, err error, param string) {
t.Helper()
p := requireAppsProblem(t, err, errs.CategoryValidation)
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("validation subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validation.Param != param {
t.Fatalf("validation param = %q, want %q", validation.Param, param)
}
}
func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
OnMatch: func(req *http.Request) {
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()})
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"envVars": []interface{}{
map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) {
t.Fatalf("stdout must not expose values by default: %s", got)
}
data := decodeEnvVarEnvelopeData(t, got)
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("items = %#v, want one item", data["items"])
}
item, ok := items[0].(map[string]interface{})
if !ok || item["key"] != "SECRET_TOKEN" {
t.Fatalf("item = %#v, want SECRET_TOKEN", items[0])
}
if _, ok := item["value"]; ok {
t.Fatalf("item must not contain value by default: %#v", item)
}
}
func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
OnMatch: func(req *http.Request) {
assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()})
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"envVars": []interface{}{
map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "super-secret") {
t.Fatalf("stdout should include values when requested: %s", got)
}
}
func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") {
t.Fatalf("expected unknown -e shorthand, got %v", err)
}
}
func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsEnvVarList, []string{
"+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() {
t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String())
}
}
func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"envVars": []interface{}{
map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvVarList, []string{
"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.HasPrefix(got, "key") {
t.Fatalf("pretty output should start with key column, got:\n%s", got)
}
for _, want := range []string{"API_HOST", "online", "https://example.com"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) {
t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got)
}
}
func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout)
p := requireAppsProblem(t, err, errs.CategoryConfirmation)
if p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("confirmation subtype = %q, want %q", p.Subtype, errs.SubtypeConfirmationRequired)
}
if !strings.Contains(p.Hint, "add --yes") {
t.Fatalf("confirmation hint missing --yes guidance: %#v", p)
}
}
func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "super-secret") {
t.Fatalf("dry-run must redact value: %s", got)
}
for _, want := range []string{`"method": "POST"`, `/open-apis/spark/v1/apps/app_x/create_or_update_env_var`} {
if !strings.Contains(got, want) {
t.Fatalf("dry-run missing %q: %s", want, got)
}
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)
}
if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "<redacted>" || dryRun.API[0].Body["key"] != "SECRET_TOKEN" {
t.Fatalf("dry-run body = %#v, want redacted value and key", dryRun.API)
}
}
func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/create_or_update_env_var",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"action": "updated"}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["key"] != "SECRET_TOKEN" || sent["env"] != "online" || sent["value"] != "super-secret" {
t.Fatalf("body = %#v, want real online value", sent)
}
got := stdout.String()
if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) {
t.Fatalf("stdout must not echo value: %s", got)
}
for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "updated"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q: %s", want, got)
}
}
}
func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) {
if AppsEnvVarDelete.Risk != "high-risk-write" {
t.Fatalf("risk = %q, want high-risk-write", AppsEnvVarDelete.Risk)
}
}
func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"deleted_keys": []interface{}{"SECRET_ONE", "SECRET_TWO"}}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["env"] != "online" {
t.Fatalf("body.env = %v, want online", sent["env"])
}
keys, ok := sent["keys"].([]interface{})
if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" {
t.Fatalf("body.keys = %#v, want SECRET_ONE/SECRET_TWO", sent["keys"])
}
got := stdout.String()
for _, want := range []string{`"env": "online"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q: %s", want, got)
}
}
}
func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
Body: map[string]interface{}{
"code": 400000072,
"msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable",
},
})
err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Code != 400000072 {
t.Fatalf("code = %d, want 400000072", p.Code)
}
if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") {
t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint)
}
if strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint)
}
}
func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
got := stdout.String()
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)
}
if len(dryRun.API) != 1 || dryRun.API[0].Method != "POST" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/delete_env_vars" {
t.Fatalf("dry-run api = %#v", dryRun.API)
}
if dryRun.API[0].Body["env"] != "online" {
t.Fatalf("dry-run body.env = %v, want online", dryRun.API[0].Body["env"])
}
keys, ok := dryRun.API[0].Body["keys"].([]interface{})
if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" {
t.Fatalf("dry-run body.keys = %#v, want SECRET_ONE/SECRET_TWO", dryRun.API[0].Body["keys"])
}
}
func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
requireEnvVarValidationProblem(t, err, "--environment")
}
func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") {
t.Fatalf("expected old --env to be rejected, got %v", err)
}
}
func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--key", "bad-key",
"--value", "super-secret", "--as", "user"}, factory, stdout)
requireEnvVarValidationProblem(t, err, "--key")
}
func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--key", "bad-key",
"--yes", "--as", "user"}, factory, stdout)
requireEnvVarValidationProblem(t, err, "--key")
}