mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
37d490a198
* 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>
215 lines
6.3 KiB
Go
215 lines
6.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package output
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
extcs "github.com/larksuite/cli/extension/contentsafety"
|
|
)
|
|
|
|
func TestSuccessEnvelopeData_ExtractsBusinessData(t *testing.T) {
|
|
result := map[string]interface{}{
|
|
"code": float64(0),
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{"id": "1"},
|
|
}
|
|
|
|
got := SuccessEnvelopeData(result)
|
|
m, ok := got.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("business data type = %T, want map", got)
|
|
}
|
|
if m["id"] != "1" {
|
|
t.Fatalf("id = %v, want 1", m["id"])
|
|
}
|
|
if _, ok := m["code"]; ok {
|
|
t.Fatal("business data must not contain outer code")
|
|
}
|
|
}
|
|
|
|
func TestSuccessEnvelopeData_MissingDataUsesEmptyObject(t *testing.T) {
|
|
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok"})
|
|
m, ok := got.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("business data type = %T, want map", got)
|
|
}
|
|
if len(m) != 0 {
|
|
t.Fatalf("business data = %#v, want empty object", m)
|
|
}
|
|
}
|
|
|
|
func TestSuccessEnvelopeData_NilDataUsesEmptyObject(t *testing.T) {
|
|
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok", "data": nil})
|
|
m, ok := got.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("business data type = %T, want map", got)
|
|
}
|
|
if len(m) != 0 {
|
|
t.Fatalf("business data = %#v, want empty object", m)
|
|
}
|
|
}
|
|
|
|
func TestWriteSuccessEnvelope_PrintsShortcutCompatibleEnvelope(t *testing.T) {
|
|
var out strings.Builder
|
|
|
|
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
|
Identity: "bot",
|
|
Out: &out,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
|
}
|
|
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
|
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
|
}
|
|
if env["ok"] != true || env["identity"] != "bot" {
|
|
t.Fatalf("unexpected envelope: %#v", env)
|
|
}
|
|
data, ok := env["data"].(map[string]interface{})
|
|
if !ok || data["id"] != "1" {
|
|
t.Fatalf("unexpected data payload: %#v", env["data"])
|
|
}
|
|
if _, ok := env["code"]; ok {
|
|
t.Fatalf("output leaked protocol field code: %#v", env)
|
|
}
|
|
if _, ok := env["msg"]; ok {
|
|
t.Fatalf("output leaked protocol field msg: %#v", env)
|
|
}
|
|
if _, ok := env["_content_safety_alert"]; ok {
|
|
t.Fatalf("output should omit empty content-safety alert: %#v", env)
|
|
}
|
|
}
|
|
|
|
func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
|
|
var out strings.Builder
|
|
|
|
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
|
Identity: "bot",
|
|
JqExpr: ".data.id",
|
|
Out: &out,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
|
}
|
|
if strings.TrimSpace(out.String()) != "1" {
|
|
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
|
}
|
|
}
|
|
|
|
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
|
|
var out strings.Builder
|
|
|
|
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
|
Identity: "bot",
|
|
DryRun: true,
|
|
Out: &out,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
|
}
|
|
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
|
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
|
}
|
|
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
|
|
t.Fatalf("unexpected dry-run envelope: %#v", env)
|
|
}
|
|
if _, ok := env["data"].(map[string]interface{}); !ok {
|
|
t.Fatalf("data = %#v, want object", env["data"])
|
|
}
|
|
}
|
|
|
|
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
|
|
var out strings.Builder
|
|
|
|
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
|
Identity: "bot",
|
|
DryRun: true,
|
|
JqExpr: ".dry_run",
|
|
Out: &out,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
|
}
|
|
if strings.TrimSpace(out.String()) != "true" {
|
|
t.Fatalf("jq output = %q, want true", out.String())
|
|
}
|
|
}
|
|
|
|
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
|
extcs.Register(&mockProvider{
|
|
name: "mock",
|
|
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
|
|
})
|
|
t.Cleanup(func() { extcs.Register(nil) })
|
|
|
|
var out strings.Builder
|
|
var errOut strings.Builder
|
|
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
|
CommandPath: "lark-cli im +test",
|
|
Identity: "bot",
|
|
JqExpr: ".data.id",
|
|
Out: &out,
|
|
ErrOut: &errOut,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
|
}
|
|
if strings.TrimSpace(out.String()) != "1" {
|
|
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
|
}
|
|
if !strings.Contains(errOut.String(), "warning: content safety alert from mock") {
|
|
t.Fatalf("expected content safety warning on stderr, got: %s", errOut.String())
|
|
}
|
|
if !strings.Contains(errOut.String(), "r1") {
|
|
t.Fatalf("expected rule in stderr warning, got: %s", errOut.String())
|
|
}
|
|
}
|
|
|
|
func TestWriteSuccessEnvelope_BlockModeReturnsTypedErrorWithoutStdout(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
|
extcs.Register(&mockProvider{
|
|
name: "mock",
|
|
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
|
|
})
|
|
t.Cleanup(func() { extcs.Register(nil) })
|
|
|
|
var out strings.Builder
|
|
var errOut strings.Builder
|
|
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
|
CommandPath: "lark-cli im +test",
|
|
Identity: "bot",
|
|
Out: &out,
|
|
ErrOut: &errOut,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected content safety block error")
|
|
}
|
|
var safetyErr *errs.ContentSafetyError
|
|
if !errors.As(err, &safetyErr) {
|
|
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
|
}
|
|
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
|
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
|
}
|
|
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
|
|
t.Fatalf("rules = %v, want [r1]", safetyErr.Rules)
|
|
}
|
|
if !errors.Is(err, errBlocked) {
|
|
t.Fatal("content safety error should preserve errBlocked cause")
|
|
}
|
|
if out.String() != "" {
|
|
t.Fatalf("stdout should stay empty on block, got: %s", out.String())
|
|
}
|
|
}
|