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