Files
larksuite-cli/internal/output/emit_test.go
evandance c5b5aece33 refactor: retire legacy error envelopes and enforce typed contract (#1449)
* refactor: retire legacy error envelopes and enforce typed contract

Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.

Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.

Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.

Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
  migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
  every comparison must use errors.Is/errors.As, so interior wraps stay legal
  but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
  explicit per-domain allowlist, so new shortcut domains are covered without
  editing a list. It runs where forbidigo is enabled (the shortcut domains and
  the auth/config/service command groups); repo-wide chain integrity for the
  remaining command paths is carried by errorlint above.

* test: align cli_e2e success assertions to the ok envelope

The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
2026-06-17 19:42:38 +08:00

160 lines
4.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
// mockProvider is a test provider that returns a configurable alert.
type mockProvider struct {
name string
alert *extcs.Alert
err error
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return m.alert, m.err
}
func TestScanForSafety_ModeOff(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +messages-search", map[string]any{"text": "inject"}, &buf)
if result.Alert != nil || result.Blocked {
t.Error("mode=off should produce zero ScanResult")
}
}
func TestScanForSafety_ModeWarn_WithAlert(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
alert := &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}}
mp := &mockProvider{name: "mock", alert: alert}
// Register mock provider (save and restore)
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Alert == nil {
t.Fatal("expected non-nil alert in warn mode")
}
if result.Blocked {
t.Error("warn mode should not block")
}
if result.BlockErr != nil {
t.Error("warn mode should not have BlockErr")
}
}
func TestScanForSafety_ModeBlock_WithAlert(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
alert := &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}}
mp := &mockProvider{name: "mock", alert: alert}
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if !result.Blocked {
t.Error("block mode with alert should set Blocked=true")
}
if result.BlockErr == nil {
t.Error("block mode with alert should have BlockErr")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr should be *ContentSafetyError, got %T", result.BlockErr)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Errorf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if got := ExitCodeOf(result.BlockErr); got != ExitContentSafety {
t.Errorf("exit code = %d, want %d", got, ExitContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
t.Errorf("rules = %v, want [r1]", safetyErr.Rules)
}
if !errors.Is(result.BlockErr, errBlocked) {
t.Error("BlockErr should preserve errBlocked cause")
}
}
func TestScanForSafety_NoProvider(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Alert != nil || result.Blocked {
t.Error("no provider should produce zero ScanResult")
}
}
func TestScanForSafety_ScanError_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("scan error should fail-open, not block")
}
if !strings.Contains(buf.String(), "scan error") {
t.Errorf("expected warning on stderr, got: %s", buf.String())
}
}
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
slow := &slowProvider{}
extcs.Register(slow)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("slow provider should fail-open on timeout, not block")
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
}
// slowProvider blocks for longer than scanTimeout to trigger the timeout path.
type slowProvider struct{}
func (s *slowProvider) Name() string { return "slow" }
func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(200 * time.Millisecond):
return &extcs.Alert{Provider: "slow", MatchedRules: []string{"never"}}, nil
}
}
func TestWriteAlertWarning(t *testing.T) {
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
var buf bytes.Buffer
WriteAlertWarning(&buf, alert)
got := buf.String()
if !strings.Contains(got, "r1") || !strings.Contains(got, "r2") {
t.Errorf("warning should contain rule IDs, got: %s", got)
}
}