The new root-type guard shifted `filepath.WalkDir`'s outer error branch into the diff, and neither the macOS nor the Windows runner reaches it naturally — raising Windows coverage to 99.9365% and blocking the gate. Add a `statusWalkDir` seam and a `TestCrossPlatformCoverage` regression that swaps in a WalkDir returning a sentinel error, asserting it is surfaced unchanged. Verified locally: changed code coverage back to 100.0000%.
419 lines
18 KiB
Go
419 lines
18 KiB
Go
package helpers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// driveScriptCaller — 可编程 MCP mock:按 (toolName, 第几次调用) 决定返回,
|
|
// 覆盖分页、递归、错误分支等 walkRemoteDir / runDriveStatus 路径。
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
type driveScriptCaller struct {
|
|
calls []driveMCPCall
|
|
reply func(toolName string, args map[string]any, nth int) (string, error)
|
|
dryRun bool
|
|
}
|
|
|
|
func (m *driveScriptCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
|
nth := 0
|
|
for _, c := range m.calls {
|
|
if c.toolName == toolName {
|
|
nth++
|
|
}
|
|
}
|
|
m.calls = append(m.calls, driveMCPCall{toolName: toolName, args: args})
|
|
text, err := m.reply(toolName, args, nth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
|
}
|
|
|
|
func (m *driveScriptCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
|
return m.CallTool(ctx, productID, toolName, args)
|
|
}
|
|
|
|
func (m *driveScriptCaller) Format() string { return "raw" }
|
|
func (m *driveScriptCaller) DryRun() bool { return m.dryRun }
|
|
func (m *driveScriptCaller) Fields() string { return "" }
|
|
func (m *driveScriptCaller) JQ() string { return "" }
|
|
|
|
func (m *driveScriptCaller) callsFor(tool string) []driveMCPCall {
|
|
var out []driveMCPCall
|
|
for _, c := range m.calls {
|
|
if c.toolName == tool {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var errTestList = errors.New("list_files boom")
|
|
|
|
// runDriveCmd 以给定 caller 执行 `dws drive <args...>`。
|
|
//
|
|
// pull / push / sync 声明了 Safety.Confirmation = user_required,非交互环境下必须带
|
|
// --yes 才能越过统一确认门;这里自动补上,让用例专注被测行为。要断言「未确认即拒绝」
|
|
// 请改用 runDriveCmdWithoutConfirm。
|
|
func runDriveCmd(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
|
t.Helper()
|
|
switch {
|
|
case len(args) == 0:
|
|
case args[0] == "pull", args[0] == "push", args[0] == "sync":
|
|
args = append(args, "--yes")
|
|
}
|
|
return runDriveCmdWithoutConfirm(t, caller, args...)
|
|
}
|
|
|
|
// runDriveCmdWithoutConfirm 原样执行,不补 --yes,用于验证确认门本身。
|
|
func runDriveCmdWithoutConfirm(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
|
t.Helper()
|
|
|
|
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
|
|
|
root := &cobra.Command{Use: "dws"}
|
|
root.PersistentFlags().BoolP("yes", "y", false, "")
|
|
root.PersistentFlags().Bool("dry-run", false, "")
|
|
root.AddCommand(newDriveCommand())
|
|
|
|
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, args...))
|
|
root.SetArgs(append([]string{"drive"}, args...))
|
|
return root.Execute()
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// runDriveStatus — 端到端:本地 + 远端树 → diff
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// 端到端 status:递归进入子文件夹、拼接 rel_path、跳过非 file 类型。
|
|
func TestCrossPlatformCoverageDriveStatus_endToEndNestedTree(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustWrite(t, filepath.Join(dir, "root.txt"), "r")
|
|
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(dir, "sub", "nested.txt"), "n")
|
|
|
|
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) {
|
|
switch args["parentId"] {
|
|
case "ROOT":
|
|
return `{"result":{"items":[` +
|
|
`{"name":"root.txt","type":"file","fileId":"R1","modifyTime":1000},` +
|
|
`{"name":"sub","type":"folder","fileId":"SUB"},` +
|
|
`{"name":"online.adoc","type":"document","fileId":"D1"}` +
|
|
`],"nextToken":""}}`, nil
|
|
case "SUB":
|
|
return `{"result":{"items":[{"name":"nested.txt","type":"file","fileId":"N1","modifyTime":2000}],"nextToken":""}}`, nil
|
|
}
|
|
return `{"result":{"items":[],"nextToken":""}}`, nil
|
|
}}
|
|
|
|
if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// 递归进入 sub 一次;不属于文件夹镜像范围的在线文档不产生额外调用。
|
|
if got := len(caller.callsFor("list_files")); got != 2 {
|
|
t.Errorf("expected 2 list_files calls (ROOT + SUB), got %d", got)
|
|
}
|
|
}
|
|
|
|
// 分页 token 未推进意味着远端清单可能被截断,必须失败而不是把当前页当作 EOF。
|
|
func TestCrossPlatformCoverageDriveStatus_stalledPaginationTokenFailsClosed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) {
|
|
switch nth {
|
|
case 0:
|
|
if _, ok := args["nextToken"]; ok {
|
|
t.Error("first page must not carry nextToken")
|
|
}
|
|
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":"T1"}}`, nil
|
|
case 1:
|
|
if args["nextToken"] != "T1" {
|
|
t.Errorf("second page nextToken = %v, want T1", args["nextToken"])
|
|
}
|
|
// 返回同一个 token → 未推进,必须 fail closed。
|
|
return `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B"}],"nextToken":"T1"}}`, nil
|
|
}
|
|
return `{"result":{"items":[],"nextToken":""}}`, nil
|
|
}}
|
|
|
|
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick")
|
|
if err == nil || !strings.Contains(err.Error(), "分页 token") {
|
|
t.Fatalf("error = %v, want stalled pagination failure", err)
|
|
}
|
|
if got := len(caller.callsFor("list_files")); got != 2 {
|
|
t.Fatalf("stalled token must fail after 2 calls, got %d", got)
|
|
}
|
|
}
|
|
|
|
// space-id 显式传入时必须透传到 list_files。
|
|
func TestCrossPlatformCoverageDriveStatus_passesSpaceID(t *testing.T) {
|
|
dir := t.TempDir()
|
|
caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) {
|
|
return `{"result":{"items":[],"nextToken":""}}`, nil
|
|
}}
|
|
if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--space-id", "SP1"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := caller.callsFor("list_files")[0].args["spaceId"]; got != "SP1" {
|
|
t.Errorf("spaceId = %v, want SP1", got)
|
|
}
|
|
}
|
|
|
|
// list_files 失败 → status 直接报错。
|
|
func TestCrossPlatformCoverageDriveStatus_listFailurePropagates(t *testing.T) {
|
|
dir := t.TempDir()
|
|
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }}
|
|
if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil {
|
|
t.Fatal("expected list_files error to propagate")
|
|
}
|
|
}
|
|
|
|
// 返回体无法解析 → status 报解析失败。
|
|
func TestCrossPlatformCoverageDriveStatus_unparsableListPropagates(t *testing.T) {
|
|
dir := t.TempDir()
|
|
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
|
return `{"result":"not-a-list"}`, nil
|
|
}}
|
|
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT")
|
|
if err == nil || !strings.Contains(err.Error(), "list_files") {
|
|
t.Fatalf("expected list_files parse error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// exact 模式下本地文件不可读 → MD5 计算失败要作为错误上抛,不能静默当成一致。
|
|
func TestCrossPlatformCoverageDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "secret.txt")
|
|
mustWrite(t, p, "data")
|
|
testseam.Swap(t, &md5File, func(string) (string, error) {
|
|
return "", errors.New("MD5 boom")
|
|
})
|
|
|
|
caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) {
|
|
return `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`, nil
|
|
}}
|
|
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT")
|
|
if err == nil || !strings.Contains(err.Error(), "MD5") {
|
|
t.Fatalf("expected MD5 failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDriveStatus_walkLocalFailurePropagates(t *testing.T) {
|
|
dir := t.TempDir()
|
|
testseam.Swap(t, &runDriveStatusWalkLocalTree, func(string) (map[string]*localFile, error) {
|
|
return nil, errors.New("walk boom")
|
|
})
|
|
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
|
t.Error("local scan failure must abort before MCP calls")
|
|
return "", nil
|
|
}}
|
|
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT")
|
|
if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") {
|
|
t.Fatalf("expected local scan failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --local-folder 不是绝对路径 → 校验拦下,不发起任何 MCP 调用。
|
|
func TestCrossPlatformCoverageDriveStatus_relativeLocalFolderRejected(t *testing.T) {
|
|
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
|
t.Error("must not call MCP when --local-folder is rejected")
|
|
return "", nil
|
|
}}
|
|
if err := runDriveCmd(t, caller, "status", "--local-folder", "relative/path", "--remote-folder", "ROOT"); err == nil {
|
|
t.Fatal("expected relative --local-folder to be rejected")
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// walkRemoteDir — 深度上限
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// 远端目录自引用时深度上限必须中止遍历,避免无限递归。
|
|
func TestCrossPlatformCoverageWalkRemoteDir_depthLimitAborts(t *testing.T) {
|
|
caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) {
|
|
// 每层都返回一个同名子文件夹 → 无限深。
|
|
return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil
|
|
}}
|
|
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
|
testseam.Swap(t, &os.Args, []string{"dws", "drive"}) // 工具路由需要命令上下文
|
|
|
|
_, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true)
|
|
if err == nil || !strings.Contains(err.Error(), "循环引用") {
|
|
t.Fatalf("expected depth-limit abort, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// isSafeRemoteSegment / resolveLocalTarget 的剩余分支
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
func TestCrossPlatformCoverageIsSafeRemoteSegment_volumeAndNonCanonical(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
if isSafeRemoteSegment(`C:name.txt`) {
|
|
t.Error("volume-qualified name must be rejected")
|
|
}
|
|
}
|
|
// 非规范形式(Clean 后不等于自身)必须拒绝。
|
|
for _, name := range []string{"./a.txt", "a/../b.txt", "a//b.txt"} {
|
|
if isSafeRemoteSegment(name) {
|
|
t.Errorf("non-canonical %q must be rejected", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// md5File / walkLocalTree 错误分支
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
func TestCrossPlatformCoverageMD5File_missingFile(t *testing.T) {
|
|
if _, err := md5File(filepath.Join(t.TempDir(), "nope.bin")); err == nil {
|
|
t.Fatal("expected error for missing file")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageMD5File_directoryIsNotReadable(t *testing.T) {
|
|
// 目录不是常规文件:io.Copy 读取时必然报错。
|
|
if _, err := md5File(t.TempDir()); err == nil {
|
|
t.Fatal("expected error when hashing a directory")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageWalkLocalTree_missingRootErrors(t *testing.T) {
|
|
if _, err := walkLocalTree(filepath.Join(t.TempDir(), "absent")); err == nil {
|
|
t.Fatal("expected error for missing local root")
|
|
}
|
|
}
|
|
|
|
// 根路径是指向目录的符号链接时,filepath.WalkDir 只把根本身当成一次 !IsRegular
|
|
// 回调静默跳过,结果是空索引,而 status 会把所有远端文件误报为 new_remote。
|
|
// 必须在遍历前 fail-closed。这里通过 seam 注入,兼顾无法真实创建 symlink 的平台。
|
|
func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootViaSeam(t *testing.T) {
|
|
testseam.Swap(t, &statusRootLstat, func(string) (os.FileInfo, error) {
|
|
return fakeSymlinkFileInfo{}, nil
|
|
})
|
|
if _, err := walkLocalTree(t.TempDir()); err == nil || !strings.Contains(err.Error(), "符号链接") {
|
|
t.Fatalf("symlink root must fail closed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 真实 symlink 复现:非 Windows 平台可以直接创建,进一步保证 seam 与真实语义一致。
|
|
func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootReal(t *testing.T) {
|
|
target := t.TempDir()
|
|
link := filepath.Join(t.TempDir(), "root-link")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Skipf("symlink unsupported: %v", err)
|
|
}
|
|
if _, err := walkLocalTree(link); err == nil || !strings.Contains(err.Error(), "符号链接") {
|
|
t.Fatalf("symlink root must fail closed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// 根类型校验通过后 WalkDir 内部返回的错误必须原样上抛,不能被吞掉;根消失或
|
|
// 权限变更之类的情形在 Linux/Windows runner 上都难以稳定复现,用 seam 注入。
|
|
func TestCrossPlatformCoverageWalkLocalTree_propagatesWalkDirError(t *testing.T) {
|
|
testseam.Swap(t, &statusWalkDir, func(string, fs.WalkDirFunc) error {
|
|
return errWalkDirSentinel
|
|
})
|
|
dir := t.TempDir()
|
|
if _, err := walkLocalTree(dir); !errors.Is(err, errWalkDirSentinel) {
|
|
t.Fatalf("walk dir error must be surfaced, got %v", err)
|
|
}
|
|
}
|
|
|
|
var errWalkDirSentinel = errors.New("simulated walk dir failure")
|
|
|
|
// 根路径既非目录也非符号链接(例如普通文件被误传)时,也必须报错而非空索引。
|
|
func TestCrossPlatformCoverageWalkLocalTree_rejectsNonDirectoryRoot(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "not-a-dir")
|
|
mustWrite(t, file, "x")
|
|
if _, err := walkLocalTree(file); err == nil || !strings.Contains(err.Error(), "不是目录") {
|
|
t.Fatalf("file root must fail closed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeSymlinkFileInfo 是一个 Mode()&os.ModeSymlink != 0 的 os.FileInfo。Windows
|
|
// 上创建目录符号链接需要管理员权限,测试通过它绕过创建步骤。
|
|
type fakeSymlinkFileInfo struct{}
|
|
|
|
func (fakeSymlinkFileInfo) Name() string { return "root-link" }
|
|
func (fakeSymlinkFileInfo) Size() int64 { return 0 }
|
|
func (fakeSymlinkFileInfo) Mode() os.FileMode { return os.ModeSymlink | 0o755 }
|
|
func (fakeSymlinkFileInfo) ModTime() time.Time { return time.Time{} }
|
|
func (fakeSymlinkFileInfo) IsDir() bool { return false }
|
|
func (fakeSymlinkFileInfo) Sys() any { return nil }
|
|
|
|
// 非常规文件(FIFO/符号链接目标缺失等)不计入本地索引。
|
|
func TestCrossPlatformCoverageWalkLocalTree_skipsIrregularEntries(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustWrite(t, filepath.Join(dir, "real.txt"), "x")
|
|
if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil {
|
|
t.Skipf("symlink unsupported: %v", err)
|
|
}
|
|
got, err := walkLocalTree(dir)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if _, ok := got["dangling"]; ok {
|
|
t.Error("dangling symlink must not be indexed")
|
|
}
|
|
if _, ok := got["real.txt"]; !ok {
|
|
t.Error("regular file must be indexed")
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// toMillis 的 json.Number / float64 分支
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
func TestCrossPlatformCoverageDriveItem_modifiedMillisFromNumericForms(t *testing.T) {
|
|
if got, ok := (driveItem{"modifyTime": float64(1500)}).modifiedMillis(); !ok || got != 1500 {
|
|
t.Errorf("float64 modifyTime = (%d,%v), want (1500,true)", got, ok)
|
|
}
|
|
if got, ok := (driveItem{"modifyTime": json.Number("2500")}).modifiedMillis(); !ok || got != 2500 {
|
|
t.Errorf("json.Number modifyTime = (%d,%v), want (2500,true)", got, ok)
|
|
}
|
|
if _, ok := (driveItem{"modifyTime": json.Number("nope")}).modifiedMillis(); ok {
|
|
t.Error("malformed json.Number modifyTime must be invalid")
|
|
}
|
|
if _, ok := (driveItem{"modifyTime": float64(0)}).modifiedMillis(); ok {
|
|
t.Error("non-positive modifyTime must be invalid")
|
|
}
|
|
if _, ok := (driveItem{}).modifiedMillis(); ok {
|
|
t.Error("missing modifyTime must be invalid")
|
|
}
|
|
}
|
|
|
|
// fileSize 的 float64 / json.Number 两种形态,以及缺失时的兜底。
|
|
func TestCrossPlatformCoverageDriveItem_sizeForms(t *testing.T) {
|
|
if got := (driveItem{"fileSize": float64(42)}).size(); got != 42 {
|
|
t.Errorf("float fileSize = %d, want 42", got)
|
|
}
|
|
if got := (driveItem{"fileSize": json.Number("77")}).size(); got != 77 {
|
|
t.Errorf("json.Number fileSize = %d, want 77", got)
|
|
}
|
|
if got := (driveItem{"fileSize": json.Number("not-a-number")}).size(); got != 0 {
|
|
t.Errorf("malformed json.Number fileSize = %d, want 0", got)
|
|
}
|
|
if got := (driveItem{}).size(); got != 0 {
|
|
t.Errorf("missing fileSize = %d, want 0", got)
|
|
}
|
|
}
|