Files
felhom-controller/controller/internal/web/network_card_test.go
T
admin 7013a5fd2e R-66: the box's own address becomes visible (v0.159.0)
Leg A: „Hálózat" card on Beállítások → Rendszer — Helyi cím (LAN),
Hálózati név (only while Megosztás is enabled), Átjáró; live per render,
stored nowhere (S-5), „—" on unavailable.
Leg B: network section in the Debug system dump (interfaces/route/DNS/
lan_address), best-effort per item via the samba-netns door.
Leg C: NetBIOS trap named — Szerver field helper text + a purely lexical
hint on unreachable failures for single-label non-IP names.

Design note: all guest-net reads go through docker exec into the
host-networked felhom-samba container (stacks/guestnet.go, one seam) —
the controller's own netns is the docker bridge, so /proc/net/route etc.
would answer 172.x (the S-2 trap). Red-proofs: A2 gate-drop and C2
lexical-invert both failed as required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
2026-07-22 13:49:57 +02:00

190 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-66 Leg A — the „Hálózat" card on Beállítások → Rendszer.
// A1: sharing enabled → the card renders address + network-name + gateway rows with live values.
func TestNetworkCardRendersAllRows(t *testing.T) {
s := testPageServer(t)
if err := s.settings.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
s.sambaAddrFn = func() string { return "192.168.0.104" }
s.guestGatewayFn = func() string { return "192.168.0.1" }
body := getPage(t, s, "/settings").Body.String()
for _, m := range []string{"Hálózat", "Helyi cím (LAN)", "192.168.0.104", "Hálózati név", `\\FELHOM`, "Átjáró", "192.168.0.1"} {
if !strings.Contains(body, m) {
t.Errorf("card missing %q", m)
}
}
// The footer sentence (remote-troubleshooting read-aloud hint) belongs to the card.
if !strings.Contains(body, "helyi hálózattól függenek") {
t.Error("card missing the footer sentence")
}
}
// A2: sharing DISABLED → the Hálózati név row is ABSENT. The WRONG case this pins: rendering
// \\FELHOM while samba is down would promise a name that does not exist on the network.
// Red-proof: drop the smb.Enabled gate in systemPageData → this fails with \\FELHOM present.
func TestNetworkCardNoSMBNameWhenSharingOff(t *testing.T) {
s := testPageServer(t)
if err := s.settings.SetSMBEnabled(false); err != nil {
t.Fatal(err)
}
s.sambaAddrFn = func() string { return "" }
s.guestGatewayFn = func() string { return "" }
body := getPage(t, s, "/settings").Body.String()
if !strings.Contains(body, "Hálózat") {
t.Fatal("card absent entirely")
}
if strings.Contains(body, "Hálózati név") || strings.Contains(body, `\\FELHOM`) {
t.Error("Hálózati név row rendered while Megosztás is disabled — a wrong promise")
}
}
// A3: the address helper returns "" → the row renders „—" + the muted note, no crash, and no
// fallback value appears from anywhere (nothing is stored to fall back TO — S-5).
func TestNetworkCardDashOnUnavailable(t *testing.T) {
s := testPageServer(t)
s.sambaAddrFn = func() string { return "" }
s.guestGatewayFn = func() string { return "" }
rec := getPage(t, s, "/settings")
if rec.Code != 200 {
t.Fatalf("GET /settings = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "nem állapítható meg") {
t.Error("missing the muted unavailable note")
}
// No plausible-but-stale address may surface: the only IPs on the page must be the ones other
// cards legitimately carry — assert the card region itself carries the dash.
cardStart := strings.Index(body, "Helyi cím (LAN)")
if cardStart < 0 {
t.Fatal("card row missing")
}
region := body[cardStart:]
if end := strings.Index(region, "Átjáró"); end > 0 {
region = region[:end]
}
if !strings.Contains(region, "—") {
t.Error("Helyi cím row lacks the em-dash placeholder")
}
}
// R-66 Leg A seam freshness: the card must re-derive per render (never memoize a DHCP lease) —
// the same counted-fn assertion that guards the Megosztás connect card.
func TestNetworkCardFreshPerRender(t *testing.T) {
s := testPageServer(t)
addrCalls, gwCalls := 0, 0
s.sambaAddrFn = func() string { addrCalls++; return "192.168.0.104" }
s.guestGatewayFn = func() string { gwCalls++; return "192.168.0.1" }
getPage(t, s, "/settings")
getPage(t, s, "/settings")
if addrCalls != 2 || gwCalls != 2 {
t.Errorf("stale-value risk: addr resolved %d×, gateway %d× over 2 renders (want 2/2)", addrCalls, gwCalls)
}
}
// R-66 Leg B (web half) — the Debug dump's network section.
// B1: the dump contains `network` with the four sub-keys; a fabricated resolv.conf failure yields
// an error string IN PLACE while the dump stays complete.
func TestDebugDumpNetworkSection(t *testing.T) {
s := testPageServer(t)
s.guestNetFn = func() stacks.GuestNetSnapshot {
return stacks.GuestNetSnapshot{
Interfaces: []stacks.GuestInterface{{Name: "eth0", Up: true, Addresses: []string{"192.168.0.104/24"}}},
Gateway: "192.168.0.1",
RouteInterface: "eth0",
LANAddress: "192.168.0.104",
Errors: map[string]string{"dns": "scripted resolv.conf failure"},
}
}
rec := httptest.NewRecorder()
s.debugDump(rec, httptest.NewRequest("GET", "/api/debug/dump", nil))
if rec.Code != 200 {
t.Fatalf("dump = %d, want 200", rec.Code)
}
var dump map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &dump); err != nil {
t.Fatalf("dump not JSON: %v", err)
}
network, ok := dump["network"].(map[string]interface{})
if !ok {
t.Fatalf("dump lacks a network object: %T", dump["network"])
}
for _, key := range []string{"interfaces", "default_route", "dns_servers", "lan_address"} {
if _, present := network[key]; !present {
t.Errorf("network section missing %q", key)
}
}
// The failed item reports in place…
if e, _ := network["dns_servers"].(string); !strings.Contains(e, "scripted resolv.conf failure") {
t.Errorf("dns_servers = %v, want the in-place error string", network["dns_servers"])
}
// …and the healthy items are real values, not casualties.
if la, _ := network["lan_address"].(string); la != "192.168.0.104" {
t.Errorf("lan_address = %v (must equal the Hálózat card's value)", network["lan_address"])
}
route, _ := network["default_route"].(map[string]interface{})
if route["gateway"] != "192.168.0.1" || route["interface"] != "eth0" {
t.Errorf("default_route = %v", network["default_route"])
}
// The dump as a whole stayed complete (existing sections intact).
for _, key := range []string{"controller", "storage", "stacks"} {
if _, present := dump[key]; !present {
t.Errorf("dump lost its %q section", key)
}
}
}
// R-66 Leg C — the NetBIOS trap named on an unreachable-class add failure.
func TestNetAddMessageNetBIOSHint(t *testing.T) {
const hintMark = "Windows-hálózati névnek tűnik"
// C1: single-label non-IP name + unreachable → hint present, naming the submitted value.
msg := netAddMessage("unreachable", "FELHOM", 1000)
if !strings.Contains(msg, hintMark) || !strings.Contains(msg, "»FELHOM«") {
t.Errorf("C1: hint missing from %q", msg)
}
// C2: an IP + unreachable → hint ABSENT (the wrong case: nagging an IP user about NetBIOS).
// Red-proof: invert the lexical check in looksLikeFlatNetworkName → this fails.
if msg := netAddMessage("unreachable", "192.168.0.50", 1000); strings.Contains(msg, hintMark) {
t.Errorf("C2: hint wrongly present for an IP: %q", msg)
}
// C3: dotted name → hint absent.
if msg := netAddMessage("unreachable", "nas.local", 1000); strings.Contains(msg, hintMark) {
t.Errorf("C3: hint wrongly present for a dotted name: %q", msg)
}
// The hint stays out of every OTHER category — it explains unreachability only.
if msg := netAddMessage("smb_auth", "FELHOM", 1000); strings.Contains(msg, hintMark) {
t.Errorf("hint leaked into smb_auth: %q", msg)
}
// Lexical edges: IPv6 literal (no dots, but an IP) and empty stay quiet.
if looksLikeFlatNetworkName("fe80::1") {
t.Error("IPv6 literal flagged as a NetBIOS name")
}
if looksLikeFlatNetworkName("") || looksLikeFlatNetworkName(" ") {
t.Error("empty value flagged as a NetBIOS name")
}
if !looksLikeFlatNetworkName("FELHOM") {
t.Error("FELHOM not flagged")
}
}