1026 lines
34 KiB
Go
1026 lines
34 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
|
producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
|
)
|
|
|
|
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 TestExecuteServeParsesConfig(t *testing.T) {
|
|
originalServeApp := serveApp
|
|
defer func() {
|
|
serveApp = originalServeApp
|
|
}()
|
|
var gotOptions app.ServeOptions
|
|
serveApp = func(_ context.Context, options app.ServeOptions) error {
|
|
gotOptions = options
|
|
return nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{"serve", "--config", "config.yml"}, &stdout, &stderr)
|
|
|
|
if code != exitOK {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
if gotOptions.ConfigPath != "config.yml" {
|
|
t.Fatalf("ConfigPath = %q, want config.yml", gotOptions.ConfigPath)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
|
}
|
|
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 TestExecuteValidateConfiguredSource(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{"validate", "--config", configPath, "--pipeline", "reports"}, &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) for pipeline reports source local\n"; got != want {
|
|
t.Fatalf("stdout = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExecuteValidateConfiguredSourceJSON(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{ID: "reports.daily"})
|
|
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{"validate", "--config", configPath, "--pipeline", "reports", "--bundle", "daily", "--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"] != "validate" || envelope["ok"] != true {
|
|
t.Fatalf("envelope = %#v, want validate ok", envelope)
|
|
}
|
|
result := envelopeResult(t, envelope)
|
|
if result["pipeline_id"] != "reports" || result["source_backend"] != "local" || result["bundle_count"] != float64(1) {
|
|
t.Fatalf("result = %#v, want configured source metadata", 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",
|
|
},
|
|
{
|
|
name: "path plus config",
|
|
args: []string{"validate", "--config", "config.yml", "--pipeline", "reports", validPath},
|
|
wantCode: exitUsage,
|
|
wantStderr: "does not accept a local path",
|
|
},
|
|
{
|
|
name: "pipeline without config",
|
|
args: []string{"validate", "--pipeline", "reports"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires --config",
|
|
},
|
|
{
|
|
name: "bundle without config",
|
|
args: []string{"validate", "--bundle", "daily"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires --config",
|
|
},
|
|
{
|
|
name: "config without pipeline",
|
|
args: []string{"validate", "--config", "config.yml"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires --pipeline",
|
|
},
|
|
{
|
|
name: "invalid format",
|
|
args: []string{"validate", "--format", "xml", validPath},
|
|
wantCode: exitUsage,
|
|
wantStderr: "format must be text or json",
|
|
},
|
|
}
|
|
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 TestExecuteInspectConfiguredSource(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{ID: "reports.daily"})
|
|
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{"inspect", "--config", configPath, "--pipeline", "reports"}, &stdout, &stderr)
|
|
|
|
if code != exitOK {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
for _, want := range []string{
|
|
"Pipeline: reports",
|
|
"Source: local",
|
|
"path=daily",
|
|
"id=reports.daily",
|
|
} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("stdout = %q, want substring %q", stdout.String(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
},
|
|
{
|
|
name: "path plus config",
|
|
args: []string{"inspect", "--config", "config.yml", "--pipeline", "reports", validPath},
|
|
wantCode: exitUsage,
|
|
wantStderr: "does not accept a local path",
|
|
},
|
|
{
|
|
name: "pipeline without config",
|
|
args: []string{"inspect", "--pipeline", "reports"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires --config",
|
|
},
|
|
{
|
|
name: "bundle without config",
|
|
args: []string{"inspect", "--bundle", "daily"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires --config",
|
|
},
|
|
{
|
|
name: "config without pipeline",
|
|
args: []string{"inspect", "--config", "config.yml"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires --pipeline",
|
|
},
|
|
{
|
|
name: "invalid format",
|
|
args: []string{"inspect", "--format", "xml", validPath},
|
|
wantCode: exitUsage,
|
|
wantStderr: "format must be text or json",
|
|
},
|
|
}
|
|
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 TestExecuteManifestCreateExplicitFiles(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCLIFile(t, root, "b.txt", "bravo")
|
|
writeCLIFile(t, root, "nested/a.txt", "alpha")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{
|
|
"manifest", "create", root,
|
|
"--id", "reports.explicit",
|
|
"--created", "2026-06-01T11:00:00Z",
|
|
"--file", "b.txt",
|
|
"--file", "nested/a.txt",
|
|
}, &stdout, &stderr)
|
|
|
|
if code != exitOK {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
for _, want := range []string{
|
|
"created manifest.json",
|
|
"bundle: reports.explicit",
|
|
"files: 2",
|
|
"digest: sha256:",
|
|
} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("stdout = %q, want substring %q", stdout.String(), want)
|
|
}
|
|
}
|
|
manifest, err := producerbundle.LoadManifest(root)
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest() error = %v", err)
|
|
}
|
|
if got, want := manifestPaths(manifest), []string{"b.txt", "nested/a.txt"}; !equalStrings(got, want) {
|
|
t.Fatalf("manifest paths = %v, want %v", got, want)
|
|
}
|
|
if err := producerbundle.ValidateBundle(root, manifest); err != nil {
|
|
t.Fatalf("ValidateBundle() error = %v", err)
|
|
}
|
|
var validateStdout, validateStderr bytes.Buffer
|
|
validateCode := Execute(context.Background(), []string{"validate", root}, &validateStdout, &validateStderr)
|
|
if validateCode != exitOK {
|
|
t.Fatalf("validate exit code = %d, want %d; stderr = %q", validateCode, exitOK, validateStderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteManifestCreateScansBundle(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCLIFile(t, root, "z.txt", "zulu")
|
|
writeCLIFile(t, root, ".env", "dotfile")
|
|
writeCLIFile(t, root, "nested/report.md", "# Report\n")
|
|
writeCLIFile(t, root, storage.StateFileName, "destination state")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.scan"}, &stdout, &stderr)
|
|
|
|
if code != exitOK {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
manifest, err := producerbundle.LoadManifest(root)
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest() error = %v", err)
|
|
}
|
|
if got, want := manifestPaths(manifest), []string{".env", "nested/report.md", "z.txt"}; !equalStrings(got, want) {
|
|
t.Fatalf("manifest paths = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExecuteManifestCreateJSON(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCLIFile(t, root, "report.md", "# Report\n")
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.json", "--file", "report.md", "--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"] != "manifest create" || envelope["ok"] != true {
|
|
t.Fatalf("envelope = %#v, want manifest create ok", envelope)
|
|
}
|
|
result := envelopeResult(t, envelope)
|
|
if result["id"] != "reports.json" || result["file_count"] != float64(1) {
|
|
t.Fatalf("result = %#v, want manifest summary", result)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteManifestCreateOverwrite(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCLIFile(t, root, "report.md", "old\n")
|
|
var stdout, stderr bytes.Buffer
|
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.old", "--file", "report.md"}, &stdout, &stderr)
|
|
if code != exitOK {
|
|
t.Fatalf("initial exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
writeCLIFile(t, root, "report.md", "new\n")
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.new", "--file", "report.md"}, &stdout, &stderr)
|
|
if code != exitError {
|
|
t.Fatalf("overwrite exit code = %d, want %d", code, exitError)
|
|
}
|
|
if !strings.Contains(stderr.String(), "write manifest") {
|
|
t.Fatalf("stderr = %q, want write manifest error", stderr.String())
|
|
}
|
|
manifest, err := producerbundle.LoadManifest(root)
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest() error = %v", err)
|
|
}
|
|
if manifest.ID != "reports.old" {
|
|
t.Fatalf("manifest id = %q, want reports.old", manifest.ID)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.new", "--file", "report.md", "--overwrite"}, &stdout, &stderr)
|
|
if code != exitOK {
|
|
t.Fatalf("overwrite exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
manifest, err = producerbundle.LoadManifest(root)
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest() error = %v", err)
|
|
}
|
|
if manifest.ID != "reports.new" {
|
|
t.Fatalf("manifest id = %q, want reports.new", manifest.ID)
|
|
}
|
|
}
|
|
|
|
func TestExecuteManifestCreateRejectsSymlink(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCLIFile(t, root, "target.md", "# Report\n")
|
|
if err := os.Symlink("target.md", filepath.Join(root, "link.md")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.link", "--file", "link.md"}, &stdout, &stderr)
|
|
|
|
if code != exitError {
|
|
t.Fatalf("exit code = %d, want %d", code, exitError)
|
|
}
|
|
if !strings.Contains(stderr.String(), "regular file") {
|
|
t.Fatalf("stderr = %q, want regular file error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteManifestCreateArgs(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeCLIFile(t, root, "report.md", "# Report\n")
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantCode int
|
|
wantStderr string
|
|
}{
|
|
{
|
|
name: "missing path",
|
|
args: []string{"manifest", "create", "--id", "reports.missing"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "requires exactly one bundle path",
|
|
},
|
|
{
|
|
name: "missing id",
|
|
args: []string{"manifest", "create", root},
|
|
wantCode: exitError,
|
|
wantStderr: "requires --id",
|
|
},
|
|
{
|
|
name: "bad created",
|
|
args: []string{"manifest", "create", root, "--id", "reports.bad", "--created", "June 1"},
|
|
wantCode: exitError,
|
|
wantStderr: "created must be RFC3339",
|
|
},
|
|
{
|
|
name: "bad format",
|
|
args: []string{"manifest", "create", root, "--id", "reports.bad", "--format", "xml"},
|
|
wantCode: exitUsage,
|
|
wantStderr: "format must be text or json",
|
|
},
|
|
}
|
|
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 stdout.Len() != 0 {
|
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
|
}
|
|
if !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())
|
|
}
|
|
wantStdout := "Configured pipelines: 1\n" +
|
|
"- pipeline=reports source=local bundles=1 destinations=archive\n" +
|
|
" - bundle=. destination=archive backend=local action=publish_new workflow=additive outputs=report.md,summary.txt reason=\"\"\n" +
|
|
"Final status: ok planned=1 publish_new=1 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=0 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=true fixed_path=0\n"
|
|
if got := stdout.String(); got != wantStdout {
|
|
t.Fatalf("stdout = %q, want %q", got, wantStdout)
|
|
}
|
|
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)
|
|
}
|
|
pipelines, ok := result["pipelines"].([]any)
|
|
if !ok || len(pipelines) != 1 {
|
|
t.Fatalf("pipelines = %#v, want one pipeline", result["pipelines"])
|
|
}
|
|
pipeline, ok := pipelines[0].(map[string]any)
|
|
if !ok || pipeline["id"] != "reports" || pipeline["source_backend"] != "local" || pipeline["bundle_count"] != float64(1) {
|
|
t.Fatalf("pipeline = %#v, want reports/local summary", pipelines[0])
|
|
}
|
|
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])
|
|
}
|
|
outputs, ok := action["outputs"].([]any)
|
|
if !ok || len(outputs) != 2 {
|
|
t.Fatalf("outputs = %#v, want source outputs", action["outputs"])
|
|
}
|
|
summary, ok := result["summary"].(map[string]any)
|
|
if !ok || summary["status"] != "ok" || summary["planned"] != float64(1) || summary["publish_new"] != float64(1) || summary["dry_run"] != true {
|
|
t.Fatalf("summary = %#v, want ok dry-run publish counters", result["summary"])
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRunJSONDryRunReportsFixedPathMapping(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
|
if err := os.WriteFile(configPath, []byte(`
|
|
pipelines:
|
|
- id: reports
|
|
source:
|
|
backend: local
|
|
path: `+sourceRoot+`
|
|
destinations:
|
|
- id: latest
|
|
backend: local
|
|
path: `+destinationRoot+`
|
|
path_mapping:
|
|
mode: fixed
|
|
`), 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 fixed-path warning", envelope["warnings"])
|
|
}
|
|
warning, ok := warnings[0].(map[string]any)
|
|
if !ok || !strings.Contains(fmt.Sprint(warning["message"]), "path_mapping=fixed candidates=1 selected_bundle=.") {
|
|
t.Fatalf("warning = %#v, want fixed-path selection warning", warnings[0])
|
|
}
|
|
result := envelopeResult(t, envelope)
|
|
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["path_mapping"] != "fixed" || action["destination_path"] != "." || action["action"] != "publish_new" {
|
|
t.Fatalf("action = %#v, want fixed publish_new at root", actions[0])
|
|
}
|
|
summary, ok := result["summary"].(map[string]any)
|
|
if !ok || summary["fixed_path"] != float64(1) {
|
|
t.Fatalf("summary = %#v, want fixed_path 1", result["summary"])
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRunJSONDryRunReportsLinks(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
|
if err := os.WriteFile(configPath, []byte(`
|
|
pipelines:
|
|
- id: reports
|
|
source:
|
|
backend: local
|
|
path: `+sourceRoot+`
|
|
destinations:
|
|
- id: web
|
|
backend: local
|
|
path: `+destinationRoot+`
|
|
links:
|
|
base_url: https://reports.example.com/archive
|
|
primary: source
|
|
`), 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)
|
|
result := envelopeResult(t, envelope)
|
|
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["primary_url"] != "https://reports.example.com/archive/report.md" {
|
|
t.Fatalf("action = %#v, want primary URL", actions[0])
|
|
}
|
|
outputs, ok := action["outputs"].([]any)
|
|
if !ok || len(outputs) != 2 {
|
|
t.Fatalf("outputs = %#v, want two outputs", action["outputs"])
|
|
}
|
|
output, ok := outputs[0].(map[string]any)
|
|
if !ok || output["url"] != "https://reports.example.com/archive/report.md" {
|
|
t.Fatalf("output = %#v, want output URL", outputs[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, "report.md"), []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("write unmanaged planned 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 workflow=additive") || !strings.Contains(stdout.String(), "force_replace=1") {
|
|
t.Fatalf("stdout = %q, want forced replacement", 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())
|
|
}
|
|
}
|
|
|
|
func manifestPaths(manifest producerbundle.Manifest) []string {
|
|
paths := make([]string, 0, len(manifest.Files))
|
|
for _, file := range manifest.Files {
|
|
paths = append(paths, file.Path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for index := range a {
|
|
if a[index] != b[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeCLIFile(t *testing.T, root, relative, body string) {
|
|
t.Helper()
|
|
path := filepath.Join(root, filepath.FromSlash(relative))
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|