package report import ( "bytes" "encoding/json" "log" "strings" "testing" "time" ) func resetSelfTail() { SetPendingControllerLog(false) SetControllerLogSource(nil) } // S2 (controller half): the ACK's controller_log_requested arms the pull; the next // build attaches the ring tail; the build after that attaches nothing (consume-once). // Companion red-proof: remove the `selfTailPending = false` drain → the second build // ships again → the nil assertion fails. func TestControllerLogTail_ConsumeOnce(t *testing.T) { t.Cleanup(resetSelfTail) SetControllerLogSource(func(maxBytes int) []string { return []string{"l1", "l2", "l3"} }) SetPendingControllerLog(true) var logbuf bytes.Buffer logger := log.New(&logbuf, "", 0) first := buildControllerLogTail(logger) if first == nil || len(first.Lines) != 3 || first.CollectedAt.IsZero() { t.Fatalf("first build tail = %+v, want the 3 ring lines + collected_at", first) } // Customer transparency: the pull is announced in the box's own log. if !strings.Contains(logbuf.String(), "operator log pull served") { t.Errorf("transparency INFO line missing: %q", logbuf.String()) } if second := buildControllerLogTail(logger); second != nil { t.Errorf("second build shipped again — consume-once broken: %+v", second) } } // The hub is the source of truth: an ACK without the flag clears a stale pending. func TestControllerLogTail_AckClears(t *testing.T) { t.Cleanup(resetSelfTail) SetControllerLogSource(func(int) []string { return []string{"x"} }) SetPendingControllerLog(true) SetPendingControllerLog(false) if tail := buildControllerLogTail(nil); tail != nil { t.Errorf("cleared pending still shipped: %+v", tail) } } // No source wired (defensive) → pending is drained, nothing shipped, no panic. func TestControllerLogTail_NoSource(t *testing.T) { t.Cleanup(resetSelfTail) SetPendingControllerLog(true) if tail := buildControllerLogTail(nil); tail != nil { t.Errorf("tail shipped with no source: %+v", tail) } } // S3 app-tail byte-compatibility: the v0.111.0 log_tails wire shape is unchanged by // the additive controller_log_tail — absent in the steady state (omitempty), and the // existing keys marshal identically. Companion red-proof: drop `omitempty` from // ControllerLogTail → the steady-state JSON gains a null key → FAIL. func TestReportSchema_AppTailUnchangedAndSelfTailAdditive(t *testing.T) { r := &Report{ Version: 1, CustomerID: "c1", LogTails: []LogTail{{ App: "gokapi", CollectedAt: time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC), Lines: []string{"a", "b"}, }}, } raw, err := json.Marshal(r) if err != nil { t.Fatal(err) } var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } if _, present := m["controller_log_tail"]; present { t.Errorf("controller_log_tail present in the steady state — must be omitted (additive)") } var tails []map[string]json.RawMessage if err := json.Unmarshal(m["log_tails"], &tails); err != nil || len(tails) != 1 { t.Fatalf("log_tails shape changed: %s", m["log_tails"]) } for _, key := range []string{"app", "collected_at", "lines"} { if _, ok := tails[0][key]; !ok { t.Errorf("log_tails entry lost key %q — app-tail flow must stay byte-compatible", key) } } // The ACK parse: old fields + the new flag coexist; absent flag = false. var pr PushResponse if err := json.Unmarshal([]byte(`{"status":"ok","log_tail_requests":["gokapi"],"controller_log_requested":true}`), &pr); err != nil { t.Fatal(err) } if len(pr.LogTailRequests) != 1 || !pr.ControllerLogRequested { t.Errorf("ACK parse = %+v", pr) } var old PushResponse if err := json.Unmarshal([]byte(`{"status":"ok"}`), &old); err != nil || old.ControllerLogRequested { t.Errorf("old ACK must parse with the flag false: %+v err=%v", old, err) } }