package main import ( "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // ---- helpers ---------------------------------------------------------------- func newTestServer(t *testing.T, facts []fact) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(facts) })) } func rawJSON(v interface{}) json.RawMessage { b, _ := json.Marshal(v) return json.RawMessage(b) } // captureStdout redirects os.Stdout for the duration of fn and returns whatever // was written. run() writes directly to os.Stdout, so the output modes are // exercised end-to-end this way. func captureStdout(t *testing.T, fn func()) string { t.Helper() old := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatal(err) } os.Stdout = w defer func() { os.Stdout = old }() fn() _ = w.Close() var buf strings.Builder _, _ = io.Copy(&buf, r) return buf.String() } // ---- buildQuery ------------------------------------------------------------- func TestBuildQuery_NoFilters(t *testing.T) { // With no node/fact/match, buildQuery falls back to a role-fact query. q := buildQuery("", "", "", "enc_role", false, false, false) if !strings.Contains(q, "enc_role") || !strings.Contains(q, "name") { t.Fatalf("expected default role query, got %s", q) } } func TestBuildQuery_Node(t *testing.T) { q := buildQuery("host1", "", "", "enc_role", false, false, false) if !strings.Contains(q, "certname") || !strings.Contains(q, "host1") { t.Fatalf("unexpected query: %s", q) } } func TestBuildQuery_FactAndMatch(t *testing.T) { q := buildQuery("", "region", "syd1", "enc_role", false, false, false) if !strings.Contains(q, "region") || !strings.Contains(q, "syd1") { t.Fatalf("unexpected query: %s", q) } // exact match uses the "=" operator, never "~". if strings.Contains(q, `"~"`) { t.Fatalf("exact match should not use ~ operator: %s", q) } } func TestBuildQuery_PartialMatch(t *testing.T) { // -p turns the value match into a regex ("~") comparison. q := buildQuery("", "enc_role", "dns", "enc_role", false, true, false) if !strings.Contains(q, `"~"`) || !strings.Contains(q, "dns") { t.Fatalf("expected partial (~) match query, got %s", q) } } func TestBuildQuery_InverseMatch(t *testing.T) { // -i wraps the value comparison in a "not". q := buildQuery("", "enc_role", "dns", "enc_role", false, false, true) if !strings.Contains(q, `"not"`) { t.Fatalf("expected inverse (not) match query, got %s", q) } } func TestBuildQuery_InversePartialMatch(t *testing.T) { // -i and -p compose: a negated regex match. q := buildQuery("", "enc_role", "dns", "enc_role", false, true, true) if !strings.Contains(q, `"not"`) || !strings.Contains(q, `"~"`) { t.Fatalf("expected inverse partial match query, got %s", q) } // The regex operator must sit inside the "not" wrapper, not beside it. notIdx := strings.Index(q, `"not"`) tildeIdx := strings.Index(q, `"~"`) if notIdx < 0 || tildeIdx < 0 || tildeIdx < notIdx { t.Fatalf("expected ~ nested inside not, got %s", q) } } func TestBuildQuery_ShowRole(t *testing.T) { q := buildQuery("", "", "", "my_role_fact", true, false, false) if !strings.Contains(q, "my_role_fact") { t.Fatalf("expected role fact in query, got %s", q) } } func TestBuildQuery_CustomRoleFact(t *testing.T) { q := buildQuery("", "", "", "custom_role", true, false, false) if !strings.Contains(q, "custom_role") { t.Fatalf("expected custom role fact, got %s", q) } } func TestBuildQuery_FactBeatsRole(t *testing.T) { // An explicit -F fact name takes precedence over the role fact. q := buildQuery("", "region", "", "enc_role", true, false, false) if !strings.Contains(q, "region") { t.Fatalf("expected explicit fact name, got %s", q) } if strings.Contains(q, "enc_role") { t.Fatalf("role fact should not appear when -F is set: %s", q) } } func TestBuildQuery_ValidJSON(t *testing.T) { // Whatever the flag combination, the query must be a valid JSON array. q := buildQuery("host1", "region", "syd1", "enc_role", false, true, true) var v []interface{} if err := json.Unmarshal([]byte(q), &v); err != nil { t.Fatalf("query is not valid JSON: %v (%s)", err, q) } if v[0] != "and" { t.Fatalf("expected combined query to start with 'and', got %v", v[0]) } } // ---- valueString / valueAny ------------------------------------------------- func TestValueString_String(t *testing.T) { if got := valueString(rawJSON("hello")); got != "hello" { t.Fatalf("expected hello, got %s", got) } } func TestValueString_Number(t *testing.T) { if got := valueString(rawJSON(42)); got != "42" { t.Fatalf("expected 42, got %s", got) } } func TestValueString_Bool(t *testing.T) { if got := valueString(rawJSON(true)); got != "true" { t.Fatalf("expected true, got %s", got) } } func TestValueString_Object(t *testing.T) { raw := json.RawMessage(`{"a":1}`) if got := valueString(raw); got != `{"a":1}` { t.Fatalf("expected compact JSON, got %s", got) } } func TestValueAny_TypesPreserved(t *testing.T) { if v := valueAny(rawJSON("s")); v != "s" { t.Fatalf("expected string, got %v", v) } if v := valueAny(rawJSON(3)); v != float64(3) { t.Fatalf("expected float64(3), got %T %v", v, v) } if v := valueAny(rawJSON(true)); v != true { t.Fatalf("expected bool true, got %v", v) } } // ---- processResults --------------------------------------------------------- func TestProcessResults_Sorted(t *testing.T) { facts := []fact{ {Certname: "z-host", Value: rawJSON("val1")}, {Certname: "a-host", Value: rawJSON("val2")}, } out := processResults(facts) if out[0] != "a-host val2" || out[1] != "z-host val1" { t.Fatalf("unexpected order: %v", out) } } func TestProcessResults_Empty(t *testing.T) { if out := processResults(nil); len(out) != 0 { t.Fatalf("expected empty, got %v", out) } } // ---- countResults ----------------------------------------------------------- func TestCountResults_Basic(t *testing.T) { lines := []string{"host1 syd1", "host2 syd1", "host3 mel1"} out := countResults(lines) found := map[string]bool{} for _, l := range out { found[l] = true } if !found["2: syd1"] { t.Fatalf("expected '2: syd1' in %v", out) } if !found["1: mel1"] { t.Fatalf("expected '1: mel1' in %v", out) } } func TestCountResults_Sorted(t *testing.T) { // Lexicographic sort of the "N: value" strings: "1: z" < "2: a". out := countResults([]string{"h1 z", "h2 a", "h3 a"}) if out[0] != "1: z" || out[1] != "2: a" { t.Fatalf("unexpected order: %v", out) } } // ---- queryPuppetDB ---------------------------------------------------------- func TestQueryPuppetDB_Success(t *testing.T) { want := []fact{{Certname: "node1", Name: "enc_role", Value: rawJSON("roles::dns")}} srv := newTestServer(t, want) defer srv.Close() got, err := queryPuppetDB(srv.URL, `["=","name","enc_role"]`) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Certname != "node1" { t.Fatalf("unexpected result: %v", got) } } func TestQueryPuppetDB_SendsQueryParam(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get("query") _ = json.NewEncoder(w).Encode([]fact{}) })) defer srv.Close() _, _ = queryPuppetDB(srv.URL, `["=","name","enc_role"]`) if got != `["=","name","enc_role"]` { t.Fatalf("query param not forwarded correctly, got %q", got) } } func TestQueryPuppetDB_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) })) defer srv.Close() if _, err := queryPuppetDB(srv.URL, `[]`); err == nil { t.Fatal("expected error for 404") } } func TestQueryPuppetDB_BadJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })) defer srv.Close() if _, err := queryPuppetDB(srv.URL, `[]`); err == nil { t.Fatal("expected decode error") } } func TestQueryPuppetDB_ConnectionRefused(t *testing.T) { if _, err := queryPuppetDB("http://127.0.0.1:1/facts", `[]`); err == nil { t.Fatal("expected connection error") } } // ---- config ----------------------------------------------------------------- func TestLoadConfig_Defaults(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("NODE_LOOKUP_URL", "") t.Setenv("NODE_LOOKUP_ROLE_FACT", "") cfg, err := loadConfig() if err != nil { t.Fatal(err) } if cfg.PuppetDBURL != defaultPuppetDBURL { t.Fatalf("expected default URL, got %s", cfg.PuppetDBURL) } if cfg.RoleFact != defaultRoleFact { t.Fatalf("expected default role fact, got %s", cfg.RoleFact) } } func TestLoadConfig_EnvOverride(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("NODE_LOOKUP_URL", "http://custom:9090/facts") t.Setenv("NODE_LOOKUP_ROLE_FACT", "my_role") cfg, err := loadConfig() if err != nil { t.Fatal(err) } if cfg.PuppetDBURL != "http://custom:9090/facts" { t.Fatalf("env override failed: %s", cfg.PuppetDBURL) } if cfg.RoleFact != "my_role" { t.Fatalf("env override failed: %s", cfg.RoleFact) } } func TestLoadConfig_FileOverride(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("NODE_LOOKUP_URL", "") t.Setenv("NODE_LOOKUP_ROLE_FACT", "") cfgDir := filepath.Join(dir, appName) if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\nrole_fact: file_role\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := loadConfig() if err != nil { t.Fatal(err) } if cfg.PuppetDBURL != "http://file:8080/facts" { t.Fatalf("file override failed: %s", cfg.PuppetDBURL) } if cfg.RoleFact != "file_role" { t.Fatalf("file override failed: %s", cfg.RoleFact) } } func TestLoadConfig_EnvOverridesFile(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("NODE_LOOKUP_URL", "http://env:9999/facts") t.Setenv("NODE_LOOKUP_ROLE_FACT", "") cfgDir := filepath.Join(dir, appName) if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := loadConfig() if err != nil { t.Fatal(err) } if cfg.PuppetDBURL != "http://env:9999/facts" { t.Fatalf("env should beat file, got %s", cfg.PuppetDBURL) } } func TestLoadConfig_InvalidYAML(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("NODE_LOOKUP_URL", "") t.Setenv("NODE_LOOKUP_ROLE_FACT", "") cfgDir := filepath.Join(dir, appName) if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(":\tinvalid: yaml:\n"), 0o644); err != nil { t.Fatal(err) } if _, err := loadConfig(); err == nil { t.Fatal("expected error for invalid YAML") } } func TestWriteDefaultConfig(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) if err := writeDefaultConfig(); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(dir, appName, configFileName)) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "puppetdb_url") { t.Fatalf("config file missing puppetdb_url: %s", data) } } func TestWriteDefaultConfig_AlreadyExists(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) if err := writeDefaultConfig(); err != nil { t.Fatal(err) } if err := writeDefaultConfig(); err == nil { t.Fatal("expected error when config already exists") } } // ---- allFactsForNode -------------------------------------------------------- func TestAllFactsForNode_ReturnsFacts(t *testing.T) { facts := []fact{ {Certname: "node1", Name: "zebra", Value: rawJSON("z-val")}, {Certname: "node1", Name: "alpha", Value: rawJSON("a-val")}, {Certname: "node1", Name: "middle", Value: rawJSON(42)}, } srv := newTestServer(t, facts) defer srv.Close() got, err := allFactsForNode(srv.URL, "node1") if err != nil { t.Fatal(err) } if len(got) != 3 { t.Fatalf("expected 3 facts, got %d", len(got)) } } func TestAllFactsForNode_QueryContainsCertname(t *testing.T) { var receivedQuery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedQuery = r.URL.Query().Get("query") _ = json.NewEncoder(w).Encode([]fact{}) })) defer srv.Close() _, _ = allFactsForNode(srv.URL, "mynode.example.com") if !strings.Contains(receivedQuery, "mynode.example.com") { t.Fatalf("expected certname in query, got: %s", receivedQuery) } if !strings.Contains(receivedQuery, "certname") { t.Fatalf("expected 'certname' in query, got: %s", receivedQuery) } } func TestAllFactsForNode_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) })) defer srv.Close() if _, err := allFactsForNode(srv.URL, "node1"); err == nil { t.Fatal("expected error for HTTP 500") } } // ---- stdinReader (no-TTY behavior) ------------------------------------------ func TestStdinReader_CharDeviceFallsThrough(t *testing.T) { // /dev/null is a character device — the same shape stdin has when the tool // is invoked by an agent/CI without a TTY. It must NOT be treated as input. f, err := os.Open(os.DevNull) if err != nil { t.Fatal(err) } defer func() { _ = f.Close() }() if _, ok := stdinReader(f); ok { t.Fatal("expected /dev/null to be treated as no stdin data") } } func TestStdinReader_EmptyPipeFallsThrough(t *testing.T) { // A closed, empty pipe (e.g. `true | node-lookup`) must fall through to a // normal query rather than consuming empty input and printing nothing. r, w, err := os.Pipe() if err != nil { t.Fatal(err) } _ = w.Close() // no data written; reader sees immediate EOF defer func() { _ = r.Close() }() if _, ok := stdinReader(r); ok { t.Fatal("expected empty pipe to be treated as no stdin data") } } func TestStdinReader_PipeWithDataIsRead(t *testing.T) { r, w, err := os.Pipe() if err != nil { t.Fatal(err) } go func() { _, _ = io.WriteString(w, "node1\nnode2\n") _ = w.Close() }() defer func() { _ = r.Close() }() reader, ok := stdinReader(r) if !ok { t.Fatal("expected pipe with data to be treated as stdin input") } got, _ := reader.ReadString('\n') if strings.TrimSpace(got) != "node1" { t.Fatalf("expected first line 'node1', got %q", got) } } func TestStdinReader_RegularFileWithData(t *testing.T) { path := filepath.Join(t.TempDir(), "nodes.txt") if err := os.WriteFile(path, []byte("host1\nhost2\n"), 0o644); err != nil { t.Fatal(err) } f, err := os.Open(path) if err != nil { t.Fatal(err) } defer func() { _ = f.Close() }() if _, ok := stdinReader(f); !ok { t.Fatal("expected redirected file with data to be treated as stdin input") } } func TestStdinReader_EmptyFileFallsThrough(t *testing.T) { path := filepath.Join(t.TempDir(), "empty.txt") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatal(err) } f, err := os.Open(path) if err != nil { t.Fatal(err) } defer func() { _ = f.Close() }() if _, ok := stdinReader(f); ok { t.Fatal("expected empty file to be treated as no stdin data") } } // ---- run: validation -------------------------------------------------------- func TestRun_AllFacts_RequiresNode(t *testing.T) { cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"} err := run(cfg, "", "", "", false, false, false, false, false, false, false, false, true) if err == nil || !strings.Contains(err.Error(), "-a requires -n") { t.Fatalf("expected -a requires -n error, got: %v", err) } } func TestRun_OutputFlagsRequireRoleOrFact(t *testing.T) { cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"} // -1 (nodeOnly) with neither -R nor -F must error before any HTTP call. err := run(cfg, "", "", "", false, false, false, true, false, false, false, false, false) if err == nil || !strings.Contains(err.Error(), "-R or -F") { t.Fatalf("expected -R/-F requirement error, got: %v", err) } } func TestRun_MatchRequiresRoleOrFact(t *testing.T) { cfg := config{PuppetDBURL: "http://unused", RoleFact: "enc_role"} // -m with neither -R nor -F must error before any HTTP call. err := run(cfg, "", "", "someval", false, false, false, false, false, false, false, false, false) if err == nil || !strings.Contains(err.Error(), "-R or -F") { t.Fatalf("expected -R/-F requirement error, got: %v", err) } } // ---- run: output modes ------------------------------------------------------ type runArgs struct { cfg config nodeName, factName, match string showRole, partial, inverse, nodeOnly, valueOnly, count bool ansible, jsonMode, allFacts bool } // runToString invokes run against a mock PuppetDB returning facts, capturing // stdout. nodeName defaults to "n1" so the stdin path is skipped. func runToString(t *testing.T, facts []fact, mutate func(*runArgs)) string { t.Helper() srv := newTestServer(t, facts) t.Cleanup(srv.Close) a := runArgs{ cfg: config{PuppetDBURL: srv.URL, RoleFact: "enc_role"}, nodeName: "n1", showRole: true, } if mutate != nil { mutate(&a) } return captureStdout(t, func() { if err := run(a.cfg, a.nodeName, a.factName, a.match, a.showRole, a.partial, a.inverse, a.nodeOnly, a.valueOnly, a.count, a.ansible, a.jsonMode, a.allFacts); err != nil { t.Fatalf("run returned error: %v", err) } }) } func TestRun_DefaultOutput(t *testing.T) { facts := []fact{ {Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")}, {Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")}, } out := strings.TrimSpace(runToString(t, facts, nil)) lines := strings.Split(out, "\n") if len(lines) != 2 || lines[0] != "hosta roles::db" || lines[1] != "hostb roles::web" { t.Fatalf("unexpected default output: %q", out) } } func TestRun_NodeOnly(t *testing.T) { facts := []fact{ {Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")}, {Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")}, } out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.nodeOnly = true })) if out != "hosta\nhostb" { t.Fatalf("unexpected -1 output: %q", out) } } func TestRun_ValueOnly(t *testing.T) { facts := []fact{ {Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")}, {Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")}, } out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.valueOnly = true })) if out != "roles::db\nroles::web" { t.Fatalf("unexpected -2 output: %q", out) } } func TestRun_Count(t *testing.T) { facts := []fact{ {Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::web")}, {Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")}, {Certname: "hostc", Name: "enc_role", Value: rawJSON("roles::db")}, } out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.count = true })) if !strings.Contains(out, "2: roles::web") || !strings.Contains(out, "1: roles::db") { t.Fatalf("unexpected -C output: %q", out) } } func TestRun_JSON(t *testing.T) { facts := []fact{{Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")}} out := runToString(t, facts, func(a *runArgs) { a.jsonMode = true }) var parsed map[string]map[string]interface{} if err := json.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("output is not valid JSON: %v (%s)", err, out) } if parsed["hosta"]["enc_role"] != "roles::db" { t.Fatalf("unexpected JSON structure: %v", parsed) } } func TestRun_Ansible(t *testing.T) { facts := []fact{ {Certname: "hosta", Name: "enc_role", Value: rawJSON("roles::db")}, {Certname: "hostb", Name: "enc_role", Value: rawJSON("roles::web")}, } out := runToString(t, facts, func(a *runArgs) { a.ansible = true }) if !strings.Contains(out, "all:") || !strings.Contains(out, "hosts:") { t.Fatalf("expected Ansible inventory structure, got: %q", out) } if !strings.Contains(out, "hosta:") || !strings.Contains(out, "hostb:") { t.Fatalf("expected both hosts in inventory, got: %q", out) } } func TestRun_AllFacts_PrintsSortedByName(t *testing.T) { facts := []fact{ {Certname: "node1", Name: "zzz_fact", Value: rawJSON("last")}, {Certname: "node1", Name: "aaa_fact", Value: rawJSON("first")}, {Certname: "node1", Name: "mmm_fact", Value: rawJSON(true)}, } out := strings.TrimSpace(runToString(t, facts, func(a *runArgs) { a.showRole = false a.allFacts = true })) lines := strings.Split(out, "\n") if len(lines) != 3 { t.Fatalf("expected 3 lines, got %d: %q", len(lines), out) } if !strings.HasPrefix(lines[0], "aaa_fact") || !strings.HasPrefix(lines[1], "mmm_fact") || !strings.HasPrefix(lines[2], "zzz_fact") { t.Fatalf("facts not sorted by name: %v", lines) } }