Files
dingtalk-workspace-cli/internal/app/canonical_panic_fallback_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

158 lines
5.8 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 app
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
// named after the reserved --params flag) aborted every invocation. With no
// on-disk cache to quarantine it must degrade to an inert stub instead.
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
t.Setenv(cli.CacheDirEnv, t.TempDir())
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
if calls != 1 {
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
}
}
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
// the build panics AND a discovery cache exists on disk, the partition is
// quarantined and the build retried once, so a fixed binary escapes the
// lock-out with zero manual cache surgery.
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
if calls == 1 {
panic("chat_permission_grant flag redefined: params")
}
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Short != "rebuilt-probe" {
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
}
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
if len(quarantines) != 1 {
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
}
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
}
}
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
// if the rebuild after quarantine panics again, the stub is returned and the
// `dws cache refresh` hint kept.
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
tmp := t.TempDir()
t.Setenv(cli.CacheDirEnv, tmp)
store := cache.NewStore(tmp)
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
t.Fatalf("SaveTools() error = %v", err)
}
calls := 0
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
calls++
panic("chat_permission_grant flag redefined: params")
}
t.Cleanup(func() { buildMCPCommandFn = orig })
var cmd *cobra.Command
captured := captureStderr(t, func() {
cmd = newMCPCommand(context.Background(), nil, nil, nil)
})
if calls != 2 {
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
}
if cmd == nil || cmd.Name() != "mcp" {
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
}
if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
// on the happy path.
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
orig := buildMCPCommandFn
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
}
t.Cleanup(func() { buildMCPCommandFn = orig })
cmd := newMCPCommand(context.Background(), nil, nil, nil)
if cmd == nil || cmd.Short != "canonical-probe" {
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
}
}