package puppetdb import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" ) // The shape PuppetDB answers /pdb/query/v4/facts with: a flat array of fact // records, each naming the node it belongs to. const factsBody = `[ {"certname":"host.example.net","environment":"develop","name":"id","value":"root"}, {"certname":"host.example.net","environment":"develop","name":"os","value":{"family":"RedHat","release":{"major":"9"}}}, {"certname":"host.example.net","environment":"develop","name":"processorcount","value":4} ]` func assertFacts(t *testing.T, facts map[string]any) { t.Helper() if facts["id"] != "root" { t.Errorf("id = %v", facts["id"]) } if facts["processorcount"] != float64(4) { t.Errorf("processorcount = %#v", facts["processorcount"]) } os, ok := facts["os"].(map[string]any) if !ok { t.Fatalf("os fact not structured: %#v", facts["os"]) } if os["family"] != "RedHat" { t.Errorf("os.family = %v", os["family"]) } release, ok := os["release"].(map[string]any) if !ok || release["major"] != "9" { t.Errorf("nested os.release lost: %#v", os["release"]) } } // factsServer answers the query route with body and fails the test on any // other path. func factsServer(t *testing.T, body string) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/pdb/query/v4/facts" { t.Errorf("unexpected path %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(body)) })) t.Cleanup(srv.Close) return srv } func TestFactsQueriesByCertname(t *testing.T) { var paths, queries []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) queries = append(queries, r.URL.Query().Get("query")) _, _ = w.Write([]byte(factsBody)) })) defer srv.Close() facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err != nil { t.Fatalf("Facts: %v", err) } assertFacts(t, facts) if len(paths) != 1 || paths[0] != "/pdb/query/v4/facts" { t.Errorf("unexpected requests: %v", paths) } if len(queries) != 1 || queries[0] != `["=","certname","host.example.net"]` { t.Errorf("unexpected query: %v", queries) } } // A certname with characters the AST would otherwise have to escape must // survive both JSON encoding and query-string encoding intact. func TestFactsEscapesTheCertnameInTheQuery(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get("query") _, _ = w.Write([]byte(`[]`)) })) defer srv.Close() const certname = `we"ird &host` _, _ = New(srv.URL, 5*time.Second).Facts(context.Background(), certname) var ast []string if err := json.Unmarshal([]byte(got), &ast); err != nil { t.Fatalf("query %q is not valid JSON: %v", got, err) } if len(ast) != 3 || ast[0] != "=" || ast[1] != "certname" || ast[2] != certname { t.Errorf("query = %q, decoded %#v", got, ast) } } // The response shape as PuppetDB really returns it, structured facts and all. func TestFactsDecodesARealResponse(t *testing.T) { srv := factsServer(t, `[ {"certname":"host.example.net","environment":"develop","name":"id","value":"root"}, {"certname":"host.example.net","environment":"develop","name":"processorcount","value":4}, {"certname":"host.example.net","environment":"develop","name":"os","value":{"name":"AlmaLinux","distro":{"id":"AlmaLinux","release":{"full":"9.7","major":"9","minor":"7"}},"family":"RedHat","release":{"full":"9.7","major":"9","minor":"7"},"selinux":{"enabled":false},"architecture":"x86_64"}} ]`) facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err != nil { t.Fatalf("Facts: %v", err) } assertFacts(t, facts) os := facts["os"].(map[string]any) if os["architecture"] != "x86_64" { t.Errorf("os.architecture = %v", os["architecture"]) } selinux, ok := os["selinux"].(map[string]any) if !ok || selinux["enabled"] != false { t.Errorf("nested os.selinux lost: %#v", os["selinux"]) } } // An unknown node is not an HTTP error: the query simply matches nothing. func TestFactsEmptyResultIsAMissingNode(t *testing.T) { srv := factsServer(t, `[]`) _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "nope.example.net") if !errors.Is(err, ErrNodeNotFound) { t.Fatalf("err = %v, want ErrNodeNotFound", err) } } func TestFactsMissingNode(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"No information is known about node nope.example.net"}`)) })) defer srv.Close() _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "nope.example.net") if !errors.Is(err, ErrNodeNotFound) { t.Fatalf("err = %v, want ErrNodeNotFound", err) } } func TestFactsServerError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`boom`)) })) defer srv.Close() _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err == nil { t.Fatal("expected an error on HTTP 500") } if errors.Is(err, ErrNodeNotFound) { t.Fatalf("500 misreported as a missing node: %v", err) } } func TestFactsBadJSON(t *testing.T) { srv := factsServer(t, `not json`) if _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net"); err == nil { t.Fatal("expected a parse error") } } func TestFactsContextCancelled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(200 * time.Millisecond) _, _ = w.Write([]byte(factsBody)) })) defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() if _, err := New(srv.URL, 5*time.Second).Facts(ctx, "host.example.net"); err == nil { t.Fatal("expected a timeout error") } } // assertNamesBoth checks the mismatch error names the node asked for and the // ones that came back, and that it is not dressed up as a parse failure. func assertNamesBoth(t *testing.T, err error, want string, got ...string) { t.Helper() if !strings.Contains(err.Error(), want) { t.Errorf("error does not name the requested certname %s: %v", want, err) } for _, g := range got { if !strings.Contains(err.Error(), g) { t.Errorf("error does not name the returned certname %s: %v", g, err) } } if strings.Contains(err.Error(), "parsing facts") { t.Errorf("a certname mismatch is not a parse failure: %v", err) } } func TestFactsRejectsRecordsForAnotherNode(t *testing.T) { srv := factsServer(t, `[ {"certname":"totally-different-host.example.net","name":"id","value":"nobody"}, {"certname":"totally-different-host.example.net","name":"os","value":{"family":"Debian"}} ]`) facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err == nil { t.Fatalf("another node's facts were served as ours: %#v", facts) } assertNamesBoth(t, err, "host.example.net", "totally-different-host.example.net") } func TestFactsRejectsAMixedResponseNamingEveryNode(t *testing.T) { srv := factsServer(t, `[ {"certname":"a.example.net","name":"id","value":"a"}, {"certname":"b.example.net","name":"id","value":"b"} ]`) _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err == nil { t.Fatal("expected an error when no record matches the certname") } assertNamesBoth(t, err, "host.example.net", "a.example.net", "b.example.net") } func TestFactsDropsAForeignRecordFromAnOtherwiseMatchingResponse(t *testing.T) { srv := factsServer(t, `[ {"certname":"host.example.net","name":"id","value":"root"}, {"certname":"other.example.net","name":"id","value":"nobody"}, {"certname":"host.example.net","name":"os","value":{"family":"RedHat","release":{"major":"9"}}}, {"certname":"host.example.net","name":"processorcount","value":4} ]`) facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err != nil { t.Fatalf("Facts: %v", err) } assertFacts(t, facts) } // The query already names the node, so a record that asserts no certname of // its own cannot be contradicting it. func TestFactsAcceptsRecordsWithNoCertname(t *testing.T) { srv := factsServer(t, `[ {"name":"id","value":"root"}, {"name":"os","value":{"family":"RedHat","release":{"major":"9"}}}, {"name":"processorcount","value":4} ]`) facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err != nil { t.Fatalf("Facts: %v", err) } assertFacts(t, facts) } func TestFactsMatchesCertnameCaseInsensitively(t *testing.T) { srv := factsServer(t, factsBody) facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "HOST.example.net") if err != nil { t.Fatalf("Facts: %v", err) } assertFacts(t, facts) } func TestFactsToleratesALeadingBOM(t *testing.T) { srv := factsServer(t, "\ufeff"+factsBody) facts, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err != nil { t.Fatalf("Facts: %v", err) } assertFacts(t, facts) } func TestFactsSkipsUndecodableRecords(t *testing.T) { srv := factsServer(t, `[ {"certname":"host.example.net","name":"id","value":"root"}, {"certname":"host.example.net","name":"broken"}, {"certname":"host.example.net","name":"os","value":{"family":"RedHat","release":{"major":"9"}}}, {"certname":"host.example.net","name":"processorcount","value":4} ]`) var warnings []string var counted int c := New(srv.URL, 5*time.Second) c.Warn = func(msg string, records int) { warnings = append(warnings, msg) counted += records } facts, err := c.Facts(context.Background(), "host.example.net") if err != nil { t.Fatalf("one bad fact must not fail the fetch: %v", err) } assertFacts(t, facts) if _, ok := facts["broken"]; ok { t.Error("a valueless fact must be dropped, not stored") } if len(warnings) != 1 || !strings.Contains(warnings[0], "broken") { t.Errorf("the skipped fact must be reported, got %v", warnings) } if counted != 1 { t.Errorf("Warn reported %d dropped record(s), want 1", counted) } } func TestFactsWithNothingDecodableIsAnError(t *testing.T) { srv := factsServer(t, `[{"name":"a"},{"name":"b"}]`) _, err := New(srv.URL, 5*time.Second).Facts(context.Background(), "host.example.net") if err == nil { t.Fatal("a fact set in which nothing decodes must not pass as empty") } if !strings.Contains(err.Error(), "no usable facts") { t.Errorf("unhelpful error: %v", err) } } func TestFactsByName(t *testing.T) { var gotQuery, gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotQuery = r.URL.Query().Get("query") w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[ {"certname":"a.example.net","name":"country","value":"au"}, {"certname":"a.example.net","name":"os","value":{"name":"AlmaLinux","release":{"major":"9","minor":"7"}}} ]`)) })) defer srv.Close() records, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), []string{"country", "os"}) if err != nil { t.Fatalf("FactsByName: %v", err) } if gotPath != "/pdb/query/v4/facts" { t.Errorf("path = %s", gotPath) } if gotQuery != `["extract",["certname","name","value"],["or",["=","name","country"],["=","name","os"]]]` { t.Errorf("query = %s", gotQuery) } if len(records) != 2 { t.Fatalf("records = %#v", records) } if records[0].Certname != "a.example.net" || records[0].Value != "au" { t.Errorf("first record = %#v", records[0]) } os, ok := records[1].Value.(map[string]any) if !ok || os["name"] != "AlmaLinux" { t.Errorf("structured os fact lost: %#v", records[1].Value) } } func TestFactsByNameEmptyNames(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("no request should be made for an empty fact list") })) defer srv.Close() records, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), nil) if err != nil || records != nil { t.Errorf("FactsByName(nil) = %v, %v", records, err) } } func TestFactsByNameServerError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("boom")) })) defer srv.Close() if _, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), []string{"country"}); err == nil { t.Fatal("expected an error for a 500") } } func TestFactsByNameSkipsUndecodableRecords(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte("\ufeff" + `[ {"certname":"a.example.net","name":"country","value":"au"}, {"certname":"a.example.net","name":"broken"}, {"certname":"b.example.net","name":"broken"}, {"certname":"c.example.net","name":"broken"} ]`)) })) defer srv.Close() var warnings []string var counted int c := New(srv.URL, 5*time.Second) c.Warn = func(msg string, records int) { warnings = append(warnings, msg) counted += records } records, err := c.FactsByName(context.Background(), []string{"country", "broken"}) if err != nil { t.Fatalf("FactsByName: %v", err) } if len(records) != 1 || records[0].Name != "country" { t.Fatalf("records = %#v", records) } // One query batches its drops into one message, so the count has to come // from the hook and not from the number of messages. if len(warnings) != 1 || !strings.Contains(warnings[0], "broken") { t.Errorf("the skipped fact must be reported, got %v", warnings) } if counted != 3 { t.Errorf("Warn reported %d dropped record(s), want 3", counted) } } func TestFactsByNameWithoutWarnHookReportsOnStderr(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"certname":"a.example.net","name":"broken"}]`)) })) defer srv.Close() stderr := captureStderr(t) if _, err := New(srv.URL, 5*time.Second).FactsByName(context.Background(), []string{"broken"}); err != nil { t.Fatalf("FactsByName: %v", err) } if got := stderr(); !strings.Contains(got, "broken") { t.Errorf("an unwired Warn must still report the drop, stderr = %q", got) } } // captureStderr redirects os.Stderr for the duration of the test and returns a // func that restores it and yields what was written. func captureStderr(t *testing.T) func() string { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatalf("pipe: %v", err) } saved := os.Stderr os.Stderr = w var once sync.Once var out string read := func() string { once.Do(func() { os.Stderr = saved _ = w.Close() b, _ := io.ReadAll(r) _ = r.Close() out = string(b) }) return out } t.Cleanup(func() { read() }) return read }