Files
dingtalk-workspace-cli/internal/app/root_output_atomic_test.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)
}
}