mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
ca35f60616
* docs(skills): require explicit confirmation before apps +cache-clear
Asked to clear an app's online cache, an agent read `Risk: high-risk-write` from
--help and then supplied `--yes` itself on the first call, wiping production
cache without ever hitting the confirmation gate.
The CLI gate is fine: no --yes -> exit 10 confirmation_required, and --dry-run ->
exit 0 without triggering it. The wording was not. It only forbade appending
`--yes` *after* an exit-10, and said "已明确授权可直接带 --yes" without defining
authorization — so "clear my cache" read as authorization.
- `+cache-clear` gets a CAUTION block: never self-supply `--yes` on the first
call; without confirmation, either --dry-run or ask, then stop and wait. exit
10 is not a signal to retry with --yes.
- Add a zero-ambiguity table separating a *request* to clear ("clear the online
cache") from a *confirmation* ("我确认清 dev"), so blocking the accidental wipe
does not also kill the cases that were already correct: an explicit
confirmation still goes straight to `--yes`, and a request with no environment
named still has to ask instead of picking one.
- Note that online needs a confirmation phrase even when named explicitly.
`+cache-delete` gains the response field an agent has to read
(`deleted_key_count`): 0 means the key never existed, not "deleted
successfully", plus the get -> delete -> get chain needed to prove a delete took
effect — a single miss afterwards cannot tell the two apart.
SKILL.md: add +cache-clear to 禁止预授权判定底线, the one list a pre-authorized
run cannot skip; a reference-level rule alone would be bypassed there. The
routing table is left alone — no other row annotates risk, including
+file-delete, +role-delete and +member-remove.
* fix(apps): stop attaching request-shaped hints to precondition failures
`+db-execute` against a tenant that never activated Miaoda returns code 221800
"miaoda UAT not activated" with the hint "verify table/column names with
`+db-table-get` ... target the dev database with --environment dev". Neither step
can help: the failure is tenant-level, so a caller following the hint loops over
table lookups and env retries that fail identically.
Two causes. 221800 was unregistered, so it degraded to api/unknown — nothing in
the envelope distinguished "your tenant is not activated, stop" from "your SQL
was wrong, fix it and retry". And withAppsHint filled the caller's hint whenever
the server sent none, without looking at what failed: the hints are
command-scoped ("verify --app-id", "verify table/column names", "list releases"),
so every one of them describes the request, and the request is exactly what
failed_precondition says was fine.
Register 221800 as validation/failed_precondition (same shape as 400002465 "app
has no database yet") and gate the hint fallback on the subtype.
Blast radius is two codes, since that is all the subtype covers here:
- 221800 — now withheld; message and code still carry the meaning.
- 400002655 "no running container" — only when it reaches a non-observability
command; the observability pair rewrites it first, and "verify --app-id" was
never the fix for an undeployed app.
400002465 / 500002759 are intercepted by the isAppNoDatabaseError branch above
the gate, and 400002479 is served by withDBSyncHint, which does not delegate
here. The other 78 call sites take the original path for every input.
Gate on the one subtype, not on Category: this package asserts on purpose that an
authentication failure on +role-list (99991663) keeps the app-access hint and a
503 on credential issuance keeps the developer-access hint. Those hints are broad
enough to survive a caller-standing failure; only the precondition class is
misdescribed by construction. A test pins that, so widening the gate to Category
fails loudly instead of silently dropping those hints.
The gate is asserted on the real classification path (BuildAPIError -> the code
table -> withAppsHint), not only on a hand-built Problem. Constructing
SubtypeFailedPrecondition directly feeds the gate the input it wants and passes
whether or not 221800 is registered, so the registration itself has to be part of
what the test covers.
No recovery hint for 221800 — the activation path is a product procedure, and
guessing one is what made this failure misleading in the first place.
* fix(apps): classify file-storage and app-level failures
Five Spark business codes reached the CLI unregistered, so every one of them came
out as api/unknown with exit 1: a caller could not tell "your app id is wrong"
from "you lack permission" from "the upstream is having a bad minute", and the
exit code offered no way to branch either.
400002484 app not found -> validation/invalid_argument exit 2
400002467 no admin/developer perm -> authorization/permission_denied exit 3
500002761 ditto, pre-4xx renumber -> same
400000034 file not found/no access -> api/not_found exit 1
500000034 ditto, pre-4xx renumber -> same
400002467 is not file-specific: db commands (+db-table-list, +db-table-get,
+db-quota-get, +db-changelog-list) return it for an app the caller cannot access,
so registering it fixes both domains at once.
400002484 covers a well-formed id that does not exist AND a malformed one
("notanappid", "app_1" return it too), so the argument itself is the failure ->
invalid_argument, whose exit 2 separates "you passed the wrong id" from an
upstream fault. Environments that have not picked it up answer with 400002465
instead, conflating it with "app has no database yet"; the CLI cannot tell those
apart on the old code, so nothing here keys on that.
Both the current and the pre-4xx number are registered for each file failure.
The domain is moving its client-class errors from the 5xxxxxxxx band into
4xxxxxxxx, rolled out per environment, so both are live at once and dropping the
old one would silently return the un-migrated half to api/unknown — the same trap
that made the no-database recovery flow disappear when the server renumbered it
(see appNoDatabaseCode). The new number is not derivable from the old either:
500002761 became 400002467, tail digits included.
No hints added: permission_denied already has framework recovery wording, and a
domain-specific one would have to invent a remedy.
268 lines
11 KiB
Go
268 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package apps
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
func assertHintContains(t *testing.T, sc common.Shortcut, args []string, stub *httpmock.Stub, want string) {
|
|
t.Helper()
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(stub)
|
|
err := runAppsShortcut(t, sc, args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
|
}
|
|
if !strings.Contains(p.Hint, want) {
|
|
t.Fatalf("hint %q does not contain %q", p.Hint, want)
|
|
}
|
|
}
|
|
|
|
func TestAppsSessionCreate_4xxFailureCarriesListHint(t *testing.T) {
|
|
assertHintContains(t, AppsSessionCreate,
|
|
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
|
|
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
|
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
|
|
"apps +list")
|
|
}
|
|
|
|
func TestAppsSessionList_4xxFailureCarriesListHint(t *testing.T) {
|
|
assertHintContains(t, AppsSessionList,
|
|
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
|
|
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/sessions",
|
|
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
|
"apps +list")
|
|
}
|
|
|
|
func TestAppsUpdate_4xxFailureCarriesListHint(t *testing.T) {
|
|
assertHintContains(t, AppsUpdate,
|
|
[]string{"+update", "--app-id", "app_x", "--name", "n", "--as", "user"},
|
|
&httpmock.Stub{Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x",
|
|
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
|
|
"apps +list")
|
|
}
|
|
|
|
func TestAppsReleaseList_4xxFailureCarriesListHint(t *testing.T) {
|
|
assertHintContains(t, AppsReleaseList,
|
|
[]string{"+release-list", "--app-id", "app_x", "--as", "user"},
|
|
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases",
|
|
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
|
"apps +list")
|
|
}
|
|
|
|
func TestAppsSessionStop_4xxFailureCarriesSessionHint(t *testing.T) {
|
|
assertHintContains(t, AppsSessionStop,
|
|
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "s1", "--turn-id", "t1", "--as", "user"},
|
|
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions/s1/stop",
|
|
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "session not found"}},
|
|
"+session-list")
|
|
}
|
|
|
|
func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) {
|
|
assertHintContains(t, AppsCreate,
|
|
[]string{"+create", "--name", "n", "--app-type", "html", "--as", "user"},
|
|
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps",
|
|
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
|
|
"full_stack")
|
|
}
|
|
|
|
func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) {
|
|
assertHintContains(t, AppsDBEnvCreate,
|
|
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"},
|
|
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
|
Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}},
|
|
"+db-table-list")
|
|
}
|
|
|
|
func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) {
|
|
assertHintContains(t, AppsDBTableGet,
|
|
[]string{"+db-table-get", "--app-id", "app_x", "--table", "users", "--as", "user"},
|
|
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables/users",
|
|
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "table not found"}},
|
|
"+db-table-list")
|
|
}
|
|
|
|
func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) {
|
|
assertHintContains(t, AppsDBTableList,
|
|
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"},
|
|
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables",
|
|
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}},
|
|
"+db-env-create")
|
|
}
|
|
|
|
// withAppsHint must only fill an EMPTY hint; an upstream-provided hint wins.
|
|
func TestWithAppsHint_DoesNotOverrideUpstreamHint(t *testing.T) {
|
|
upstream := &errs.Problem{Message: "boom", Hint: "upstream specific hint"}
|
|
got := withAppsHint(upstream, appIDListHint)
|
|
p, ok := errs.ProblemOf(got)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", got)
|
|
}
|
|
if p.Hint != "upstream specific hint" {
|
|
t.Fatalf("upstream hint was overridden: %q", p.Hint)
|
|
}
|
|
}
|
|
|
|
// withAppsHint fills the hint when empty and leaves Message untouched.
|
|
func TestWithAppsHint_FillsEmptyHintKeepsMessage(t *testing.T) {
|
|
p0 := &errs.Problem{Message: "boom"}
|
|
got := withAppsHint(p0, appIDListHint)
|
|
p, _ := errs.ProblemOf(got)
|
|
if p.Hint != appIDListHint {
|
|
t.Fatalf("hint not filled: %q", p.Hint)
|
|
}
|
|
if p.Message != "boom" {
|
|
t.Fatalf("message mutated: %q", p.Message)
|
|
}
|
|
}
|
|
|
|
// A failed_precondition failure must NOT inherit the command's request-shaped
|
|
// hint: by classification the request is valid, so "fix your request" advice is
|
|
// wrong. Regression guard for 221800 "miaoda UAT not activated", which used to be
|
|
// answered with the +db-execute table/column hint.
|
|
func TestWithAppsHint_WithholdsHintForFailedPrecondition(t *testing.T) {
|
|
in := errs.NewValidationError(errs.SubtypeFailedPrecondition, "miaoda UAT not activated").WithCode(221800)
|
|
out := withAppsHint(in, "verify table/column names with `lark-cli apps +db-table-get`")
|
|
p, ok := errs.ProblemOf(out)
|
|
if !ok {
|
|
t.Fatalf("returned error is not typed: %T", out)
|
|
}
|
|
if p.Hint != "" {
|
|
t.Fatalf("hint = %q, want empty: a request-shaped hint cannot explain a precondition failure", p.Hint)
|
|
}
|
|
// Only the hint is withheld — classification, code and message must survive so
|
|
// the envelope still tells the caller what happened.
|
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition ||
|
|
p.Code != 221800 || p.Message != "miaoda UAT not activated" {
|
|
t.Fatalf("classification mutated: category=%q subtype=%q code=%d msg=%q", p.Category, p.Subtype, p.Code, p.Message)
|
|
}
|
|
}
|
|
|
|
// The gate has to hold on the REAL classification path, not just on a
|
|
// hand-built Problem. Every other gate test constructs
|
|
// errs.NewValidationError(SubtypeFailedPrecondition, ...) directly, which feeds
|
|
// the gate the input it wants and passes whether or not 221800 is registered in
|
|
// internal/errclass — a false negative that let an unregistered 221800 ship with
|
|
// this gate believed to be working. Drive it through BuildAPIError so the
|
|
// registration is part of what is asserted.
|
|
func TestWithAppsHint_WithholdsHintOnRealClassificationPath(t *testing.T) {
|
|
err := errclass.BuildAPIError(map[string]any{
|
|
"code": 221800,
|
|
"msg": "miaoda UAT not activated",
|
|
}, errclass.ClassifyContext{Identity: "user"})
|
|
|
|
p, ok := errs.ProblemOf(withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get`"))
|
|
if !ok {
|
|
t.Fatalf("BuildAPIError did not produce a typed problem: %#v", err)
|
|
}
|
|
// Registration is the precondition for the gate; assert it here so a missing
|
|
// codemeta entry fails as a hint bug, which is how it is felt in practice.
|
|
if p.Subtype != errs.SubtypeFailedPrecondition {
|
|
t.Fatalf("subtype = %q, want failed_precondition: 221800 must be registered in internal/errclass for the gate to fire", p.Subtype)
|
|
}
|
|
if p.Hint != "" {
|
|
t.Fatalf("hint = %q, want empty: the request-shaped hint must be withheld", p.Hint)
|
|
}
|
|
}
|
|
|
|
// The gate declines to invent a hint; it must never drop one the upstream sent,
|
|
// nor touch the classification it came with.
|
|
func TestWithAppsHint_KeepsUpstreamHintOnFailedPrecondition(t *testing.T) {
|
|
cause := errors.New("upstream cause")
|
|
in := errs.NewValidationError(errs.SubtypeFailedPrecondition, "not activated").
|
|
WithCode(221800).WithHint("activate Miaoda first").WithCause(cause)
|
|
out := withAppsHint(in, "verify --app-id")
|
|
p, ok := errs.ProblemOf(out)
|
|
if !ok {
|
|
t.Fatalf("returned error is not typed: %T", out)
|
|
}
|
|
if p.Hint != "activate Miaoda first" {
|
|
t.Fatalf("hint = %q, want the upstream hint preserved", p.Hint)
|
|
}
|
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition || p.Code != 221800 {
|
|
t.Fatalf("classification mutated: category=%q subtype=%q code=%d", p.Category, p.Subtype, p.Code)
|
|
}
|
|
if !errors.Is(out, cause) {
|
|
t.Fatalf("cause chain dropped: %v", out)
|
|
}
|
|
}
|
|
|
|
// The no-database recovery flow is itself a failed_precondition (400002465), and its
|
|
// override runs before the gate — so it must keep rewriting message and forcing its
|
|
// own accurate hint. Guards the ordering inside withAppsHint.
|
|
func TestWithAppsHint_NoDatabaseOverrideOutranksTheGate(t *testing.T) {
|
|
cause := errors.New("upstream cause")
|
|
in := errs.NewValidationError(errs.SubtypeFailedPrecondition, "workspace has no db branch").
|
|
WithCode(appNoDatabaseCode).WithCause(cause)
|
|
out := withAppsHint(in, "verify table/column names")
|
|
p, ok := errs.ProblemOf(out)
|
|
if !ok {
|
|
t.Fatalf("returned error is not typed: %T", out)
|
|
}
|
|
if p.Message != appNoDatabaseMessage {
|
|
t.Fatalf("message = %q, want the no-database rewrite %q", p.Message, appNoDatabaseMessage)
|
|
}
|
|
if p.Hint != appNoDatabaseHint {
|
|
t.Fatalf("hint = %q, want the cloud-dev recovery hint", p.Hint)
|
|
}
|
|
// The override rewrites Message/Hint only — classification and cause stay put.
|
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition || p.Code != appNoDatabaseCode {
|
|
t.Fatalf("classification mutated: category=%q subtype=%q code=%d", p.Category, p.Subtype, p.Code)
|
|
}
|
|
if !errors.Is(out, cause) {
|
|
t.Fatalf("cause chain dropped: %v", out)
|
|
}
|
|
}
|
|
|
|
// Everything that is not a precondition keeps inheriting the command hint —
|
|
// including api/unknown, where most unclassified Spark codes still land, and the
|
|
// two classes other tests in this package assert on purpose (caller standing,
|
|
// transient upstream). Narrowing the gate further would fail here.
|
|
func TestWithAppsHint_FillsHintForOtherClasses(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
category errs.Category
|
|
subtype errs.Subtype
|
|
code int
|
|
}{
|
|
{"api/not_found", errs.NewAPIError(errs.SubtypeNotFound, "table does not exist").WithCode(400002469),
|
|
errs.CategoryAPI, errs.SubtypeNotFound, 400002469},
|
|
{"api/unknown unclassified business code", errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(999999),
|
|
errs.CategoryAPI, errs.SubtypeUnknown, 999999},
|
|
{"api/server_error", errs.NewAPIError(errs.SubtypeServerError, "upstream busy").WithCode(503),
|
|
errs.CategoryAPI, errs.SubtypeServerError, 503},
|
|
{"authentication/token_invalid", errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "permission denied").WithCode(99991663),
|
|
errs.CategoryAuthentication, errs.SubtypeTokenInvalid, 99991663},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out := withAppsHint(tc.err, appIDListHint)
|
|
p, ok := errs.ProblemOf(out)
|
|
if !ok {
|
|
t.Fatalf("returned error is not typed: %T", out)
|
|
}
|
|
if p.Hint != appIDListHint {
|
|
t.Fatalf("hint = %q, want %q", p.Hint, appIDListHint)
|
|
}
|
|
// Filling the hint must not reclassify the failure.
|
|
if p.Category != tc.category || p.Subtype != tc.subtype || p.Code != tc.code {
|
|
t.Fatalf("classification mutated: category=%q subtype=%q code=%d", p.Category, p.Subtype, p.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|