package api import ( "encoding/json" "net/http" "testing" "time" ) var longAgoAPI = time.Now().Add(-365 * 24 * time.Hour) // Part D (API half) — the full pull-based round-trip on /report: // 1. no pending request → the ACK omits log_tail_requests entirely // 2. operator requests a tail → the ACK advertises it // 3. the controller's next report carries log_tails → stored + request cleared // 4. the FOLLOWING report's ACK omits the field again (consume-once) func TestReportACK_LogTailRoundTrip(t *testing.T) { h, st, _ := newTestHandler(t) reportBody := `{"customer_id":"cust-a"}` // 1. baseline ACK — no field rr := do(h, http.MethodPost, "/report", globalKey, reportBody) if rr.Code != http.StatusOK { t.Fatalf("report POST = %d (%s)", rr.Code, rr.Body.String()) } var ack map[string]json.RawMessage json.Unmarshal(rr.Body.Bytes(), &ack) if _, present := ack["log_tail_requests"]; present { t.Fatalf("baseline ACK must omit log_tail_requests: %s", rr.Body.String()) } // 2. operator requests a tail → advertised if err := st.RequestLogTail("cust-a", "gokapi"); err != nil { t.Fatalf("RequestLogTail: %v", err) } rr = do(h, http.MethodPost, "/report", globalKey, reportBody) ack = map[string]json.RawMessage{} json.Unmarshal(rr.Body.Bytes(), &ack) var apps []string json.Unmarshal(ack["log_tail_requests"], &apps) if len(apps) != 1 || apps[0] != "gokapi" { t.Fatalf("ACK log_tail_requests = %v, want [gokapi] (%s)", apps, rr.Body.String()) } // 3. the controller ships the tail on its next report → stored + consumed tailReport := `{"customer_id":"cust-a","log_tails":[{"app":"gokapi","collected_at":"2026-07-10T10:00:00Z","lines":["l1","l2 password=[REDACTED]","l3"]}]}` rr = do(h, http.MethodPost, "/report", globalKey, tailReport) if rr.Code != http.StatusOK { t.Fatalf("tail report POST = %d", rr.Code) } tails, err := st.GetCustomerLogTails("cust-a") if err != nil || len(tails) != 1 { t.Fatalf("stored tails = %d/%v, want 1", len(tails), err) } if len(tails[0].Lines) != 3 || tails[0].Lines[1] != "l2 password=[REDACTED]" { t.Fatalf("tail lines not stored ordered/verbatim: %#v", tails[0].Lines) } // 4. consume-once: the FOLLOWING ACK omits the field rr = do(h, http.MethodPost, "/report", globalKey, reportBody) ack = map[string]json.RawMessage{} json.Unmarshal(rr.Body.Bytes(), &ack) if _, present := ack["log_tail_requests"]; present { t.Fatalf("request survived fulfillment — the controller would ship tails every cycle: %s", rr.Body.String()) } } // A report whose telemetry issues carry context lands it in app_log_issues (the C wire leg). func TestReport_IssueContextStored(t *testing.T) { h, st, _ := newTestHandler(t) body := `{"customer_id":"cust-a","app_telemetry":[{"app_name":"cwa","issues":[ {"severity":"error","message":"ERROR: nfs gone","count":2,"last_seen":"2026-07-10T09:00:00Z", "context":["before","ERROR: nfs gone","after"]}]}]}` rr := do(h, http.MethodPost, "/report", globalKey, body) if rr.Code != http.StatusOK { t.Fatalf("report POST = %d (%s)", rr.Code, rr.Body.String()) } issues, err := st.GetAppIssues("cwa", longAgoAPI, false, 10) if err != nil || len(issues) != 1 { t.Fatalf("issues = %d/%v, want 1", len(issues), err) } is := issues[0] if len(is.Context) != 3 || is.Context[1] != "ERROR: nfs gone" { t.Fatalf("context not stored from the wire: %#v", is.Context) } if is.ContextCustomer != "cust-a" { t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer) } }