package api import ( "net/http" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-1204: the box's tunnel to ep0 — a future-stamped handshake (the clock ran ahead) or no handshake for 6 h raises // wg_tunnel_stale ONCE (edge-triggered from the host's newest tunnel event), and wg_tunnel_ok once when healthy; a // healthy tunnel, an inactive one and an older agent are not judged. // RED-PROOF: with checkWGTunnel not called from the host-report handler the first count fails (0 events); with // wgTunnelStale ignoring handshake_future_s the clock case fails — seen failing 2026-10-22. func tunnelReportBody(hostID, wg string) string { b := validReportBody(hostID) i := strings.LastIndex(b, "}") return b[:i] + `,"wireguard":` + wg + b[i:] } func TestR1204_TheClockAheadTunnelAlarmsOnceAndClears(t *testing.T) { h, st, _ := newTestHandler(t) st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) post := func(wg string) { t.Helper() if rr := do(h, http.MethodPost, "/host-report", "HKEY", tunnelReportBody("h1", wg)); rr.Code != http.StatusOK { t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String()) } } count := func(typ string) int { evs, _ := st.GetEventsByType("c1", typ, time.Time{}) return len(evs) } post(`{"registered":true,"active":true,"last_handshake_age_s":60}`) // healthy post(`{"registered":true,"active":false}`) // service down: not judged here if count(eventWGTunnelStale) != 0 { t.Fatal("a healthy or inactive tunnel must not alarm") } post(`{"registered":true,"active":true,"last_handshake_age_s":0,"handshake_future_s":63072000}`) // ran 2 years ahead post(`{"registered":true,"active":true,"last_handshake_age_s":0,"handshake_future_s":63071100}`) if n := count(eventWGTunnelStale); n != 1 { t.Fatalf("wg_tunnel_stale = %d, want exactly 1 (edge-triggered)", n) } evs, _ := st.GetEventsByType("c1", eventWGTunnelStale, time.Time{}) if !strings.Contains(evs[0].Message, "Host h1:") || !strings.Contains(evs[0].Message, "FUTURE") { t.Fatalf("the alarm must name the host and the clock cause: %q", evs[0].Message) } post(`{"registered":true,"active":true,"last_handshake_age_s":45}`) post(`{"registered":true,"active":true,"last_handshake_age_s":50}`) if count(eventWGTunnelOK) != 1 { t.Fatalf("wg_tunnel_ok = %d, want 1", count(eventWGTunnelOK)) } post(`{"registered":true,"active":true,"last_handshake_age_s":30000}`) // 8 h 20 min with no handshake if count(eventWGTunnelStale) != 2 { t.Fatal("six hours without a handshake while reporting must alarm again") } } func TestR1204_WhatIsJudged(t *testing.T) { i := func(v int64) *int64 { return &v } for _, c := range []struct { w *hostWireguard want bool }{ {nil, false}, {&hostWireguard{Registered: true, Active: true, LastHandshakeAgeS: i(120)}, false}, {&hostWireguard{Registered: true, Active: true, LastHandshakeAgeS: i(6*3600 + 1)}, true}, {&hostWireguard{Registered: true, Active: true, LastHandshakeAgeS: i(0), HandshakeFutureS: i(3600)}, true}, {&hostWireguard{Registered: false, Active: true, LastHandshakeAgeS: i(99999)}, false}, } { if got, _ := wgTunnelStale(c.w); got != c.want { t.Errorf("%+v: stale=%t, want %t", c.w, got, c.want) } } } // D30 (R-1204): after the agent re-keys, the box reports a restarted tunnel with no handshake yet (nothing to judge), // then the new key's first handshake — which clears the alarm once, and raises nothing new. // RED-PROOF (2026-10-22 night): with the `!stale && wasStale` case removed from checkWGTunnel, this fails (no ok event). func TestD30_TheAlarmClearsWhenTheNewKeyShakesHands(t *testing.T) { h, st, _ := newTestHandler(t) st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) post := func(wg string) { t.Helper() if rr := do(h, http.MethodPost, "/host-report", "HKEY", tunnelReportBody("h1", wg)); rr.Code != http.StatusOK { t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String()) } } count := func(typ string) int { evs, _ := st.GetEventsByType("c1", typ, time.Time{}) return len(evs) } post(`{"pubkey":"OLD","registered":true,"active":true,"last_handshake_age_s":0,"handshake_future_s":10800}`) // 3 h ahead post(`{"pubkey":"NEW","registered":true,"active":true}`) // re-keyed, no handshake yet if count(eventWGTunnelStale) != 1 || count(eventWGTunnelOK) != 0 { t.Fatalf("stale %d ok %d, want 1 / 0 before the new key shakes hands", count(eventWGTunnelStale), count(eventWGTunnelOK)) } post(`{"pubkey":"NEW","registered":true,"active":true,"last_handshake_age_s":20}`) post(`{"pubkey":"NEW","registered":true,"active":true,"last_handshake_age_s":110}`) if count(eventWGTunnelOK) != 1 || count(eventWGTunnelStale) != 1 { t.Fatalf("stale %d ok %d, want 1 / 1 — the new key's handshake clears the alarm once", count(eventWGTunnelStale), count(eventWGTunnelOK)) } }