Files
pdbmux/source_test.go
unkin-agent 49ce1293de
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled
Sum /facts aggregates across backends
A `/facts` aggregate row carries no certname, so the per-certname fact
merge collapsed every backend's rows into one bucket and served a single
backend's numbers.

- Route a `/facts` query whose `extract` carries a `function` column to
  serveSummed, as /nodes, /resources and /reports already do
- Document which extract functions combine correctly across backends
2026-09-06 15:08:33 +10:00

571 lines
22 KiB
Go

package main
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"slices"
"strings"
"testing"
)
// sourceValues returns certname -> value of the synthetic fact record, and the
// number of records carrying that fact name.
func sourceValues(t *testing.T, body []byte, factName string) (map[string]string, int) {
t.Helper()
var raws []json.RawMessage
if err := json.Unmarshal(body, &raws); err != nil {
t.Fatalf("unmarshal %s: %v", body, err)
}
out := map[string]string{}
n := 0
for _, raw := range raws {
var m struct {
Certname string `json:"certname"`
Name string `json:"name"`
Value string `json:"value"`
}
if json.Unmarshal(raw, &m) != nil || m.Name != factName {
continue
}
out[m.Certname] = m.Value
n++
}
return out, n
}
// nodeSources returns certname -> the stamped provenance field on /nodes records.
func nodeSources(t *testing.T, body []byte, field string) map[string]string {
t.Helper()
var raws []json.RawMessage
if err := json.Unmarshal(body, &raws); err != nil {
t.Fatalf("unmarshal %s: %v", body, err)
}
out := map[string]string{}
for _, raw := range raws {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatalf("unmarshal record %s: %v", raw, err)
}
var m recordMeta
_ = json.Unmarshal(raw, &m)
v, ok := obj[field]
if !ok {
continue
}
var s string
if err := json.Unmarshal(v, &s); err != nil {
t.Fatalf("provenance field of %s is not a string: %v", raw, err)
}
out[m.Certname] = s
}
return out
}
func factEnv(cn, name, val, env string) string {
return `{"certname":"` + cn + `","name":"` + name + `","value":"` + val + `","environment":"` + env + `"}`
}
// Both backends hold h1; a holds its newer report, so h1's facts and its
// provenance fact must both come from a.
func TestHandler_FactsSourceFollowsMergeOwner(t *testing.T) {
a := newFakeBackend(t,
`[`+node("h1", "2026-07-20T00:00:00Z")+`,`+node("h2", "2026-07-01T00:00:00Z")+`]`,
`[`+factEnv("h1", "role", "web-a", "production")+`,`+factEnv("h2", "role", "db-a", "production")+`]`)
b := newFakeBackend(t,
`[`+node("h1", "2026-07-01T00:00:00Z")+`,`+node("h2", "2026-07-20T00:00:00Z")+`]`,
`[`+factEnv("h1", "role", "web-b", "staging")+`,`+factEnv("h2", "role", "db-b", "staging")+`]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeFreshness))
rec := doGet(t, srv.Handler(), factsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact)
if n != 2 {
t.Fatalf("expected one %s record per certname, got %d: %s", defaultSourceFact, n, rec.Body.String())
}
if got["h1"] != "a" || got["h2"] != "b" {
t.Errorf("provenance must name the backend that won the merge, got %v", got)
}
}
// The synthetic record carries the node's own environment so it groups with the
// real facts rather than landing in an unrelated environment.
func TestHandler_FactsSourceCopiesEnvironment(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+factEnv("h1", "role", "web", "staging")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsPath, "")
var raws []json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &raws); err != nil {
t.Fatal(err)
}
var found bool
for _, raw := range raws {
var m recordMeta
if json.Unmarshal(raw, &m) != nil || m.Name != defaultSourceFact {
continue
}
found = true
if m.Environment != "staging" {
t.Errorf("environment = %q, want staging: %s", m.Environment, raw)
}
}
if !found {
t.Fatalf("no %s record: %s", defaultSourceFact, rec.Body.String())
}
}
// A node record's provenance names the backend whose node record won /nodes'
// own report_timestamp merge.
func TestHandler_NodesSourceStamped(t *testing.T) {
a := newFakeBackend(t,
`[`+node("h1", "2026-07-01T00:00:00Z")+`,`+node("h2", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got := nodeSources(t, rec.Body.Bytes(), defaultSourceFact)
if got["h1"] != "b" || got["h2"] != "a" {
t.Errorf("node provenance = %v, want h1=b h2=a", got)
}
}
// Stamping must not drop unknown upstream fields.
func TestHandler_NodesSourceKeepsUpstreamFields(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, "")
var raws []json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &raws); err != nil {
t.Fatal(err)
}
if len(raws) != 1 {
t.Fatalf("expected 1 node, got %d", len(raws))
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raws[0], &obj); err != nil {
t.Fatal(err)
}
for _, k := range []string{"certname", "report_timestamp", "latest_report_status", defaultSourceFact} {
if _, ok := obj[k]; !ok {
t.Errorf("field %q missing from stamped record: %s", k, raws[0])
}
}
}
func TestHandler_SourceDisabled(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
cfg := testConfig(a.srv.URL, b.srv.URL, mergeStatic)
cfg.SourceFactEnabled = false
srv := newTestServer(cfg)
facts := doGet(t, srv.Handler(), factsPath, "")
if _, n := sourceValues(t, facts.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("disabled injection still produced %d records: %s", n, facts.Body.String())
}
nodes := doGet(t, srv.Handler(), nodesPath, "")
if got := nodeSources(t, nodes.Body.Bytes(), defaultSourceFact); len(got) != 0 {
t.Errorf("disabled injection still stamped nodes: %v", got)
}
}
func TestHandler_SourceFactNameOverride(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
cfg := testConfig(a.srv.URL, b.srv.URL, mergeStatic)
cfg.SourceFact = "origin_pdb"
srv := newTestServer(cfg)
facts := doGet(t, srv.Handler(), factsPath, "")
got, n := sourceValues(t, facts.Body.Bytes(), "origin_pdb")
if n != 1 || got["h1"] != "a" {
t.Errorf("override name not honoured: %s", facts.Body.String())
}
if _, n := sourceValues(t, facts.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("default name still emitted alongside the override: %s", facts.Body.String())
}
nodes := doGet(t, srv.Handler(), nodesPath, "")
if got := nodeSources(t, nodes.Body.Bytes(), "origin_pdb"); got["h1"] != "a" {
t.Errorf("override name not honoured on /nodes: %v", got)
}
}
// An upstream fact of the configured name is replaced, not duplicated: pdbmux's
// own attribution is authoritative.
func TestHandler_UpstreamSourceFactOverridden(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web", "")+`,`+fact("h1", defaultSourceFact, "stale-value", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsPath, "")
got, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact)
if n != 1 {
t.Fatalf("expected exactly 1 %s record, got %d: %s", defaultSourceFact, n, rec.Body.String())
}
if got["h1"] != "a" {
t.Errorf("upstream value survived: %v", got)
}
}
// The configured name means one thing on every query shape: an upstream fact of
// that name is dropped whether or not the query gate allows synthesis.
func TestHandler_UpstreamSourceFactSuppressedOnEveryGateState(t *testing.T) {
const upstream = "REAL-UPSTREAM-VALUE"
tests := []struct {
name string
query string
want string // synthetic value, or "" when the gate blocks injection
}{
{"injection on", "", "a"},
{"gated by extract", `["extract",["certname","name","value"],["=","certname","h1"]]`, ""},
{"gated by name filter", `["=","name","` + defaultSourceFact + `"]`, ""},
{"gated by nested extract", `["and",["=","certname","h1"],["extract",["certname"]]]`, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web", "")+`,`+fact("h1", defaultSourceFact, upstream, "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsPath, tc.query)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), upstream) {
t.Fatalf("upstream value survived: %s", rec.Body.String())
}
got, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact)
if tc.want == "" {
if n != 0 {
t.Errorf("gated query produced %d %s records: %s", n, defaultSourceFact, rec.Body.String())
}
} else if n != 1 || got["h1"] != tc.want {
t.Errorf("provenance = %v (%d records), want h1=%s: %s", got, n, tc.want, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"role"`) {
t.Errorf("real facts were dropped: %s", rec.Body.String())
}
})
}
}
// Dropping an upstream record is invisible in the response, so it is logged —
// once per request, not once per record.
func TestHandler_SuppressedUpstreamFactLoggedOncePerRequest(t *testing.T) {
a := newFakeBackend(t,
`[`+node("h1", "2026-07-20T00:00:00Z")+`,`+node("h2", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", defaultSourceFact, "old-h1", "")+`,`+fact("h2", defaultSourceFact, "old-h2", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
var buf bytes.Buffer
srv := NewServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic), log.New(&buf, "", 0))
doGet(t, srv.Handler(), factsPath, "")
if n := strings.Count(buf.String(), defaultSourceFact); n != 1 {
t.Fatalf("expected 1 log line naming the fact, got %d: %s", n, buf.String())
}
if !strings.Contains(buf.String(), "dropped 2") {
t.Errorf("log does not report the number dropped: %s", buf.String())
}
}
// Disabled means untouched: an upstream fact of the configured name is served as
// the backend reported it.
func TestHandler_SourceDisabledKeepsUpstreamFact(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", defaultSourceFact, "upstream-value", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
cfg := testConfig(a.srv.URL, b.srv.URL, mergeStatic)
cfg.SourceFactEnabled = false
srv := newTestServer(cfg)
rec := doGet(t, srv.Handler(), factsPath, "")
got, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact)
if n != 1 || got["h1"] != "upstream-value" {
t.Errorf("disabled injection altered the upstream fact: %s", rec.Body.String())
}
}
// Suppression matches a record's own name field, so rows from a projection that
// filters on name without returning it are not self-identifying and pass
// through. Pinned as a known limit of the guarantee, and documented as one.
func TestHandler_ProjectionWithoutNameColumnCarriesUpstreamValue(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.bodies[factsPath] = `[{"certname":"h1","value":"upstream-value"}]`
var buf bytes.Buffer
srv := NewServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic), log.New(&buf, "", 0))
rec := doGet(t, srv.Handler(), factsPath,
`["extract",["certname","value"],["=","name","`+defaultSourceFact+`"]]`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); !strings.Contains(got, "upstream-value") {
t.Errorf("unidentifiable row was dropped: %s", got)
}
if strings.Contains(buf.String(), "dropped") {
t.Errorf("a row with no name field was counted as suppressed: %s", buf.String())
}
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("gated projection gained %d synthetic records: %s", n, rec.Body.String())
}
}
// A /facts aggregate is summed, and the summed row must gain no synthetic record
// and no stamp.
func TestHandler_SourceNotInjectedOnFactsAggregate(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.bodies[factsPath] = `[{"count":3}]`
b.bodies[factsPath] = `[{"count":2}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsPath, `["extract",[["function","count"]]]`)
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("aggregate response gained %d synthetic records: %s", n, rec.Body.String())
}
if strings.Contains(rec.Body.String(), defaultSourceFact) {
t.Errorf("aggregate rows were stamped: %s", rec.Body.String())
}
if got := counts(t, rec.Body.Bytes(), "count"); !slices.Equal(got, []float64{5}) {
t.Errorf("count = %v, want [5]", got)
}
}
// /nodes aggregates are summed rather than merged, so nothing may stamp them.
func TestHandler_NodesAggregateNotStamped(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.bodies[nodesPath] = `[{"count":3}]`
b.bodies[nodesPath] = `[{"count":2}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, `["extract",[["function","count"]]]`)
if strings.Contains(rec.Body.String(), defaultSourceFact) {
t.Errorf("aggregate rows were stamped: %s", rec.Body.String())
}
if got := rec.Body.String(); !strings.Contains(got, `"count":5`) {
t.Errorf("count = %s, want the summed 5", got)
}
}
// A plain extract projects columns and skips the aggregate path, so the stamp
// must not add a key the client did not ask for.
func TestHandler_NodesProjectionNotStamped(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.bodies[nodesPath] = `[{"certname":"h1"}]`
b.bodies[nodesPath] = `[]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), nodesPath, `["extract",["certname"]]`)
if strings.Contains(rec.Body.String(), defaultSourceFact) {
t.Errorf("projection gained a stamp: %s", rec.Body.String())
}
}
// A query naming a specific fact asked for that fact only.
func TestHandler_SourceNotInjectedWhenNameFiltered(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
for _, q := range []string{
`["=","name","role"]`,
`["and",["=","certname","h1"],["=","name","role"]]`,
`["=","name","` + defaultSourceFact + `"]`,
} {
rec := doGet(t, srv.Handler(), factsPath, q)
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("query %s gained %d synthetic records: %s", q, n, rec.Body.String())
}
}
}
// A certname filter selects nodes, not facts, so the full fact set — synthetic
// record included — is still the right answer.
func TestHandler_SourceInjectedWhenOnlyCertnameFiltered(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`,
`[`+fact("h1", "role", "web", "")+`]`)
b := newFakeBackend(t, `[]`, `[]`)
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGet(t, srv.Handler(), factsPath, `["=","certname","h1"]`)
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 1 {
t.Errorf("expected 1 synthetic record, got %d: %s", n, rec.Body.String())
}
}
func TestInjectable(t *testing.T) {
tests := []struct {
name string
query string
factEntity bool
want bool
}{
{"empty query", "", true, true},
{"certname filter", `["=","certname","h1"]`, true, true},
{"regex certname filter", `["~","certname","^web"]`, true, true},
{"name filter", `["=","name","os"]`, true, false},
{"name regex filter", `["~","name","^net"]`, true, false},
{"name under and", `["and",["=","certname","h1"],["=","name","os"]]`, true, false},
{"name under or", `["or",["=","name","os"],["=","name","kernel"]]`, true, false},
{"name under not", `["not",["=","name","os"]]`, true, false},
{"name in list", `["in","name",["array",["os"]]]`, true, false},
// A select_facts subquery narrows which nodes match; the outer response is
// still whole fact sets, so the synthetic record belongs in it.
{"name only inside subquery", `["in","certname",["extract",["certname"],["select_facts",["=","name","os"]]]]`, true, true},
{"top-level extract", `["extract",["certname","value"],["=","certname","h1"]]`, true, false},
{"aggregate extract", `["extract",[["function","count"]]]`, true, false},
// openvoxdb hands each boolean operand back to user-node->plan-node, which
// builds an extract node from it, so a nested extract can reshape the rows.
{"extract under and", `["and",["=","certname","h1"],["extract",["certname"]]]`, true, false},
{"extract under or", `["or",["extract",["certname"]],["=","certname","h1"]]`, true, false},
{"extract under not", `["not",["extract",["certname"]]]`, true, false},
{"extract nested two deep", `["and",["or",["extract",[["function","count"]]]]]`, true, false},
{"extract under from", `["from","facts",["extract",["certname"]]]`, true, false},
{"nested extract on nodes", `["and",["extract",["certname"]]]`, false, false},
// An extract inside an `in` operand projects the subquery, not the response.
{"extract under in stays injectable", `["in","certname",["extract",["certname"],["select_facts",["=","name","os"]]]]`, true, true},
// `subquery` is rewritten to ["in" ... ["extract" ... ["select_x" ...]]]
// before any plan node is built, so its operand is subquery-scoped too.
{"subquery operand stays injectable", `["and",["=","certname","h1"],["subquery","facts",["extract",["certname"],["=","name","os"]]]]`, true, true},
{"bare subquery stays injectable", `["subquery","facts",["extract",["certname"]]]`, true, true},
{"extract under select_facts stays injectable", `["and",["select_facts",["extract",["certname"]]]]`, true, true},
{"nodes name filter is not a fact filter", `["=","name","os"]`, false, true},
{"nodes extract", `["extract",["certname"]]`, false, false},
{"unparseable query", `not json`, true, false},
{"non-array query", `{"a":1}`, true, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := injectable(tc.query, tc.factEntity); got != tc.want {
t.Errorf("injectable(%s, %v) = %v, want %v", tc.query, tc.factEntity, got, tc.want)
}
})
}
}
// pypuppetdb reads certname/name/value/environment by direct index, so a
// missing key is a KeyError there — all four are always present.
func TestSourceInjector_FactRecordHasEveryFactKey(t *testing.T) {
si := &sourceInjector{name: defaultSourceFact, inject: true}
for _, env := range []string{"production", ""} {
var obj map[string]json.RawMessage
if err := json.Unmarshal(si.factRecord("h1", "a", env), &obj); err != nil {
t.Fatal(err)
}
for _, k := range []string{"certname", "name", "value", "environment"} {
if _, ok := obj[k]; !ok {
t.Errorf("environment=%q: key %q missing from synthetic fact", env, k)
}
}
if len(obj) != 4 {
t.Errorf("synthetic fact has %d keys, want the 4 of a real fact record: %v", len(obj), obj)
}
}
}
// A nil injector is the disabled path and must leave every input untouched.
func TestSourceInjector_NilIsInert(t *testing.T) {
var si *sourceInjector
if si.claims(defaultSourceFact) {
t.Error("nil injector claims a fact name")
}
if si.injects() {
t.Error("nil injector injects")
}
si.logSuppressed(log.New(io.Discard, "", 0))
if si.factRecord("h1", "a", "production") != nil {
t.Error("nil injector produced a record")
}
raw := json.RawMessage(`{"certname":"h1"}`)
if got := si.stamp(raw, "a"); string(got) != string(raw) {
t.Errorf("nil injector rewrote %s to %s", raw, got)
}
}
// A response element that is not a JSON object cannot be stamped, and must be
// passed through rather than dropped or mangled.
func TestSourceInjector_StampNonObject(t *testing.T) {
si := &sourceInjector{name: defaultSourceFact, inject: true}
for _, raw := range []string{`"scalar"`, `[1,2]`, `null`} {
if got := si.stamp(json.RawMessage(raw), "a"); string(got) != raw {
t.Errorf("stamp(%s) = %s, want unchanged", raw, got)
}
}
}
// A gated injector suppresses without synthesising anything, on either endpoint.
func TestSourceInjector_GatedSuppressesButDoesNotInject(t *testing.T) {
si := &sourceInjector{name: defaultSourceFact}
if !si.claims(defaultSourceFact) {
t.Error("gated injector does not claim its own fact name")
}
if si.factRecord("h1", "a", "production") != nil {
t.Error("gated injector produced a record")
}
raw := json.RawMessage(`{"certname":"h1"}`)
if got := si.stamp(raw, "a"); string(got) != string(raw) {
t.Errorf("gated injector rewrote %s to %s", raw, got)
}
}
// The disabled path must return the backends' records verbatim, source fact
// included.
func TestMergeFacts_DisabledIsUntouched(t *testing.T) {
a := recs(t, "a", fact("h1", "role", "web-a", ""), fact("h1", defaultSourceFact, "upstream", ""))
merged := mergeFacts([]backendResult{a}, nil, nil)
got := factValues(t, merged)
want := []string{"h1:role=web-a", "h1:" + defaultSourceFact + "=upstream"}
if !slices.Equal(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}
// A gated query still gets the upstream record removed, and nothing added.
func TestMergeFacts_GatedSuppressesUpstream(t *testing.T) {
a := recs(t, "a", fact("h1", "role", "web-a", ""), fact("h1", defaultSourceFact, "upstream", ""))
si := &sourceInjector{name: defaultSourceFact}
merged := mergeFacts([]backendResult{a}, nil, si)
got := factValues(t, merged)
if !slices.Equal(got, []string{"h1:role=web-a"}) {
t.Errorf("merged = %v, want only the real fact", got)
}
if si.suppressed != 1 {
t.Errorf("suppressed = %d, want 1", si.suppressed)
}
}
func TestMergeFacts_SourceOrderedAfterOwnersFacts(t *testing.T) {
a := recs(t, "a", fact("h1", "role", "web-a", ""), fact("h1", "kernel", "Linux", ""))
b := recs(t, "b", fact("h1", "role", "web-b", ""))
merged := mergeFacts([]backendResult{a, b}, nil, &sourceInjector{name: defaultSourceFact, inject: true})
got := factValues(t, merged)
want := []string{"h1:role=web-a", "h1:kernel=Linux", "h1:" + defaultSourceFact + "=a"}
if !slices.Equal(got, want) {
t.Errorf("merged = %v, want %v", got, want)
}
}