Co-authored-by: zearlin <ruomiao.linrm@alibaba-inc.com> Co-authored-by: chichuan <30925823+haofeng0705@users.noreply.github.com>
385 lines
12 KiB
Go
385 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.txt")
|
|
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var tempMode os.FileMode
|
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
|
info, err := cmd.OutOrStdout().(*os.File).Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempMode = info.Mode().Perm()
|
|
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
|
return err
|
|
})
|
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
|
if _, err := root.ExecuteC(); err != nil {
|
|
t.Fatalf("ExecuteC: %v", err)
|
|
}
|
|
|
|
assertOutputFile(t, target, "replacement", 0o600)
|
|
if tempMode != 0o600 {
|
|
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
|
|
}
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.txt")
|
|
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
|
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
|
return errors.New("handler failed")
|
|
})
|
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
|
if _, err := root.ExecuteC(); err == nil {
|
|
t.Fatal("ExecuteC succeeded")
|
|
}
|
|
|
|
assertOutputFile(t, target, "original", 0o640)
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
|
|
testseam.Protect(t, &os.Args)
|
|
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
|
|
target := os.Args[3]
|
|
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
|
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
|
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
|
var stdout, stderr bytes.Buffer
|
|
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
|
root := NewRootCommandWithEngine(ctx, engine)
|
|
root.SetOut(&stdout)
|
|
root.SetErr(&stderr)
|
|
leaf := &cobra.Command{
|
|
Use: "atomic-output-unified-failure",
|
|
RunE: func(*cobra.Command, []string) error {
|
|
return apperrors.NewValidation("business validation failed")
|
|
},
|
|
}
|
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
|
root.AddCommand(leaf)
|
|
return root
|
|
})
|
|
|
|
if code := Execute(); code != 3 {
|
|
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
|
|
}
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Outcome string `json:"outcome"`
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
|
}
|
|
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
|
|
t.Fatalf("failure envelope=%+v", envelope)
|
|
}
|
|
assertOutputFile(t, target, "original", 0o640)
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.txt")
|
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
|
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
|
|
panic("boom")
|
|
})
|
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
|
if recovered := executeAndRecover(root); recovered == nil {
|
|
t.Fatal("ExecuteC did not panic")
|
|
}
|
|
|
|
assertOutputFile(t, target, "original", 0o600)
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
|
|
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
|
return errors.New("rename failed")
|
|
})
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.txt")
|
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
|
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
|
return err
|
|
})
|
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
|
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
|
|
t.Fatalf("ExecuteC error=%v, want publication failure", err)
|
|
}
|
|
|
|
assertOutputFile(t, target, "original", 0o600)
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
seam func(*testing.T)
|
|
}{
|
|
{
|
|
name: "sync",
|
|
seam: func(t *testing.T) {
|
|
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
|
|
},
|
|
},
|
|
{
|
|
name: "close",
|
|
seam: func(t *testing.T) {
|
|
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
|
|
_ = file.Close()
|
|
return errors.New("close failed")
|
|
})
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.seam(t)
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.txt")
|
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
|
|
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
|
|
return err
|
|
})
|
|
root.SetArgs([]string{"atomic-output", "--output", target})
|
|
if _, err := root.ExecuteC(); err == nil {
|
|
t.Fatal("ExecuteC succeeded")
|
|
}
|
|
|
|
assertOutputFile(t, target, "original", 0o600)
|
|
assertNoOutputTemps(t, target)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
|
|
testseam.Swap(t, &rootRenameFile, func(string, string) error {
|
|
return errors.New("rename failed")
|
|
})
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.json")
|
|
|
|
root := NewRootCommand()
|
|
leaf := &cobra.Command{
|
|
Use: "atomic-output-unified",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
|
},
|
|
}
|
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
|
root.AddCommand(leaf)
|
|
root.SetArgs([]string{"atomic-output-unified", "--output", target})
|
|
if _, err := root.ExecuteC(); err == nil {
|
|
t.Fatal("unified ExecuteC succeeded without publishing its output")
|
|
} else if code := apperrors.ExitCode(err); code != 5 {
|
|
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
|
|
}
|
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("final output exists after publication failure: %v", err)
|
|
}
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
|
testseam.Protect(t, &os.Args)
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
// Keep argv portable: an absolute Windows path contains a volume colon,
|
|
// which the CLI intentionally rejects as unsafe user-supplied output.
|
|
target := "result.json"
|
|
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalInfo, err := os.Stat(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
|
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
|
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
|
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
|
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
|
var stdout, stderr bytes.Buffer
|
|
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
|
|
root := NewRootCommandWithEngine(ctx, engine)
|
|
root.SetOut(&stdout)
|
|
root.SetErr(&stderr)
|
|
leaf := &cobra.Command{
|
|
Use: "atomic-output-unified-publication",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
|
|
},
|
|
}
|
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
|
root.AddCommand(leaf)
|
|
return root
|
|
})
|
|
|
|
if code := Execute(); code != 5 {
|
|
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
var envelope output.Envelope
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
|
|
}
|
|
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
|
|
t.Fatalf("publication failure envelope=%+v", envelope)
|
|
}
|
|
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
|
|
t.Fatalf("publication failure message=%q", envelope.Error.Message)
|
|
}
|
|
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
|
|
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
|
|
}
|
|
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
|
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
|
}
|
|
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.json")
|
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := NewRootCommand()
|
|
leaf := &cobra.Command{
|
|
Use: "atomic-emission-failure",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
|
|
return err
|
|
}
|
|
return cmd.OutOrStdout().(*os.File).Close()
|
|
},
|
|
}
|
|
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
|
|
root.AddCommand(leaf)
|
|
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
|
|
if _, err := root.ExecuteC(); err == nil {
|
|
t.Fatal("ExecuteC succeeded after emission failure")
|
|
}
|
|
|
|
assertOutputFile(t, target, "original", 0o600)
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "result.txt")
|
|
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := NewRootCommand()
|
|
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
|
|
leaf.Flags().String("required", "", "")
|
|
_ = leaf.MarkFlagRequired("required")
|
|
root.AddCommand(leaf)
|
|
root.SetArgs([]string{"atomic-validation", "--output", target})
|
|
if _, err := root.ExecuteC(); err == nil {
|
|
t.Fatal("ExecuteC succeeded without required flag")
|
|
}
|
|
|
|
assertOutputFile(t, target, "original", 0o600)
|
|
assertNoOutputTemps(t, target)
|
|
}
|
|
|
|
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
|
|
root := NewRootCommand()
|
|
root.AddCommand(&cobra.Command{
|
|
Use: "atomic-output",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return run(cmd)
|
|
},
|
|
})
|
|
return root
|
|
}
|
|
|
|
func executeAndRecover(cmd *cobra.Command) (recovered any) {
|
|
defer func() { recovered = recover() }()
|
|
_, _ = cmd.ExecuteC()
|
|
return nil
|
|
}
|
|
|
|
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read output: %v", err)
|
|
}
|
|
if string(data) != want {
|
|
t.Fatalf("output=%q, want %q", data, want)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat output: %v", err)
|
|
}
|
|
if mode := info.Mode().Perm(); mode != wantMode {
|
|
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
|
|
}
|
|
}
|
|
|
|
func assertNoOutputTemps(t *testing.T, target string) {
|
|
t.Helper()
|
|
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(matches) != 0 {
|
|
t.Fatalf("temporary output files remain: %v", matches)
|
|
}
|
|
}
|