package main import ( "bytes" "context" "encoding/json" "encoding/pem" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) const factsBody = `[ {"certname":"host.example.net","environment":"develop","name":"os","value":{"family":"RedHat"}} ]` func catalogFor(environment, content string) string { return `{"name":"host.example.net","environment":"` + environment + `","version":1,"classes":["settings"], "resources":[ {"type":"File","title":"/etc/motd","file":"site.pp","line":9,"parameters":{"ensure":"file","content":"` + content + `"}} ]}` } // testServers stands up a fake puppetserver (TLS) and PuppetDB (plain), and // returns options wired to them with the server's own cert as the CA bundle. func testServers(t *testing.T, compile http.HandlerFunc) *options { t.Helper() puppet := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/status/v1/simple" { _, _ = w.Write([]byte("running")) return } compile(w, r) })) t.Cleanup(puppet.Close) pdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(factsBody)) })) t.Cleanup(pdb.Close) caPath := filepath.Join(t.TempDir(), "ca.pem") block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: puppet.Certificate().Raw}) if err := os.WriteFile(caPath, block, 0o600); err != nil { t.Fatal(err) } return &options{ base: "develop", server: puppet.URL, puppetdb: pdb.URL, cacert: caPath, timeout: 10 * time.Second, } } func exitCode(t *testing.T, err error) int { t.Helper() if err == nil { return exitSame } var ee *exitError if !errors.As(err, &ee) { t.Fatalf("error carries no exit code: %v", err) } return ee.code } func TestResolveEnvironment(t *testing.T) { tests := []struct { name string opts options want string wantCode int }{ {name: "branch is sanitised", opts: options{branch: "benvin/foo-bar"}, want: "benvin_foo_bar"}, {name: "explicit environment wins", opts: options{environment: "develop"}, want: "develop"}, {name: "neither given", opts: options{}, wantCode: exitTransport}, {name: "both given", opts: options{branch: "a", environment: "b"}, wantCode: exitTransport}, {name: "branch with capitals is rejected", opts: options{branch: "benvin/PUP-1"}, wantCode: exitTransport}, {name: "invalid explicit environment", opts: options{environment: "Not-Valid"}, wantCode: exitTransport}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := resolveEnvironment(&tc.opts) if tc.wantCode != 0 { if code := exitCode(t, err); code != tc.wantCode { t.Fatalf("exit code %d, want %d (err %v)", code, tc.wantCode, err) } return } if err != nil { t.Fatalf("resolveEnvironment: %v", err) } if got != tc.want { t.Errorf("= %q, want %q", got, tc.want) } }) } } func TestResolveEnvironmentCapitalsMessage(t *testing.T) { _, err := resolveEnvironment(&options{branch: "benvin/PUP-1"}) if err == nil { t.Fatal("expected an error") } msg := err.Error() for _, want := range []string{"benvin/PUP-1", "benvin_PUP_1", "--environment"} { if !strings.Contains(msg, want) { t.Errorf("message missing %q: %s", want, msg) } } } func TestRunDiff(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { body := readEnvironment(t, r) if body == "develop" { _, _ = w.Write([]byte(catalogFor("develop", "old"))) return } _, _ = w.Write([]byte(catalogFor("benvin_test", "new"))) }) opts.environment = "benvin_test" var out bytes.Buffer err := run(context.Background(), &out, "host.example.net", opts) if code := exitCode(t, err); code != exitDifferent { t.Fatalf("exit code %d, want %d (err %v)", code, exitDifferent, err) } if !strings.Contains(out.String(), "File[/etc/motd] content: old -> new") { t.Errorf("diff not rendered:\n%s", out.String()) } } func TestRunJSON(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { if readEnvironment(t, r) == "develop" { _, _ = w.Write([]byte(catalogFor("develop", "old"))) return } _, _ = w.Write([]byte(catalogFor("benvin_test", "new"))) }) opts.environment = "benvin_test" opts.jsonOut = true var out bytes.Buffer err := run(context.Background(), &out, "host.example.net", opts) if code := exitCode(t, err); code != exitDifferent { t.Fatalf("exit code %d, want %d (err %v)", code, exitDifferent, err) } if !strings.Contains(out.String(), `"base_environment": "develop"`) { t.Errorf("unexpected json:\n%s", out.String()) } } func TestRunTestonlyIsSilent(t *testing.T) { var compiled []string opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { env := readEnvironment(t, r) compiled = append(compiled, env) _, _ = w.Write([]byte(catalogFor(env, "new"))) }) opts.environment = "benvin_test" opts.testonly = true var out bytes.Buffer if err := run(context.Background(), &out, "host.example.net", opts); err != nil { t.Fatalf("run: %v", err) } if out.Len() != 0 { t.Errorf("--testonly printed output: %q", out.String()) } if len(compiled) != 1 || compiled[0] != "benvin_test" { t.Errorf("--testonly must compile the test environment only, compiled %v", compiled) } } func TestRunCompilationFailureExit1(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"kind":"puppet.server/compilation-error","message":"Server Error: Syntax error at '}'","issue_kind":"PARSE_ERROR"}`)) }) opts.environment = "benvin_test" err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts) if code := exitCode(t, err); code != exitCompile { t.Fatalf("exit code %d, want %d (err %v)", code, exitCompile, err) } } func TestRunEnvironmentMismatchExit3(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { // The ENC overrides the requested environment. _, _ = w.Write([]byte(catalogFor("develop", "new"))) }) opts.environment = "benvin_test" err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts) if code := exitCode(t, err); code != exitTransport { t.Fatalf("exit code %d, want %d (err %v)", code, exitTransport, err) } if !strings.Contains(err.Error(), "environment mismatch") { t.Errorf("unhelpful mismatch error: %v", err) } } func TestRunUsageErrors(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(catalogFor("develop", "old"))) }) if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitTransport { t.Errorf("missing --branch/--environment: exit %d, want %d", code, exitTransport) } same := *opts same.environment = "develop" if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", &same)); code != exitTransport { t.Errorf("base == test: exit %d, want %d", code, exitTransport) } badCA := *opts badCA.environment = "benvin_test" badCA.cacert = "/nonexistent/ca.pem" if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", &badCA)); code != exitTransport { t.Errorf("unreadable CA bundle: exit %d, want %d", code, exitTransport) } } func TestRunUnreachableServerExit3(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(catalogFor("benvin_test", "new"))) }) opts.environment = "benvin_test" opts.server = "https://127.0.0.1:1" if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitTransport { t.Errorf("exit %d, want %d", code, exitTransport) } } // readEnvironment returns the environment a compile request asked for. func readEnvironment(t *testing.T, r *http.Request) string { t.Helper() var body struct { Environment string `json:"environment"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decoding compile request: %v", err) } return body.Environment } func TestRunIdenticalCatalogsExit0(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(catalogFor(readEnvironment(t, r), "same"))) }) opts.environment = "benvin_test" var out bytes.Buffer if err := run(context.Background(), &out, "host.example.net", opts); err != nil { t.Fatalf("identical catalogs must exit 0: %v", err) } if !strings.Contains(out.String(), "No differences between environments.") { t.Errorf("unexpected output:\n%s", out.String()) } } func TestRunRetriesTransientFailures(t *testing.T) { var attempts int var mu sync.Mutex opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() attempts++ first := attempts == 1 mu.Unlock() if first { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("bad gateway")) return } _, _ = w.Write([]byte(catalogFor(readEnvironment(t, r), "same"))) }) opts.environment = "benvin_test" opts.retry = 2 if err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts); err != nil { t.Fatalf("a 502 should have been retried: %v", err) } mu.Lock() defer mu.Unlock() if attempts < 3 { t.Errorf("expected a retry, saw %d compile attempts", attempts) } } func TestRunDoesNotRetryCompilationErrors(t *testing.T) { var attempts int var mu sync.Mutex opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { mu.Lock() attempts++ mu.Unlock() w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"kind":"puppet.server/compilation-error","message":"Syntax error"}`)) }) opts.environment = "benvin_test" opts.retry = 3 opts.testonly = true if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitCompile { t.Fatalf("exit %d, want %d", code, exitCompile) } mu.Lock() defer mu.Unlock() if attempts != 1 { t.Errorf("a manifest error must not be retried, saw %d attempts", attempts) } } func TestRunIgnoreFlags(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { if readEnvironment(t, r) == "develop" { _, _ = w.Write([]byte(catalogFor("develop", "old"))) return } _, _ = w.Write([]byte(catalogFor("benvin_test", "new"))) }) opts.environment = "benvin_test" opts.ignoreResources = []string{"File[/etc/*]"} var out bytes.Buffer if err := run(context.Background(), &out, "host.example.net", opts); err != nil { t.Fatalf("ignored resource should leave no differences: %v", err) } if !strings.Contains(out.String(), "No differences") { t.Errorf("--ignore not applied:\n%s", out.String()) } } // wrappedCatalogFor is the shape OpenVoxServer 8.8 actually returns: the // catalog under a single "catalog" key. func wrappedCatalogFor(environment, content string) string { return `{"catalog":` + catalogFor(environment, content) + `}` } func TestRunAgainstWrappedResponses(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { if readEnvironment(t, r) == "develop" { _, _ = w.Write([]byte(wrappedCatalogFor("develop", "old"))) return } _, _ = w.Write([]byte(wrappedCatalogFor("benvin_test", "new"))) }) opts.environment = "benvin_test" var out bytes.Buffer err := run(context.Background(), &out, "host.example.net", opts) if code := exitCode(t, err); code != exitDifferent { t.Fatalf("exit code %d, want %d (err %v)", code, exitDifferent, err) } if !strings.Contains(out.String(), "File[/etc/motd] content: old -> new") { t.Errorf("wrapped response not diffed:\n%s", out.String()) } } func TestRunUnknownResponseShapeDoesNotBlameTheENC(t *testing.T) { var attempts int var mu sync.Mutex opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { mu.Lock() attempts++ mu.Unlock() _, _ = w.Write([]byte(`{"unexpected":{"environment":"benvin_test"}}`)) }) opts.environment = "benvin_test" opts.testonly = true opts.retry = 3 err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts) if code := exitCode(t, err); code != exitTransport { t.Fatalf("exit %d, want %d (err %v)", code, exitTransport, err) } if strings.Contains(err.Error(), "encapi") || strings.Contains(err.Error(), "environment mismatch") { t.Errorf("a decode failure must not be reported as an ENC problem: %v", err) } mu.Lock() defer mu.Unlock() if attempts != 1 { t.Errorf("an unusable response shape must not be retried, saw %d attempts", attempts) } } const pdbFailureBody = "Internal Server Error: org.jruby.exceptions.RuntimeError: (PreformattedError) Evaluation Error: " + "Error while evaluating a Resource Statement, Evaluation Error: Error while evaluating a Function Call, " + "Failed to execute '/pdb/query/v4' on at least 1 of the following 'server_urls': https://puppetdb.example.net " + "(file: /etc/puppetlabs/code/environments/develop/site/profiles/manifests/app/client.pp, line: 31, column: 29) " + "on node host.example.net" func TestRunRetriesPuppetDBQueryFailures(t *testing.T) { var attempts int var mu sync.Mutex opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() attempts++ first := attempts == 1 mu.Unlock() if first { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(pdbFailureBody)) return } _, _ = w.Write([]byte(wrappedCatalogFor(readEnvironment(t, r), "same"))) }) opts.environment = "benvin_test" opts.testonly = true opts.retry = 2 if err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts); err != nil { t.Fatalf("a failed PuppetDB query should have been retried: %v", err) } mu.Lock() defer mu.Unlock() if attempts != 2 { t.Errorf("expected one retry, saw %d compile attempts", attempts) } } func TestRunDoesNotRetryManifestErrorsReturnedAs500(t *testing.T) { var attempts int var mu sync.Mutex opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { mu.Lock() attempts++ mu.Unlock() w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Internal Server Error: org.jruby.exceptions.RuntimeError: (Error) " + "Could not find class roles::app::demo::node for host.example.net on node host.example.net")) }) opts.environment = "benvin_test" opts.testonly = true opts.retry = 3 if code := exitCode(t, run(context.Background(), &bytes.Buffer{}, "host.example.net", opts)); code != exitCompile { t.Fatalf("exit %d, want %d", code, exitCompile) } mu.Lock() defer mu.Unlock() if attempts != 1 { t.Errorf("a manifest error must not be retried even at HTTP 500, saw %d attempts", attempts) } } func TestValidateRejectsOutOfRangeFlags(t *testing.T) { tests := []struct { name string opts options want string }{ {name: "negative retry", opts: options{retry: -1, timeout: time.Minute}, want: "--retry"}, {name: "retry above the cap", opts: options{retry: maxRetry + 1, timeout: time.Minute}, want: "--retry"}, {name: "zero timeout", opts: options{}, want: "--timeout"}, {name: "negative timeout", opts: options{timeout: -time.Second}, want: "--timeout"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := validate(&tc.opts) if code := exitCode(t, err); code != exitTransport { t.Fatalf("exit %d, want %d", code, exitTransport) } if !strings.Contains(err.Error(), tc.want) { t.Errorf("message must name %s: %v", tc.want, err) } }) } } func TestValidateAcceptsBoundaryValues(t *testing.T) { for _, opts := range []options{ {retry: 0, timeout: time.Nanosecond}, {retry: maxRetry, timeout: time.Hour}, } { if err := validate(&opts); err != nil { t.Errorf("validate(%+v) = %v, want nil", opts, err) } } } func TestRunRejectsANegativeRetryWithoutPanicking(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(catalogFor(readEnvironment(t, r), "same"))) }) opts.environment = "benvin_test" opts.retry = -1 err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts) if code := exitCode(t, err); code != exitTransport { t.Fatalf("exit %d, want %d (err %v)", code, exitTransport, err) } // main prints this before exiting; a nil inner error used to panic here. if msg := err.Error(); !strings.Contains(msg, "--retry") { t.Errorf("unhelpful usage error: %s", msg) } } func TestExitErrorWithNoInnerErrorIsPrintable(t *testing.T) { e := &exitError{code: exitTransport} if msg := e.Error(); !strings.Contains(msg, "3") { t.Errorf("Error() = %q, want the exit code", msg) } if e.Unwrap() != nil { t.Error("Unwrap of an empty exitError must be nil") } var nilErr *exitError if msg := nilErr.Error(); msg == "" { t.Error("a nil *exitError must still print something") } } func TestBackoffStaysWithinBounds(t *testing.T) { for _, attempt := range []int{0, 1, 2, 5, 31, 32, 64, 1 << 30} { d := backoff(attempt) if d <= 0 || d > 45*time.Second { t.Errorf("backoff(%d) = %s, out of range", attempt, d) } } } func TestRunCancelsTheSiblingCompileOnFirstFailure(t *testing.T) { const slow = 4 * time.Second opts := testServers(t, func(w http.ResponseWriter, r *http.Request) { if readEnvironment(t, r) == "develop" { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"kind":"puppet.server/compilation-error","message":"Syntax error"}`)) return } select { case <-r.Context().Done(): case <-time.After(slow): _, _ = w.Write([]byte(catalogFor("benvin_test", "new"))) } }) opts.environment = "benvin_test" start := time.Now() err := run(context.Background(), &bytes.Buffer{}, "host.example.net", opts) elapsed := time.Since(start) // The base compile failed for real; the cancelled sibling is not the cause. if code := exitCode(t, err); code != exitCompile { t.Fatalf("exit %d, want %d (err %v)", code, exitCompile, err) } if elapsed > slow/2 { t.Errorf("the sibling compile was not cancelled: run took %s", elapsed) } } func TestRunReportsCancellationDuringRetryBackoff(t *testing.T) { opts := testServers(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("bad gateway")) }) opts.environment = "benvin_test" opts.testonly = true opts.retry = 5 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() err := run(ctx, &bytes.Buffer{}, "host.example.net", opts) if code := exitCode(t, err); code != exitTransport { t.Fatalf("exit %d, want %d (err %v)", code, exitTransport, err) } if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("the cancellation must be the reported cause, got %v", err) } }