Files
felhom-agent/internal/hub/client_sentat_test.go
T
admin a1135018c7
gates / gates (push) Successful in 42s
agent (unreleased): a refused signed job and a watchdog give-up reach the hub (R-1383, D78)
The job runner clears a job with its outcome, a closed reason code and the op's name. The guest-power watchdog's
give-up rides the host report until the guest runs.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-11-14 17:29:53 +01:00

77 lines
3.0 KiB
Go

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)
}
}