Files
larksuite-cli/shortcuts/apps/apps_env_test.go
liangshuo-1 37d490a198 fix: unify dry-run output contract (#1870)
* fix: unify dry-run output contract

* fix: address dry-run review feedback

* fix(dryrun): tighten preview contract and unify data shape

- transcribe HTTP method verbatim in previews (HEAD/OPTIONS were
  reported as GET); reject an empty method in api with a typed error
- unify the dry-run data payload across api/service/shortcut paths:
  {api, context?: {app_id, user_open_id}}; drop data.as — the envelope
  top-level identity is the single identity source
- mark pretty dry-run stdout with '# dry-run: request not sent' so logs
  that drop stderr still show it was a preview
- extract the shared preview builder, collapse PrintDryRunWithFile's
  loose params into FileUploadMeta, and fail loudly on nil previews
- revert description-marker identity parsing: stale prose must not
  override corrected accessTokens (blocks legal user calls on
  images.create); identity gating keys off accessTokens only
- pin the new contracts with tests: verbatim method, three-way context
  parity, nil-preview error, empty-context omission, marker line

* docs(agents): add typed-data, faithful-transcription, and contract-test conventions

- typed struct at the boundary over map[string]interface{} threading;
  distinct types where values could swap silently (internal/meta.Token)
- transcribe input verbatim in previews/transformations; reject
  unhonorable flag combinations with typed errors instead of silently
  substituting behavior
- contract tests must fail when the implementation is reverted

* test: migrate dry-run tests grown on main to the envelope format

main gained raw-format dry-run readers while the PR was in flight
(wiki drive export #1802, drive list comments #1845, slash commands,
sheets history, docs fetch, mail draft-send/triage, vc meeting events).
Migrate them to the envelope accessors (clie2e.DryRunGet / data-wrapped
decoders) and drop the now-redundant DryRunData extractions in files
unified on DryRunGet.

---------

Co-authored-by: guokexin.02 <264159873+Tantanz20020918@users.noreply.github.com>
2026-07-14 10:54:16 +08:00

396 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 dryRunAPIEnvelope
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 dryRunAPIEnvelope
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 dryRunAPIEnvelope
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")
}