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 pdbmux (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, pdb: 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()) } }