Files
distributor/internal/cli/root_test.go

515 lines
17 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
)
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]any {
t.Helper()
decoder := json.NewDecoder(strings.NewReader(stdout.String()))
var envelope map[string]any
if err := decoder.Decode(&envelope); err != nil {
t.Fatalf("decode JSON envelope: %v; stdout = %q", err, stdout.String())
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
t.Fatalf("stdout contains more than one JSON document: %q", stdout.String())
}
return envelope
}
func envelopeResult(t *testing.T, envelope map[string]any) map[string]any {
t.Helper()
result, ok := envelope["result"].(map[string]any)
if !ok {
t.Fatalf("result = %#v, want object", envelope["result"])
}
return result
}
func TestExecuteRootHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"--help"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d", code, exitOK)
}
if !strings.Contains(stdout.String(), "Usage:") {
t.Fatalf("stdout = %q, want help text", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteVersion(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"version"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d", code, exitOK)
}
if got, want := stdout.String(), "distributor dev\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteVersionJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"version", "--format", "json"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
if envelope["command"] != "version" || envelope["ok"] != true {
t.Fatalf("envelope = %#v, want version ok", envelope)
}
result := envelopeResult(t, envelope)
if result["application"] != "distributor" || result["version"] != "dev" {
t.Fatalf("result = %#v, want application/version", result)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteRejectsInvalidFormat(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"version", "--format", "xml"}, &stdout, &stderr)
if code != exitUsage {
t.Fatalf("exit code = %d, want %d", code, exitUsage)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "format must be text or json") {
t.Fatalf("stderr = %q, want invalid format error", stderr.String())
}
}
func TestExecuteValidate(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"validate", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if got, want := stdout.String(), "Validated 1 bundle(s)\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}
func TestExecuteValidateJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"validate", "--format", "json", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
if envelope["command"] != "validate" || envelope["ok"] != true {
t.Fatalf("envelope = %#v, want validate ok", envelope)
}
result := envelopeResult(t, envelope)
if result["bundle_count"] != float64(1) {
t.Fatalf("result = %#v, want one bundle", result)
}
}
func TestExecuteValidateArgs(t *testing.T) {
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
tests := []struct {
name string
args []string
wantCode int
wantStdout string
wantStderr string
}{
{
name: "zero args",
args: []string{"validate"},
wantCode: exitError,
wantStderr: "requires a path",
},
{
name: "one arg",
args: []string{"validate", validPath},
wantCode: exitOK,
wantStdout: "Validated 1 bundle(s)",
},
{
name: "two args",
args: []string{"validate", validPath, validPath},
wantCode: exitUsage,
wantStderr: "accepts at most one path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), tt.args, &stdout, &stderr)
if code != tt.wantCode {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, tt.wantCode, stderr.String())
}
if tt.wantStdout != "" && !strings.Contains(stdout.String(), tt.wantStdout) {
t.Fatalf("stdout = %q, want substring %q", stdout.String(), tt.wantStdout)
}
if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) {
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
}
})
}
}
func TestExecuteInspect(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"inspect", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "id=weather.daily.brentwood.2026-05-30") {
t.Fatalf("stdout = %q, want bundle summary", stdout.String())
}
}
func TestExecuteInspectJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"inspect", "--format", "json", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
if envelope["command"] != "inspect" || envelope["ok"] != true {
t.Fatalf("envelope = %#v, want inspect ok", envelope)
}
result := envelopeResult(t, envelope)
bundles, ok := result["bundles"].([]any)
if !ok || len(bundles) != 1 {
t.Fatalf("bundles = %#v, want one bundle", result["bundles"])
}
bundle, ok := bundles[0].(map[string]any)
if !ok {
t.Fatalf("bundle = %#v, want object", bundles[0])
}
if bundle["id"] != "weather.daily.brentwood.2026-05-30" || bundle["file_count"] != float64(2) || bundle["total_size"] != float64(24) {
t.Fatalf("bundle = %#v, want normalized metadata", bundle)
}
}
func TestExecuteInspectArgs(t *testing.T) {
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
tests := []struct {
name string
args []string
wantCode int
wantStdout string
wantStderr string
}{
{
name: "zero args",
args: []string{"inspect"},
wantCode: exitError,
wantStderr: "requires a path",
},
{
name: "one arg",
args: []string{"inspect", validPath},
wantCode: exitOK,
wantStdout: "id=weather.daily.brentwood.2026-05-30",
},
{
name: "two args",
args: []string{"inspect", validPath, validPath},
wantCode: exitUsage,
wantStderr: "accepts at most one path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), tt.args, &stdout, &stderr)
if code != tt.wantCode {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, tt.wantCode, stderr.String())
}
if tt.wantStdout != "" && !strings.Contains(stdout.String(), tt.wantStdout) {
t.Fatalf("stdout = %q, want substring %q", stdout.String(), tt.wantStdout)
}
if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) {
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
}
})
}
}
func TestExecuteRunDryRun(t *testing.T) {
sourceRoot := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, t.TempDir())
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath, "--dry-run"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "action=publish_new") {
t.Fatalf("stdout = %q, want config summary", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteRunJSONDryRun(t *testing.T) {
sourceRoot := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, t.TempDir())
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath, "--dry-run", "--format", "json"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
if envelope["command"] != "run" || envelope["ok"] != true {
t.Fatalf("envelope = %#v, want run ok", envelope)
}
result := envelopeResult(t, envelope)
if result["dry_run"] != true {
t.Fatalf("result = %#v, want dry_run true", result)
}
actions, ok := result["actions"].([]any)
if !ok || len(actions) != 1 {
t.Fatalf("actions = %#v, want one action", result["actions"])
}
action, ok := actions[0].(map[string]any)
if !ok || action["action"] != "publish_new" {
t.Fatalf("action = %#v, want publish_new", actions[0])
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteRunJSONWarningsAreStructured(t *testing.T) {
name := "DISTRIBUTOR_TEST_CLI_JSON_SECRET"
t.Setenv(name, "process-value")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
secretsRoot := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
if err := os.WriteFile(filepath.Join(secretsRoot, name), []byte("secret-value\n"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(`
secrets:
directory: `+secretsRoot+`
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath, "--dry-run", "--format", "json"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
warnings, ok := envelope["warnings"].([]any)
if !ok || len(warnings) != 1 {
t.Fatalf("warnings = %#v, want one warning", envelope["warnings"])
}
warning, ok := warnings[0].(map[string]any)
if !ok || !strings.Contains(fmt.Sprint(warning["message"]), name) {
t.Fatalf("warning = %#v, want secret name", warnings[0])
}
if strings.Contains(stdout.String(), "Warning:") || strings.Contains(stdout.String(), "process-value") || strings.Contains(stdout.String(), "secret-value") {
t.Fatalf("stdout exposed text warning or secret values: %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteRunJSONFatalSetupErrorWritesNoJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", filepath.Join(t.TempDir(), "missing.yml"), "--format", "json"}, &stdout, &stderr)
if code != exitError {
t.Fatalf("exit code = %d, want %d", code, exitError)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "no such file or directory") {
t.Fatalf("stderr = %q, want setup error", stderr.String())
}
}
func TestExecuteRunJSONPartialFailure(t *testing.T) {
sourceRoot := t.TempDir()
firstDestination := t.TempDir()
secondDestination := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err)
}
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(`
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive-one
backend: local
path: `+firstDestination+`
- id: archive-two
backend: local
path: `+secondDestination+`
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath, "--format", "json"}, &stdout, &stderr)
if code != exitError {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitError, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty for partial JSON result", stderr.String())
}
envelope := decodeEnvelope(t, &stdout)
if envelope["command"] != "run" || envelope["ok"] != false {
t.Fatalf("envelope = %#v, want failed run envelope", envelope)
}
errors, ok := envelope["errors"].([]any)
if !ok || len(errors) != 1 {
t.Fatalf("errors = %#v, want one error", envelope["errors"])
}
result := envelopeResult(t, envelope)
summary, ok := result["summary"].(map[string]any)
if !ok || summary["status"] != "failed" || summary["failed"] != float64(1) {
t.Fatalf("summary = %#v, want failed summary", result["summary"])
}
actions, ok := result["actions"].([]any)
if !ok || len(actions) != 2 {
t.Fatalf("actions = %#v, want two actions", result["actions"])
}
if _, err := os.Stat(filepath.Join(secondDestination, storage.StateFileName)); err != nil {
t.Fatalf("second destination state stat error = %v", err)
}
}
func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("old"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err)
}
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath, "--force", "--dry-run"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "action=force_replace") {
t.Fatalf("stdout = %q, want force_replace", stdout.String())
}
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); err != nil {
t.Fatalf("unmanaged file stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); !os.IsNotExist(err) {
t.Fatalf("state stat error = %v, want not exist", err)
}
}
func TestExecuteRunRejectsExtraPositionalArgs(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", "config.yml", "extra"}, &stdout, &stderr)
if code != exitUsage {
t.Fatalf("exit code = %d, want %d", code, exitUsage)
}
if !strings.Contains(stderr.String(), "does not accept positional arguments") {
t.Fatalf("stderr = %q, want positional argument error", stderr.String())
}
}
func TestExecuteRunPublishes(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"run", "--config", configPath}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil {
t.Fatalf("state stat error = %v", err)
}
}
func TestUnknownCommandIsUsageError(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"nope"}, &stdout, &stderr)
if code != exitUsage {
t.Fatalf("exit code = %d, want %d", code, exitUsage)
}
if !strings.Contains(stderr.String(), "unknown command") {
t.Fatalf("stderr = %q, want unknown command error", stderr.String())
}
}