package web // Network card (hub v0.85.0) — the host's addresses + its WireGuard allocation. // // The report fixture below is the REAL wire: it is the `addresses` block copied out of // `felhom-agent --selftest=hub` on demo-felhom running agent 0.119.0 on 2026-07-31. Testing against // a hand-written shape would have proved only that the parser matches my own idea of the format. // // Every test drives ServeHTTP, so a route/template gate that never renders is visible here. import ( "net/http" "net/http/httptest" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // liveReportJSON is demo-felhom's real report body, trimmed to the parsed fields. const liveReportJSON = `{ "host": {"cpu_percent": 4.0, "memory_percent": 30.0, "disk_percent": 20.0}, "addresses": [ {"iface": "tailscale0", "cidr": "100.70.170.35/32"}, {"iface": "tailscale0", "cidr": "fd7a:115c:a1e0::5236:aa24/128"}, {"iface": "vmbr0", "cidr": "192.168.0.162/24"}, {"iface": "wg-felhom", "cidr": "10.77.0.2/32"} ] }` // seedNetHost creates a host with a report and (optionally) a WG peer allocation. func seedNetHost(t *testing.T, st *store.Store, hostID, agentVersion, reportJSON, wgIP string) { t.Helper() if err := st.UpsertHost(&store.Host{ HostID: hostID, CustomerID: "c-" + hostID, APIKey: "k-" + hostID, AgentVersion: agentVersion, }); err != nil { t.Fatal(err) } if reportJSON != "" { if err := st.SaveHostReport(hostID, "c-"+hostID, []byte(reportJSON), store.HostReportDenorm{ AgentVersion: agentVersion, }); err != nil { t.Fatal(err) } } if wgIP != "" { if err := st.SetWGEndpoint(&store.WGEndpoint{ EndpointID: "ep0", DNSName: "ep0.example", WGPort: 51820, ServerPubkey: "srv", TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1", }); err != nil { t.Fatal(err) } if _, _, err := t3AddPeer(st, hostID, wgIP); err != nil { t.Fatal(err) } } } // t3AddPeer binds a peer at a SPECIFIC address (the allocator picks the lowest free one, so the // fixture drives it to the address the live box actually holds). func t3AddPeer(st *store.Store, hostID, wantIP string) (string, bool, error) { // Allocate sequentially until the wanted address is the one handed out; the subnet is /24 and // the fixtures ask for .2/.3, so this costs a couple of rows. for i := 0; i < 8; i++ { ip, existed, err := st.AddWGPeer("pk-"+hostID+"-"+string(rune('a'+i)), hostID, "test") if err != nil { return "", false, err } if ip == wantIP { return ip, existed, nil } } return "", false, nil } func getHostPage(t *testing.T, s *Server, cookie *http.Cookie, hostID string) string { t.Helper() req := httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil) req.AddCookie(cookie) rr := httptest.NewRecorder() s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("host page = %d, want 200", rr.Code) } return rr.Body.String() } // --- A: the LAN address reaches the page. This is the whole point of the feature. --- // RED-PROOF A: delete the `"Network": network` key from hostDetailData → red. func TestNetwork_A_LANAddressRendered(t *testing.T) { s, st, _ := newRevealServer(t) cookie, _ := newRevealSession(t, s) seedNetHost(t, st, "demo-felhom-8363b5", "0.119.0", liveReportJSON, "10.77.0.2") body := getHostPage(t, s, cookie, "demo-felhom-8363b5") if !strings.Contains(body, "192.168.0.162/24") { t.Fatal("the LAN address is not on the host page — the feature shows nothing") } if !strings.Contains(body, "vmbr0") { t.Error("the interface carrying the LAN address is not named") } if !strings.Contains(body, "Network") { t.Error("no Network card") } // The tailnet address is a real address and is shown too, labelled by its interface. if !strings.Contains(body, "100.70.170.35/32") || !strings.Contains(body, "tailscale0") { t.Error("the tailnet address was dropped") } } // --- B: WireGuard is TWO facts — the hub's allocation, and whether the box confirms it --- // RED-PROOF B: make hostNetwork set WGConfirmed = true unconditionally → the C case below goes red. func TestNetwork_B_WireGuardAllocatedAndConfirmed(t *testing.T) { s, st, _ := newRevealServer(t) cookie, _ := newRevealSession(t, s) seedNetHost(t, st, "demo-felhom-8363b5", "0.119.0", liveReportJSON, "10.77.0.2") body := getHostPage(t, s, cookie, "demo-felhom-8363b5") if !strings.Contains(body, "10.77.0.2") { t.Fatal("the WireGuard address is not on the page") } if !strings.Contains(body, "confirmed") { t.Error("a box that reports holding its allocated address is not marked confirmed") } // The WG address must NOT also appear as an ordinary address row — one fact, one place. if strings.Contains(body, "wg-felhom") { t.Error("the WireGuard address was repeated in the address table instead of its own row") } } // The drift case that motivates splitting the two facts: the hub allocated a peer the box does not // hold. Rendering the allocation alone would show this as healthy. func TestNetwork_C_AllocatedButBoxDoesNotHoldIt(t *testing.T) { s, st, _ := newRevealServer(t) cookie, _ := newRevealSession(t, s) // The report carries NO 10.77.0.x address — the peer exists only in the hub. report := `{"addresses":[{"iface":"vmbr0","cidr":"192.168.0.50/24"}]}` seedNetHost(t, st, "drifted-host", "0.119.0", report, "10.77.0.2") body := getHostPage(t, s, cookie, "drifted-host") if !strings.Contains(body, "not confirmed by the box") { t.Fatal("an allocated-but-unheld WireGuard peer renders as healthy — the drift is invisible") } if strings.Contains(body, ">confirmed<") { t.Error("the page claims confirmation the box never gave") } } // --- D: an OLD agent means UNKNOWN, not "no addresses" (presence is not result) --- // RED-PROOF D: drop the AgentTooOld branch from hostNetwork → the page renders the generic // "no routable address" line and this goes red. func TestNetwork_D_OldAgentSaysUnknownNotNone(t *testing.T) { s, st, _ := newRevealServer(t) cookie, _ := newRevealSession(t, s) // 0.118.1 predates addresses[] — it sends no such key at all. seedNetHost(t, st, "old-agent-host", "0.118.1", `{"host":{"cpu_percent":1}}`, "10.77.0.2") body := getHostPage(t, s, cookie, "old-agent-host") if !strings.Contains(body, "does not report its addresses") { t.Fatal("an old agent's silence is not explained") } if !strings.Contains(body, "unknown") { t.Error("the page must say UNKNOWN — an absent field and an empty result are different facts") } if strings.Contains(body, "reports no routable address") { t.Fatal("an old agent renders as 'this host has no addresses', which is a false statement") } // The allocation is still shown — the hub knows it regardless of the agent's version. if !strings.Contains(body, "10.77.0.2") { t.Error("the WireGuard allocation vanished just because the agent is old") } } // --- E: a host with no WG peer says so, rather than rendering a blank --- func TestNetwork_E_NoPeerAllocated(t *testing.T) { s, st, _ := newRevealServer(t) cookie, _ := newRevealSession(t, s) seedNetHost(t, st, "no-peer-host", "0.119.0", liveReportJSON, "") body := getHostPage(t, s, cookie, "no-peer-host") if !strings.Contains(body, "no peer allocated") { t.Error("a host without a WireGuard peer does not say so") } // With no allocation the WG address cannot be split out, so it stays an ordinary row — still // visible, never silently dropped. if !strings.Contains(body, "10.77.0.2/32") { t.Error("the box's wg address disappeared when the hub had no allocation to match it against") } } // --- F: never-reported host — the card must not claim anything --- func TestNetwork_F_NoReportYet(t *testing.T) { s, st, _ := newRevealServer(t) cookie, _ := newRevealSession(t, s) seedNetHost(t, st, "fresh-host", "0.119.0", "", "") body := getHostPage(t, s, cookie, "fresh-host") if !strings.Contains(body, "Waiting for the first host report") { t.Error("a never-reported host does not say it is waiting") } if strings.Contains(body, "reports no routable address") { t.Error("a host that never reported is described as having reported no addresses") } } // --- G: the parser, against the real wire --- func TestParseHostAddresses_RealWire(t *testing.T) { got := parseHostAddresses(liveReportJSON) if len(got) != 4 { t.Fatalf("want 4 addresses from the live report, got %d: %+v", len(got), got) } // The bare IP must be split off the CIDR — it is what the WG comparison keys on. var found bool for _, a := range got { if a.Iface == "vmbr0" { found = true if a.CIDR != "192.168.0.162/24" || a.IP != "192.168.0.162" { t.Errorf("vmbr0 parsed wrong: %+v", a) } } } if !found { t.Error("vmbr0 missing from the parse") } // Degradations: neither an empty body nor a malformed one may panic or invent rows. if got := parseHostAddresses(""); len(got) != 0 { t.Errorf("empty body produced %d rows", len(got)) } if got := parseHostAddresses("{not json"); len(got) != 0 { t.Errorf("malformed body produced %d rows", len(got)) } if got := parseHostAddresses(`{"host":{}}`); got == nil { t.Error("a report without addresses returned nil rather than an empty slice") } }