package store import ( "io" "log" "path/filepath" "strings" "testing" "time" ) func newBundleStore(t *testing.T) *Store { t.Helper() st, err := New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) return st } // Round-trip: request → pending → save → stored + request CLEARED (consume-once). func TestLogBundle_RoundTripConsumeOnce(t *testing.T) { st := newBundleStore(t) if err := st.RequestLogBundle("host-1", LogBundleComponentAgent); err != nil { t.Fatal(err) } if pending, _ := st.PendingLogBundleRequest("host-1", LogBundleComponentAgent); !pending { t.Fatal("request not pending after RequestLogBundle") } blocked, err := st.SaveLogBundle("host-1", LogBundleComponentAgent, time.Now().UTC(), []string{"2026-07-11T10:00:00Z [INFO] netmount: ensured network mount name=nas"}) if err != nil || blocked { t.Fatalf("save: blocked=%v err=%v", blocked, err) } if pending, _ := st.PendingLogBundleRequest("host-1", LogBundleComponentAgent); pending { t.Error("request survived fulfillment — consume-once broken") } bundles, err := st.GetLogBundles("host-1") if err != nil || len(bundles) != 1 || bundles[0].Blocked { t.Fatalf("bundles = %+v err=%v, want 1 clean bundle", bundles, err) } meta, lines, err := st.GetLogBundleContent(bundles[0].ID, "host-1") if err != nil || meta == nil || len(lines) != 1 || !strings.Contains(lines[0], "ensured network mount") { t.Fatalf("content = %+v / %v / %v", meta, lines, err) } // Scoping: another scope cannot read it. if m, _, _ := st.GetLogBundleContent(bundles[0].ID, "other-host"); m != nil { t.Error("cross-scope bundle read must return nothing") } } // S4 secret gate: a planted Resend-shaped token → the bundle is BLOCKED, its content // is NOT stored (nil lines, no gz), and the request is still cleared. Companion // red-proof: empty the secretPatterns table → blocked=false + content stored → FAIL. func TestLogBundle_SecretGateFailClosed(t *testing.T) { st := newBundleStore(t) if err := st.RequestLogBundle("cust-a", LogBundleComponentController); err != nil { t.Fatal(err) } blocked, err := st.SaveLogBundle("cust-a", LogBundleComponentController, time.Now().UTC(), []string{ "[INFO] all fine", "[DEBUG] oops leaked key re_XZq81hd7wJq2M9Yv in a log line", }) if err != nil { t.Fatal(err) } if !blocked { t.Fatal("secret-shaped token not blocked — the gate is the last belt, it must fire") } bundles, _ := st.GetLogBundles("cust-a") if len(bundles) != 1 || !bundles[0].Blocked || bundles[0].BlockedReason == "" { t.Fatalf("blocked flag row missing/incomplete: %+v", bundles) } meta, lines, err := st.GetLogBundleContent(bundles[0].ID, "cust-a") if err != nil || meta == nil || !meta.Blocked { t.Fatalf("blocked meta = %+v err=%v", meta, err) } if lines != nil { t.Fatalf("blocked bundle served content — nothing may be stored: %v", lines) } // The request is still consumed (re-pulling the same ring would block again). if pending, _ := st.PendingLogBundleRequest("cust-a", LogBundleComponentController); pending { t.Error("blocked save left the request pending") } } // The gate must NOT fire on deliberately-redacted values or public checksums. func TestLogBundle_GateAllowsRedactedAndHashes(t *testing.T) { st := newBundleStore(t) blocked, err := st.SaveLogBundle("cust-a", LogBundleComponentController, time.Now().UTC(), []string{ "[INFO] env applied password=[REDACTED]", "[INFO] artifact sha256=9828c5f7aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaf50b", "[DEBUG] leaf fingerprint_sha256=ab:cd:ef", }) if err != nil { t.Fatal(err) } if blocked { t.Fatal("gate false-positive on redacted/checksum content — legitimate diagnostics would never arrive") } } // S5 TTL: past 72 h → purged; before → served. Injectable clock. func TestLogBundle_TTLPurge(t *testing.T) { st := newBundleStore(t) if _, err := st.SaveLogBundle("host-1", LogBundleComponentAgent, time.Now().UTC(), []string{"line"}); err != nil { t.Fatal(err) } // Before the TTL: kept. if n, err := st.PurgeExpiredLogBundles(time.Now().Add(71 * time.Hour)); err != nil || n != 0 { t.Fatalf("purge at 71h dropped %d (err=%v), want 0", n, err) } if b, _ := st.GetLogBundles("host-1"); len(b) != 1 { t.Fatal("bundle gone before the TTL") } // Past the TTL: dropped. if n, err := st.PurgeExpiredLogBundles(time.Now().Add(73 * time.Hour)); err != nil || n != 1 { t.Fatalf("purge at 73h dropped %d (err=%v), want 1", n, err) } if b, _ := st.GetLogBundles("host-1"); len(b) != 0 { t.Fatal("expired bundle still served") } } // Retention: only the newest 3 bundles per (scope, component) are kept. func TestLogBundle_KeepNewest(t *testing.T) { st := newBundleStore(t) for i := 0; i < 5; i++ { if _, err := st.SaveLogBundle("host-1", LogBundleComponentAgent, time.Now().UTC(), []string{"line"}); err != nil { t.Fatal(err) } } b, _ := st.GetLogBundles("host-1") if len(b) != 3 { t.Fatalf("kept %d bundles, want 3 (newest)", len(b)) } } // Oversize input keeps the NEWEST lines within the raw budget (belt over wire caps). func TestCapBundleLines_KeepsNewest(t *testing.T) { lines := []string{"old " + strings.Repeat("x", 100), "new " + strings.Repeat("y", 100)} capped := capBundleLines(lines, 110) if len(capped) != 1 || !strings.HasPrefix(capped[0], "new") { t.Fatalf("capped = %v, want only the newest line", capped) } }