Files
dingtalk-workspace-cli/internal/app/upgrade_full_coverage_test.go
玉澜 f14332f143 fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
2026-08-17 20:20:32 +08:00

449 lines
16 KiB
Go

package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type fakeUpgradeClient struct {
latest *upgradepkg.ReleaseInfo
latestErr error
tagged *upgradepkg.ReleaseInfo
taggedErr error
versions []upgradepkg.VersionEntry
versionsErr error
}
func (c *fakeUpgradeClient) FetchLatestReleaseForTrack(upgradepkg.ReleaseTrack) (*upgradepkg.ReleaseInfo, error) {
return c.latest, c.latestErr
}
func (c *fakeUpgradeClient) FetchReleaseByTag(string) (*upgradepkg.ReleaseInfo, error) {
return c.tagged, c.taggedErr
}
func (c *fakeUpgradeClient) FetchReleaseVersions(upgradepkg.ReleaseTrack) ([]upgradepkg.VersionEntry, error) {
return c.versions, c.versionsErr
}
type fakeUpgradeRollback struct {
backups []upgradepkg.BackupInfo
listErr error
backupErr error
rollbackErr error
cleaned bool
}
func (r *fakeUpgradeRollback) ListBackups() ([]upgradepkg.BackupInfo, error) {
return r.backups, r.listErr
}
func (r *fakeUpgradeRollback) RollbackTo(upgradepkg.BackupInfo) error { return r.rollbackErr }
func (r *fakeUpgradeRollback) Backup(string) (string, error) { return "backup", r.backupErr }
func (r *fakeUpgradeRollback) Cleanup(int) error {
r.cleaned = true
return nil
}
type upgradeFileInfo struct{ size int64 }
func (i upgradeFileInfo) Name() string { return "dws" }
func (i upgradeFileInfo) Size() int64 { return i.size }
func (i upgradeFileInfo) Mode() os.FileMode { return 0o755 }
func (i upgradeFileInfo) ModTime() time.Time { return time.Time{} }
func (i upgradeFileInfo) IsDir() bool { return false }
func (i upgradeFileInfo) Sys() any { return nil }
func TestCrossPlatformCoverageUpgradeRollbackAndCommandBranchesCoverage(t *testing.T) {
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
oldEdition := edition.Get()
oldStdin := os.Stdin
oldVersion := version
t.Cleanup(func() {
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
edition.Override(oldEdition)
os.Stdin = oldStdin
version = oldVersion
})
fail := errors.New("failure")
rb := &fakeUpgradeRollback{listErr: fail}
newUpgradeRollback = func() upgradeRollbackManager { return rb }
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
t.Fatalf("rollback list error = %v", err)
}
rb.listErr = nil
if err := runUpgradeRollback(true); err == nil {
t.Fatal("rollback without backups succeeded")
}
rb.backups = []upgradepkg.BackupInfo{{Version: "1.0.0", CreatedAt: time.Now()}}
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(write, "n\n")
_ = write.Close()
os.Stdin = read
if err := runUpgradeRollback(false); err != nil {
t.Fatalf("cancel rollback = %v", err)
}
_ = read.Close()
rb.rollbackErr = fail
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
t.Fatalf("rollback apply error = %v", err)
}
rb.rollbackErr = nil
if err := runUpgradeRollback(true); err != nil {
t.Fatalf("rollback success = %v", err)
}
client := &fakeUpgradeClient{
latest: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
tagged: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
versions: []upgradepkg.VersionEntry{{Version: "9.9.9"}, {Version: "9.9.8"}},
}
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
client.latestErr = fail
cmd := &cobra.Command{Use: "upgrade"}
cmd.SetOut(io.Discard)
if err := runUpgradeCheck(cmd, "json", upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
t.Fatalf("upgrade check error = %v", err)
}
client.latestErr = nil
client.versionsErr = fail
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
t.Fatalf("upgrade list error = %v", err)
}
client.versionsErr = nil
version = "9.9.9"
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); err != nil {
t.Fatal(err)
}
if err := runUpgradeList(cmd, "table", 1, upgradepkg.ReleaseTrackRelease); err != nil {
t.Fatal(err)
}
edition.Override(&edition.Hooks{IsEmbedded: true})
embedded := newUpgradeCommand()
if err := embedded.Execute(); err == nil || !strings.Contains(err.Error(), "embedded") {
t.Fatalf("unnamed embedded upgrade error = %v", err)
}
edition.Override(&edition.Hooks{})
for _, args := range [][]string{{"--list", "--all"}, {"--rollback", "--yes"}, {"--check"}} {
command := newUpgradeCommand()
if args[0] == "--rollback" {
command.Flags().Bool("yes", false, "")
}
command.SetOut(io.Discard)
command.SetArgs(args)
if err := command.Execute(); err != nil {
t.Fatalf("upgrade command %v = %v", args, err)
}
}
}
func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
oldEnsure, oldCleanup := ensureUpgradeDirs, cleanupUpgradeStale
oldNeeds, oldBinary, oldSkills, oldChecksums := upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums
oldDownload, oldProgress := downloadUpgradeFile, downloadUpgradeProgress
oldExtract, oldFind, oldLocate := extractUpgradeZip, findExtractedBinary, locateUpgradeSkill
oldReplace, oldInstall := replaceUpgradeSelf, installUpgradeSkills
oldTemp, oldRemove, oldRead, oldMkdir := upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll
oldVerify, oldTar, oldValidate := verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary
oldStdin := os.Stdin
t.Cleanup(func() {
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
ensureUpgradeDirs, cleanupUpgradeStale = oldEnsure, oldCleanup
upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums = oldNeeds, oldBinary, oldSkills, oldChecksums
downloadUpgradeFile, downloadUpgradeProgress = oldDownload, oldProgress
extractUpgradeZip, findExtractedBinary, locateUpgradeSkill = oldExtract, oldFind, oldLocate
replaceUpgradeSelf, installUpgradeSkills = oldReplace, oldInstall
upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll = oldTemp, oldRemove, oldRead, oldMkdir
verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary = oldVerify, oldTar, oldValidate
os.Stdin = oldStdin
})
fail := errors.New("stage failure")
binary := upgradepkg.GitHubAsset{Name: "dws.zip", BrowserDownloadURL: "binary"}
skills := upgradepkg.GitHubAsset{Name: "dws-skills.zip", BrowserDownloadURL: "skills"}
checksums := upgradepkg.GitHubAsset{Name: "checksums.txt", BrowserDownloadURL: "checksums"}
release := &upgradepkg.ReleaseInfo{Version: "9.9.9", Date: "2026-01-01", Prerelease: true, Assets: []upgradepkg.GitHubAsset{binary, skills, checksums}}
client := &fakeUpgradeClient{latest: release, tagged: release}
rb := &fakeUpgradeRollback{}
configure := func(stage string) {
client.latestErr, client.taggedErr = nil, nil
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
newUpgradeRollback = func() upgradeRollbackManager { return rb }
rb.backupErr, rb.cleaned = nil, false
ensureUpgradeDirs = func() error {
if stage == "ensure" {
return fail
}
return nil
}
cleanupUpgradeStale = func() {}
upgradeNeedsUpgrade = func(string, string) bool { return stage != "not-needed" }
findUpgradeBinary = func([]upgradepkg.GitHubAsset) (*upgradepkg.GitHubAsset, error) {
if stage == "find-binary" {
return nil, fail
}
asset := binary
if stage == "extract-tar" {
asset.Name = "dws.tar.gz"
}
return &asset, nil
}
findUpgradeSkills = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := skills; return &asset }
findUpgradeChecksums = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := checksums; return &asset }
tempCalls := 0
upgradeMkdirTemp = func(string, string) (string, error) {
tempCalls++
if stage == "temp-both" || (stage == "temp-fallback" && tempCalls == 1) {
return "", fail
}
return "/tmp/dws-upgrade-coverage", nil
}
upgradeRemoveAll = func(string) error { return nil }
if stage == "backup" {
rb.backupErr = fail
}
downloadUpgradeFile = func(url, _ string) (int64, error) {
if stage == "checksum-download" && url == "checksums" {
return 0, fail
}
if stage == "skills-download" && url == "skills" {
return 0, fail
}
return 4, nil
}
upgradeReadFile = func(string) ([]byte, error) {
if stage == "checksum-read" {
return nil, fail
}
return []byte("checksum"), nil
}
downloadUpgradeProgress = func(_ context.Context, _, _ string, progress func(float64, int64, int64)) (int64, error) {
progress(150, 10, 10)
if stage == "binary-download" || stage == "temp-fallback" {
return 0, fail
}
return 1024, nil
}
verifyCalls := 0
verifyUpgradeFile = func(string, string, string, string, string) error {
verifyCalls++
if stage == "verify-binary" && verifyCalls == 1 || stage == "verify-skills" && verifyCalls == 2 {
return fail
}
return nil
}
extractCalls := 0
extractUpgradeZip = func(string, string) error {
extractCalls++
if stage == "extract-binary" && extractCalls == 1 || stage == "extract-skills" && extractCalls == 2 {
return fail
}
return nil
}
extractUpgradeTarGz = func(string, string) error {
if stage == "extract-tar" {
return fail
}
return nil
}
findExtractedBinary = func(string) string {
if stage == "binary-missing" {
return ""
}
return "/tmp/new-dws"
}
validateUpgradeBinary = func(string, string) error {
if stage == "validate" {
return fail
}
return nil
}
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
locateUpgradeSkill = func(string) string {
if stage == "skill-missing" {
return ""
}
return "/tmp/SKILL.md"
}
replaceUpgradeSelf = func(string) error {
if stage == "replace" {
return fail
}
return nil
}
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
if stage == "install" {
return nil, fail
}
if stage == "install-failed-dir" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
}
if stage == "install-retire-warning" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
}}, nil
}
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
}
}
for _, stage := range []string{
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
"success", "success-no-skills", "install-retire-warning",
} {
t.Run(stage, func(t *testing.T) {
configure(stage)
opts := upgradeOptions{force: true, yes: true}
if stage == "tag-error" {
opts.targetVersion = "v9.9.9"
client.taggedErr = fail
}
if stage == "latest-error" {
client.latestErr = fail
}
if stage == "not-needed" {
opts.force = false
}
if stage == "cancel" {
opts.yes = false
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(write, "n\n")
_ = write.Close()
os.Stdin = read
defer read.Close()
}
if stage == "success-no-skills" {
opts.skipSkills = true
}
err := runUpgrade(context.Background(), opts)
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
if wantError && err == nil {
t.Fatalf("stage %s succeeded", stage)
}
if !wantError && err != nil {
t.Fatalf("stage %s failed: %v", stage, err)
}
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
t.Fatal("successful upgrade did not clean backups")
}
if stage == "success" {
command := newUpgradeCommand()
command.Flags().Bool("yes", false, "")
command.Flags().Bool("dry-run", false, "")
_ = command.Flags().Set("yes", "true")
_ = command.Flags().Set("force", "true")
_ = command.Flags().Set("skip-skills", "true")
if err := command.RunE(command, nil); err != nil {
t.Fatalf("default upgrade command = %v", err)
}
}
})
}
}
func TestCrossPlatformCoverageUpgradeBinaryHelpersFailureCoverage(t *testing.T) {
oldStat, oldChmod := upgradeStat, upgradeChmod
oldTry, oldRepair := upgradeTryExecVersion, upgradeRepairDarwin
oldGOOS := upgradeRuntimeGOOS
oldLook, oldCommand, oldMkdir, oldHome := upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir
t.Cleanup(func() {
upgradeStat, upgradeChmod = oldStat, oldChmod
upgradeTryExecVersion, upgradeRepairDarwin = oldTry, oldRepair
upgradeRuntimeGOOS = oldGOOS
upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir = oldLook, oldCommand, oldMkdir, oldHome
})
fail := errors.New("failure")
if _, err := oldCommand(filepath.Join(t.TempDir(), "missing-command")); err == nil {
t.Fatal("default command runner executed a missing command")
}
upgradeStat = func(string) (os.FileInfo, error) { return nil, fail }
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
t.Fatalf("binary stat error = %v", err)
}
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{}, nil }
if err := validateNewBinary("binary", "1.0"); err == nil || !strings.Contains(err.Error(), "为空") {
t.Fatalf("empty binary error = %v", err)
}
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{size: 1}, nil }
upgradeChmod = func(string, os.FileMode) error { return fail }
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
t.Fatalf("binary chmod error = %v", err)
}
upgradeChmod = func(string, os.FileMode) error { return nil }
upgradeTryExecVersion = func(string) ([]byte, error) { return nil, fail }
upgradeRepairDarwin = func(string) error { return fail }
if err := validateNewBinary("binary", "1.0"); err == nil {
t.Fatal("unexecutable binary succeeded")
}
upgradeRuntimeGOOS = "darwin"
calls := 0
upgradeTryExecVersion = func(string) ([]byte, error) {
calls++
if calls == 1 {
return nil, errors.New("signal: killed")
}
return []byte("version 1.0"), nil
}
upgradeRepairDarwin = func(string) error { return nil }
if err := validateNewBinary("binary", "1.0"); err != nil {
t.Fatalf("repaired binary = %v", err)
}
upgradeRuntimeGOOS = oldGOOS
upgradeTryExecVersion = func(string) ([]byte, error) { return []byte("different"), nil }
if err := validateNewBinary("binary", "1.0"); err != nil {
t.Fatalf("version mismatch warning = %v", err)
}
upgradeLookPath = func(string) (string, error) { return "", fail }
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
if err := repairDarwinBinary("binary"); !errors.Is(err, fail) {
t.Fatalf("codesign lookup error = %v", err)
}
upgradeLookPath = func(string) (string, error) { return "/codesign", nil }
upgradeCommandOutput = func(name string, _ ...string) ([]byte, error) {
if name == "codesign" {
return []byte("denied"), fail
}
return nil, nil
}
if err := repairDarwinBinary("binary"); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("codesign execution error = %v", err)
}
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
if err := repairDarwinBinary("binary"); err != nil {
t.Fatalf("codesign success = %v", err)
}
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail }
if err := extractTarGz("archive", filepath.Join(t.TempDir(), "dest")); err == nil || !strings.Contains(err.Error(), "tar failed") {
t.Fatalf("tar error = %v", err)
}
upgradeUserHomeDir = func() (string, error) { return "", fail }
if shortenHome("/path") != "/path" {
t.Fatal("home lookup failure shortened path")
}
if got := parseChangelogEntries("abcdef12", 1); len(got) != 0 {
t.Fatalf("empty changelog entry = %#v", got)
}
}