package hub import ( "context" "encoding/json" "io" "net/http" "testing" "time" ) // R-1384 (D79): the hub tells a wrong box clock by the time stamped at the moment of SENDING. A report whose collect // started long ago (an old reported_at) must still leave with a fresh sent_at, and a second send of the SAME report // struct must carry a new one — or a report that waited on the box reads as a clock that is behind. // COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-agent-sent-at.txt). func TestReport_StampsSentAtAtSending(t *testing.T) { var bodies []map[string]any c := testClient(func(r *http.Request) (*http.Response, error) { raw, _ := io.ReadAll(r.Body) var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { t.Fatalf("body: %v", err) } bodies = append(bodies, m) return httpResp(200, `{"status":"ok"}`), nil }) old := time.Now().Add(-40 * time.Minute).UTC() rep := &HostReport{HostID: "h", ReportedAt: old.Format(time.RFC3339), SentAt: old.Format(time.RFC3339Nano)} for i := 0; i < 2; i++ { if _, err := c.Report(context.Background(), rep); err != nil { t.Fatalf("Report: %v", err) } time.Sleep(5 * time.Millisecond) } var sent []time.Time for i, m := range bodies { s, _ := m["sent_at"].(string) at, err := time.Parse(time.RFC3339Nano, s) if err != nil { t.Fatalf("send %d: sent_at = %q, want an RFC3339 time", i, s) } if d := time.Since(at); d < 0 || d > 5*time.Second { t.Errorf("send %d: sent_at is %s old, want the moment of sending (reported_at was 40 minutes old)", i, d) } if m["reported_at"] != old.Format(time.RFC3339) { t.Errorf("send %d: reported_at changed to %v — the collect time is not the send time's to touch", i, m["reported_at"]) } sent = append(sent, at) } if len(sent) == 2 && !sent[1].After(sent[0]) { t.Errorf("the second send carries %s, not after the first %s — the stamp is not taken per send", sent[1], sent[0]) } } // R-1383 (D78): the clearing call says how the job ended, in the DELETE's query; a plain CompleteJob sends none (an // older hub, and the „nothing to say” case, see the same request as before). func TestCompleteJob_OutcomeRidesTheQuery(t *testing.T) { var urls []string c := testClient(func(r *http.Request) (*http.Response, error) { if r.Method != http.MethodDelete { t.Errorf("method = %s", r.Method) } urls = append(urls, r.URL.Path+"?"+r.URL.RawQuery) return httpResp(200, `{"status":"ok"}`), nil }) if err := c.CompleteJob(context.Background(), "j1"); err != nil { t.Fatal(err) } if err := c.CompleteJobWithOutcome(context.Background(), "j2", JobOutcome{Outcome: "rejected", Reason: "expired", Op: "agent_update"}); err != nil { t.Fatal(err) } want := []string{"/api/v1/hosts/demo-host-01/jobs/j1?", "/api/v1/hosts/demo-host-01/jobs/j2?op=agent_update&outcome=rejected&reason=expired"} if len(urls) != 2 || urls[0] != want[0] || urls[1] != want[1] { t.Fatalf("requests = %q, want %q", urls, want) } }