package web // v0.63.0 system-initiated immediacy — the agent-plane pokes wired into the pbsdr mutation sites. // These assert the EFFECT (the resolved WG /32 received a nudge), synchronize on a channel with a // bounded timeout (never sleep-poll), and carry explicit zero-count negatives on the error/blocked // paths. The notifier is a REAL poke.Notifier built over the test store + a channel-carrying fake // sender (structural satisfaction of poke's unexported sender seam — the value is passed, the type // is never named), exactly as main.go builds it. import ( "context" "errors" "io" "log" "net/http/httptest" "net/url" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/poke" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync" ) func quietWebLogger() *log.Logger { return log.New(io.Discard, "", 0) } // chanSender is a fake poke sender: it records every target and, for async assertions, publishes // each one on a buffered channel so a test can select-with-timeout instead of sleeping. type chanSender struct { mu sync.Mutex targets []string ch chan string err error } func newChanSender() *chanSender { return &chanSender{ch: make(chan string, 8)} } func (f *chanSender) Poke(_ context.Context, boxWGIP string) error { f.mu.Lock() f.targets = append(f.targets, boxWGIP) f.mu.Unlock() select { case f.ch <- boxWGIP: default: } return f.err } func (f *chanSender) count() int { f.mu.Lock() defer f.mu.Unlock() return len(f.targets) } // wirePoke builds a real notifier over the test store + fake sender and installs it on the server. func wirePoke(s *Server, st *store.Store, snd *chanSender) { s.SetPoke(poke.NewNotifier(st, snd, quietWebLogger())) } // expectPoke asserts exactly one poke to wantIP arrives within the bound, and none trails it. func expectPoke(t *testing.T, snd *chanSender, wantIP string) { t.Helper() select { case got := <-snd.ch: if got != wantIP { t.Fatalf("poke target = %q, want %q", got, wantIP) } case <-time.After(2 * time.Second): t.Fatalf("no poke within 2s (want one to %s)", wantIP) } // No second poke should follow (a short settle window; the send is buffered so a stray fires fast). select { case extra := <-snd.ch: t.Fatalf("a second, unexpected poke fired to %q", extra) case <-time.After(150 * time.Millisecond): } } // expectNoPoke asserts no poke arrives within a bounded settle window (the negative-path contract). func expectNoPoke(t *testing.T, snd *chanSender) { t.Helper() select { case got := <-snd.ch: t.Fatalf("a poke fired to %q on a path that must not poke", got) case <-time.After(300 * time.Millisecond): } if n := snd.count(); n != 0 { t.Fatalf("sender received %d pokes, want 0", n) } } func peerIP(t *testing.T, st *store.Store, hostID string) string { t.Helper() p, err := st.GetWGPeerForHost(hostID) if err != nil || p == nil { t.Fatalf("peer for %s: %v", hostID, err) } return p.AssignedIP } // Scenario A — the observed slice-C lag: PBSDRAutoProvision pokes the box after a hands-free // provision. Red-proof: remove the 1.1 insertion → expectPoke times out. func TestPBSDR_AutoProvisionPokes(t *testing.T) { fake := &fakeTenancy{secret: "AUTO-SECRET"} s, st, _ := newPBSDRServer(t, fake) if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil { t.Fatalf("remove seed peer: %v", err) } // Flag ON while the peer is missing → stored intent, no provision (and so no poke path). postUpdate(t, s, url.Values{"dr_tier": {"on"}}) // The agent registers its WG key; the hook fires the atom → provision succeeds → poke. if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil { t.Fatalf("register peer: %v", err) } snd := newChanSender() wirePoke(s, st, snd) wantIP := peerIP(t, st, "peti-01") s.PBSDRAutoProvision(context.Background(), "peti") expectPoke(t, snd, wantIP) } // Scenario A (negative) — a blocked precondition (no WG peer) mints nothing, so NO poke fires. func TestPBSDR_AutoProvisionBlockedDoesNotPoke(t *testing.T) { fake := &fakeTenancy{secret: "S"} s, st, _ := newPBSDRServer(t, fake) if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil { t.Fatalf("remove seed peer: %v", err) } postUpdate(t, s, url.Values{"dr_tier": {"on"}}) snd := newChanSender() wirePoke(s, st, snd) s.PBSDRAutoProvision(context.Background(), "peti") // peer still absent → blocked, no mint expectNoPoke(t, snd) } // Scenario B1 — ReissuePBSDR (the reconciler-escalation core) pokes after the descriptor bump. // Red-proof: remove the 1.2 insertion → expectPoke times out. func TestPBSDR_ReissueCorePokes(t *testing.T) { fake := &fakeTenancy{secret: "OLD"} s, st, _ := newPBSDRServer(t, fake) // poke NOT wired yet → the provision below fires none postUpdate(t, s, url.Values{"dr_tier": {"on"}}) st.ConsumeHostPBSSecret("peti-01") snd := newChanSender() wirePoke(s, st, snd) wantIP := peerIP(t, st, "peti-01") fake.secret = "FRESH" if err := s.ReissuePBSDR(context.Background(), "peti"); err != nil { t.Fatalf("ReissuePBSDR: %v", err) } expectPoke(t, snd, wantIP) } // Scenario B (negative) — a reissue that FAILS (tenantsync error) never reaches SetHostDesired, // so NO poke fires (fire-after-commit). func TestPBSDR_ReissueCoreErrorDoesNotPoke(t *testing.T) { fake := &fakeTenancy{secret: "OLD"} s, st, _ := newPBSDRServer(t, fake) postUpdate(t, s, url.Values{"dr_tier": {"on"}}) snd := newChanSender() wirePoke(s, st, snd) fake.err = errors.New("ssh boom") if err := s.ReissuePBSDR(context.Background(), "peti"); err == nil { t.Fatal("ReissuePBSDR returned nil on a tenantsync error") } expectNoPoke(t, snd) } // Scenario B2 — the operator button (handlePBSDRReissue) pokes after the descriptor bump. func TestPBSDR_HandleReissuePokes(t *testing.T) { fake := &fakeTenancy{secret: "OLD"} s, st, _ := newPBSDRServer(t, fake) postUpdate(t, s, url.Values{"dr_tier": {"on"}}) st.ConsumeHostPBSSecret("peti-01") snd := newChanSender() wirePoke(s, st, snd) wantIP := peerIP(t, st, "peti-01") fake.secret = "FRESH" req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil) rr := httptest.NewRecorder() s.handlePBSDRReissue(rr, req, "peti") if rr.Code != 303 { t.Fatalf("reissue = %d (%s), want 303", rr.Code, rr.Body.String()) } expectPoke(t, snd, wantIP) } var _ = tenantsync.ErrTokenExists // keep the tenantsync import stable across edits