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
This commit is contained in:
2026-07-22 13:49:57 +02:00
parent 4aa2ce4b61
commit 7013a5fd2e
14 changed files with 779 additions and 2 deletions
@@ -0,0 +1,189 @@
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")
}
}