Files
felhom.eu/hub/internal/api/wait_test.go
T
admin d604e624a3 hub: box presence from the wait channel + delete a switched-off box at once (R-30, D2)
Slice 1: intent.Hub records one start/end per GET /api/v1/wait request and answers
Presence(customer, now): connected (hold open, or one started < 333 s = 243 s cadence + 90 s
grace ago), not connected since T, or unknown (hub up < 333 s; in memory only). The host page
shows "Box connection". One DEBUG line per presence change.

Slice 2 (operator ruling D2, 2026-10-08, 09 §3 decision 186): a host that is online by its report
clock but whose box has had no wait-channel connection for >= 360 s may be deleted at once after
the tick "I checked: the box is off". Presence is re-read at POST time; the tick alone, unknown
presence, a connected box or a shorter gap keep today's 409. The delete logs the operator channel
and saves one host_deleted_box_off event. RESET and the customer-delete cascade are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:17:05 +02:00

211 lines
7.9 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// shrinkWaitTiming shrinks the hold/heartbeat for the duration of a test (restored on cleanup) so
// long-poll tests run in milliseconds instead of the 240 s production hold.
func shrinkWaitTiming(t *testing.T, maxHold, heartbeat time.Duration) {
t.Helper()
om, oh := waitMaxHold, waitHeartbeat
waitMaxHold, waitHeartbeat = maxHold, heartbeat
t.Cleanup(func() { waitMaxHold, waitHeartbeat = om, oh })
}
// waitWith wires an intent hub onto a test handler and returns both.
func newWaitHandler(t *testing.T) (*Handler, *store.Store, *intent.Hub) {
t.Helper()
h, st, _ := newTestHandler(t)
hub := intent.New()
h.SetIntentHub(hub)
return h, st, hub
}
// doWaitAsync runs GET /wait?gen=<gen> with the given bearer in a goroutine and returns a channel
// yielding the recorder once the handler returns.
func doWaitAsync(h *Handler, bearer string, gen string) <-chan *httptest.ResponseRecorder {
out := make(chan *httptest.ResponseRecorder, 1)
go func() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/wait?gen="+gen, nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
out <- rr
}()
return out
}
func recvRR(t *testing.T, ch <-chan *httptest.ResponseRecorder, d time.Duration) *httptest.ResponseRecorder {
t.Helper()
select {
case rr := <-ch:
return rr
case <-time.After(d):
t.Fatalf("wait handler did not return within %s", d)
return nil
}
}
func TestWait_Unauthorized(t *testing.T) {
h, _, _ := newWaitHandler(t)
shrinkWaitTiming(t, 50*time.Millisecond, 20*time.Millisecond)
rr := do(h, http.MethodGet, "/wait", "", "")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("no bearer: status = %d, want 401", rr.Code)
}
rr = do(h, http.MethodGet, "/wait", "WRONGKEY", "")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("wrong key: status = %d, want 401", rr.Code)
}
}
func TestWait_GlobalKeyRejected(t *testing.T) {
h, _, _ := newWaitHandler(t)
shrinkWaitTiming(t, 50*time.Millisecond, 20*time.Millisecond)
rr := do(h, http.MethodGet, "/wait", globalKey, "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("global key: status = %d, want 400 (wait is per-customer)", rr.Code)
}
}
func TestWait_ServiceUnavailableWithoutHub(t *testing.T) {
h, st, _ := newTestHandler(t) // no SetIntentHub
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "CKEY", ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
shrinkWaitTiming(t, 50*time.Millisecond, 20*time.Millisecond)
rr := do(h, http.MethodGet, "/wait", "CKEY", "")
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("no hub: status = %d, want 503", rr.Code)
}
}
func TestWait_CompletesOnBumpWithNewGen(t *testing.T) {
h, st, hub := newWaitHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "CKEY", ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
shrinkWaitTiming(t, 2*time.Second, 500*time.Millisecond)
ch := doWaitAsync(h, "CKEY", "0")
// Let the handler register, then bump.
time.Sleep(30 * time.Millisecond)
hub.Bump("c")
rr := recvRR(t, ch, time.Second)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := strings.TrimSpace(rr.Body.String()); !strings.HasSuffix(got, `{"gen":1}`) {
t.Fatalf("body = %q, want to end with {\"gen\":1}", got)
}
}
// TestWait_TimesOutWithHeartbeats proves Scenario B: with no bump, the hold completes with the
// unchanged generation, and heartbeat newline bytes were emitted while holding (the bytes that
// keep the nginx read-timeout from firing).
func TestWait_TimesOutWithHeartbeats(t *testing.T) {
h, st, _ := newWaitHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "CKEY", ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
shrinkWaitTiming(t, 120*time.Millisecond, 25*time.Millisecond)
start := time.Now()
rr := do(h, http.MethodGet, "/wait", "CKEY", "")
elapsed := time.Since(start)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if elapsed < 100*time.Millisecond {
t.Fatalf("returned before the hold elapsed (%s) — did it not hold?", elapsed)
}
body := rr.Body.String()
if !strings.Contains(body, "\n") || !strings.HasPrefix(body, "\n") {
t.Fatalf("expected leading heartbeat newline(s); body = %q", body)
}
if !strings.HasSuffix(strings.TrimSpace(body), `{"gen":0}`) {
t.Fatalf("expected final {\"gen\":0} on timeout; body = %q", body)
}
}
// TestWait_RaceCloserReturnsImmediately proves a bump that landed before the wait connected is not
// lost: the box passes its last-seen gen, the hub sees the generation already advanced and returns
// at once.
func TestWait_RaceCloserReturnsImmediately(t *testing.T) {
h, st, hub := newWaitHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "CKEY", ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
shrinkWaitTiming(t, 5*time.Second, 1*time.Second) // long hold; must NOT be hit
hub.Bump("c") // gen -> 1 before the box connects
start := time.Now()
rr := do(h, http.MethodGet, "/wait", "CKEY", "0") // last-seen 0 != 1
if el := time.Since(start); el > 500*time.Millisecond {
t.Fatalf("race-closer should return immediately, took %s", el)
}
if got := strings.TrimSpace(rr.Body.String()); !strings.HasSuffix(got, `{"gen":1}`) {
t.Fatalf("body = %q, want {\"gen\":1}", got)
}
}
// TestWait_CustomerIsolation proves customer A's key can never observe B's generation: A holds a
// wait, B is bumped, A must NOT complete (it times out on its own generation).
func TestWait_CustomerIsolation(t *testing.T) {
h, st, hub := newWaitHandler(t)
for _, id := range []string{"a", "b"} {
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: id, RetrievalPassword: "pw", APIKey: "KEY-" + id, ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig %s: %v", id, err)
}
}
shrinkWaitTiming(t, 150*time.Millisecond, 40*time.Millisecond)
ch := doWaitAsync(h, "KEY-a", "0") // A waits
time.Sleep(30 * time.Millisecond)
hub.Bump("b") // B's intent moves — must not wake A
rr := recvRR(t, ch, time.Second)
if got := strings.TrimSpace(rr.Body.String()); !strings.HasSuffix(got, `{"gen":0}`) {
t.Fatalf("A should time out on its own gen 0 (isolation); body = %q", got)
}
}
// R-30 slice 1: each wait REQUEST marks one start and one end on the intent hub's presence — open
// while held, ended on return. Without the marks the box reads "never seen"; without the end mark it
// would read connected forever.
func TestWait_MarksBoxPresence(t *testing.T) {
h, st, hub := newWaitHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "CKEY", ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
t0 := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
clock := t0.Add(-time.Hour)
hub.SetClock(func() time.Time { return clock }) // born an hour before t0
clock = t0
shrinkWaitTiming(t, 2*time.Second, 500*time.Millisecond)
ch := doWaitAsync(h, "CKEY", "0")
time.Sleep(50 * time.Millisecond)
if p := hub.Presence("c", t0.Add(time.Hour)); p.State != intent.PresenceConnected {
t.Fatalf("while the wait is held: %+v, want connected (open hold)", p)
}
hub.Bump("c")
recvRR(t, ch, time.Second)
p := hub.Presence("c", t0.Add(intent.PresenceConnectedWindow+time.Second))
if p.State != intent.PresenceNotConnected || p.NeverSeen || !p.Since.Equal(t0) {
t.Fatalf("after the wait returned: %+v, want not_connected since t0 (start and end both marked)", p)
}
}