e35b1ae0e6
log_bundle_requests + log_bundles store (gzip, newest-3, 72h TTL purged on the 60s sweep); SaveLogBundle secret gate fail-closed (blocked flag row, no payload; REDACTED/checksums pass). Report ACK gains controller_log_requested + ingests controller_log_tail; heartbeat envelope gains log_tail_requested + ingests log_tail (consume-once on arrival; pre-0.83 agents stay visibly pending). Host detail Diagnostics section: request buttons (controller/agent), state rows with honest latency hints, View/Download endpoint. Red-proofs: gate disabled and clear-on-arrival removed both FAIL their tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
148 lines
5.4 KiB
Go
148 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|