Files
dingtalk-workspace-cli/internal/cli/canonical_flag_guard_test.go
修雨 60ac0b409d fix(cli): guard canonical mcp tree against poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions

The canonical 'dws mcp' tree is built from cached catalog data before the
legacy command build and before Cobra dispatches anything, so a pflag
panic there (a tool schema property named after the reserved --params
flag, as cached during the 1.0.32 incident) aborted every invocation --
including 'dws cache refresh' and 'dws upgrade' -- and sat outside all
three poisoned-cache guards (#447/#449/#452).

Two layers, mirroring the existing guards:
- applyFlagSpecs now skips reserved (--json/--params), duplicate and
  alias-colliding flag names and sanitizes shorthands instead of letting
  pflag panic; the skipped property stays reachable through the reserved
  JSON payload flags (same degradation semantics as #449).
- newMCPCommand wraps the build in the #452 recover -> quarantine ->
  retry-once -> degrade-to-stub sequence, so even an unforeseen panic
  class no longer locks the CLI out.

* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
2026-06-11 09:16:36 +08:00

117 lines
4.5 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"testing"
"github.com/spf13/cobra"
)
// newToolCommandFixture mirrors the flag environment of newToolCommand: the
// reserved payload flags are registered before the spec loop runs.
func newToolCommandFixture() *cobra.Command {
cmd := &cobra.Command{Use: "probe"}
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
return cmd
}
// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
// lock-out: a tool schema property named after a reserved payload flag
// ("params", as cached during the chat_permission_grant incident, or "json")
// must be skipped instead of panicking pflag ("flag redefined") — that panic
// fires while the canonical tree is assembled, before Cobra dispatches
// anything, and used to kill every invocation including `dws cache refresh`
// and `dws upgrade`.
func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
{PropertyName: "json", FlagName: "json", Kind: flagString},
{PropertyName: "scope", FlagName: "scope", Kind: flagString},
})
if cmd.Flags().Lookup("scope") == nil {
t.Errorf("non-colliding flag --scope was not registered")
}
// The reserved flags must keep their payload usage strings, proving the
// schema-derived specs did not touch them.
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
t.Errorf("--params usage = %q, want the reserved payload usage", got)
}
}
// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
// single tool schema (or a spec colliding with an already-applied one).
func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
{PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
})
flag := cmd.Flags().Lookup("scope")
if flag == nil {
t.Fatalf("--scope was not registered at all")
}
if flag.Usage != "first" {
t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
}
}
// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
// reserved or existing flag is dropped while the primary still registers.
func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
})
if cmd.Flags().Lookup("scope") == nil {
t.Errorf("primary flag --scope was not registered when its alias collided")
}
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
}
}
// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
// shorthands (both pflag panics) degrade to long-flag-only registration.
func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
t.Parallel()
cmd := newToolCommandFixture()
applyFlagSpecs(cmd, []FlagSpec{
{PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
{PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
{PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
})
for _, name := range []string{"alpha", "beta", "gamma"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("--%s was not registered", name)
}
}
if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
}
}