Files
nadir-agent/internal/modules/networking/networking_handler_test.go
T

484 lines
16 KiB
Go
Raw Normal View History

2026-06-22 16:06:57 +02:00
package networking
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
type mockBackend struct {
name string
snapshotResult IfaceConfig
applyCalledWith IfaceConfig
applyErr error
snapshotErr error
setUpCalled bool
setDownCalled bool
}
func (m *mockBackend) Name() string { return m.name }
func (m *mockBackend) Snapshot(_ context.Context, iface string) (IfaceConfig, error) {
return m.snapshotResult, m.snapshotErr
}
func (m *mockBackend) Apply(_ context.Context, iface string, cfg IfaceConfig) error {
m.applyCalledWith = cfg
return m.applyErr
}
func (m *mockBackend) SetLinkUp(_ context.Context, iface string) error {
m.setUpCalled = true
return nil
}
func (m *mockBackend) SetLinkDown(_ context.Context, iface string) error {
m.setDownCalled = true
return nil
}
func TestNetworkingHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
be := &mockBackend{
name: "mockbe",
snapshotResult: IfaceConfig{
Method: "dhcp",
Address: "192.168.1.10/24",
},
}
m := &Module{be: be}
m.Register(api)
tempResolv := filepath.Join(t.TempDir(), "resolv.conf")
if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
oldResolv := resolvConf
resolvConf = tempResolv
defer func() { resolvConf = oldResolv }()
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-j", "addr"}) {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"-j", "route"}) {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test GET /api/networking/interfaces
resp := api.Get("/api/networking/interfaces")
if resp.Code != http.StatusOK {
t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK)
}
2026-06-23 17:16:01 +02:00
// 1b. Test GET /api/networking/interfaces/{name} (used by edit-form prefill).
// Asserts the backend's Snapshot output is returned verbatim as the body, so
// the same shape can feed straight into PUT.
resp = api.Get("/api/networking/interfaces/eth0")
if resp.Code != http.StatusOK {
t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK)
}
var ifaceRes GetInterfaceConfigOutput
if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil {
t.Fatal(err)
}
if ifaceRes.Body.Method != "dhcp" || ifaceRes.Body.Address != "192.168.1.10/24" {
t.Errorf("get interface: got %+v, want snapshot result", ifaceRes.Body)
}
// Same endpoint with no backend should return 501.
noBackend := &Module{}
noBackendMux := http.NewServeMux()
noBackendAPI := humatest.Wrap(t, humago.New(noBackendMux, huma.DefaultConfig("Test", "1.0.0")))
noBackend.Register(noBackendAPI)
if got := noBackendAPI.Get("/api/networking/interfaces/eth0").Code; got != http.StatusNotImplemented {
t.Errorf("get interface without backend: got %d, want 501", got)
}
2026-06-22 16:06:57 +02:00
// 2. Test GET /api/networking/routes
resp = api.Get("/api/networking/routes")
if resp.Code != http.StatusOK {
t.Errorf("list routes: got %d, want %d", resp.Code, http.StatusOK)
}
// 3. Test GET /api/networking/dns
resp = api.Get("/api/networking/dns")
if resp.Code != http.StatusOK {
t.Errorf("get dns: got %d, want %d", resp.Code, http.StatusOK)
}
var dnsRes DNSOutput
if err := json.Unmarshal(resp.Body.Bytes(), &dnsRes.Body); err != nil {
t.Fatal(err)
}
if len(dnsRes.Body.Servers) != 2 || dnsRes.Body.Servers[0] != "1.1.1.1" {
t.Errorf("get dns output: %+v", dnsRes.Body)
}
// 4. Test PUT /api/networking/interfaces/{name}
applyPayload := struct {
Method string `json:"method"`
Address string `json:"address,omitempty"`
Prefix int `json:"prefix,omitempty"`
Gateway string `json:"gateway,omitempty"`
DNS []string `json:"dns,omitempty"`
RollbackSeconds int `json:"rollback_seconds,omitempty"`
}{
Method: "static",
Address: "192.168.1.20",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1"},
RollbackSeconds: 2,
}
resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
if resp.Code != http.StatusOK {
t.Errorf("apply interface config: got %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusOK {
t.Errorf("get pending: got %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test POST /api/networking/interfaces/{name}/confirm
resp = api.Post("/api/networking/interfaces/eth0/confirm", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("confirm change: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusNotFound {
t.Errorf("pending change should be cleared: got %d, want %d", resp.Code, http.StatusNotFound)
}
// 6. Test explicit rollback (same revert path as automatic rollback).
2026-06-22 16:06:57 +02:00
applyPayload.RollbackSeconds = 1
resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
if resp.Code != http.StatusOK {
t.Errorf("apply config again: got %d, want %d", resp.Code, http.StatusOK)
}
if m.pending == nil {
t.Fatal("expected pending change after apply")
}
if err := m.rollbackNow("eth0"); err != nil {
t.Fatal(err)
}
2026-06-22 16:06:57 +02:00
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusNotFound {
t.Errorf("pending change should be rolled back: got %d, want %d", resp.Code, http.StatusNotFound)
}
if be.applyCalledWith.Method != "dhcp" || be.applyCalledWith.Address != "192.168.1.10/24" {
t.Errorf("rollback failed to restore prior config: %+v", be.applyCalledWith)
}
// 7. Test POST link up & down
resp = api.Post("/api/networking/interfaces/eth0/up", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("set link up: got %d, want %d", resp.Code, http.StatusOK)
}
if !be.setUpCalled {
t.Errorf("expected SetLinkUp call on backend")
}
resp = api.Post("/api/networking/interfaces/eth0/down", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("set link down: got %d, want %d", resp.Code, http.StatusOK)
}
if !be.setDownCalled {
t.Errorf("expected SetLinkDown call on backend")
}
}
// #3: a failed Apply must restore the prior snapshot (no half-applied config
// left with no auto-revert) and arm no pending change.
func TestApplyFailureRestoresPrior(t *testing.T) {
be := &mockBackend{
name: "mock",
snapshotResult: IfaceConfig{Method: "dhcp"},
applyErr: errors.New("con up failed"),
}
m := &Module{be: be}
_, err := m.startRollback(t.Context(), "eth0", IfaceConfig{Method: "static", Address: "10.0.0.5", Prefix: 24})
if err == nil {
t.Fatal("expected apply to fail")
}
// The last Apply call should be the restore-to-prior (dhcp), not the new config.
if be.applyCalledWith.Method != "dhcp" {
t.Errorf("expected restore to prior config, last Apply got %+v", be.applyCalledWith)
}
if m.pending != nil {
t.Error("no pending change should be armed after a failed apply")
}
}
// #1: link-down must go through the rollback safety net (arms a pending change),
// and rolling it back brings the interface back up. #2: a change to another
// interface while one is pending is rejected with errAlreadyPending.
func TestLinkDownArmsRollback(t *testing.T) {
be := &mockBackend{name: "mock"}
m := &Module{be: be}
secs, err := m.startLinkDown(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if secs != defaultRollbackSeconds {
t.Errorf("seconds = %d, want default %d", secs, defaultRollbackSeconds)
}
if !be.setDownCalled {
t.Error("expected SetLinkDown to be called")
}
if m.pending == nil {
t.Fatal("expected a pending change to be armed")
}
// A concurrent change to a different interface is rejected (global lock).
if _, err := m.startRollback(t.Context(), "eth1", IfaceConfig{Method: "dhcp"}); !errors.Is(err, errAlreadyPending) {
t.Errorf("expected errAlreadyPending for eth1, got %v", err)
}
// Rolling back brings the link back up and clears the pending change.
if err := m.rollbackNow("eth0"); err != nil {
t.Fatal(err)
}
if !be.setUpCalled {
t.Error("expected SetLinkUp on rollback")
}
if m.pending != nil {
t.Error("pending change should be cleared after rollback")
}
}
func TestBackendImplementations(t *testing.T) {
tempDir := t.TempDir()
// Mock ip command for fallback snapshots and link control
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
if strings.Contains(argStr, "addr show") {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.30", "prefixlen": 24}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "route show") {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "link set") {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test nmcliBackend
nm := &nmcliBackend{}
oscmd.SetMock("nmcli", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
t.Logf("nmcli mock called with: %s", argStr)
if strings.Contains(argStr, "con show --active") {
return oscmd.MockCommand{Stdout: "myconn:eth0\n", ExitCode: 0}
}
if strings.Contains(argStr, "con show") && strings.Contains(argStr, "myconn") {
showOut := "ipv4.method:manual\nipv4.addresses:192.168.1.10/24\nipv4.gateway:192.168.1.1\nipv4.dns:1.1.1.1\nipv4.routes:10.0.0.0/24 192.168.1.1\n"
return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
}
if (strings.Contains(argStr, "con modify") || strings.Contains(argStr, "con up") || strings.Contains(argStr, "con down")) && strings.Contains(argStr, "myconn") {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
cfg, err := nm.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.10" || cfg.Gateway != "192.168.1.1" {
t.Errorf("nmcli snapshot failed: %+v", cfg)
}
err = nm.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.20",
Prefix: 24,
Gateway: "192.168.1.1",
})
if err != nil {
t.Fatal(err)
}
if err := nm.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := nm.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
// 2. Test networkdBackend
nd := &networkdBackend{}
oldNdDir := networkdDir
networkdDir = tempDir
defer func() { networkdDir = oldNdDir }()
oscmd.SetMock("networkctl", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{ExitCode: 0}
})
err = nd.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.30",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"8.8.8.8"},
})
if err != nil {
t.Fatal(err)
}
cfg, err = nd.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.30" || cfg.Method != "static" {
t.Errorf("networkd snapshot failed: %+v", cfg)
}
if err := nd.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := nd.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
// 3. Test ifupdownBackend
iu := &ifupdownBackend{}
oldIfaceFile := interfacesFile
oldInterfacesDDir := interfacesDDir
interfacesFile = filepath.Join(tempDir, "interfaces")
interfacesDDir = filepath.Join(tempDir, "interfaces.d")
defer func() {
interfacesFile = oldIfaceFile
interfacesDDir = oldInterfacesDDir
}()
if err := os.MkdirAll(interfacesDDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(interfacesFile, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil {
t.Fatal(err)
}
oscmd.SetMock("ifup", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
oscmd.SetMock("ifdown", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
err = iu.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.40",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1"},
})
if err != nil {
t.Fatal(err)
}
cfg, err = iu.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.40" || cfg.Method != "static" {
t.Errorf("ifupdown snapshot failed: %+v", cfg)
}
if err := iu.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := iu.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
}
2026-06-23 17:40:17 +02:00
func TestGetInterfaceConfigAugment(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
be := &mockBackend{
name: "mockbe",
snapshotResult: IfaceConfig{
Method: "dhcp",
},
}
m := &Module{be: be}
m.Register(api)
tempResolv := filepath.Join(t.TempDir(), "resolv.conf")
if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
oldResolv := resolvConf
resolvConf = tempResolv
defer func() { resolvConf = oldResolv }()
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
if strings.Contains(argStr, "addr show") {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}, {"family": "inet6", "local": "2001:db8::10", "prefixlen": 64}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "route show") && !strings.Contains(argStr, "-6") {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "-6") && strings.Contains(argStr, "route show") {
out := `[{"dst": "default", "gateway": "2001:db8::1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
resp := api.Get("/api/networking/interfaces/eth0")
if resp.Code != http.StatusOK {
t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK)
}
var ifaceRes GetInterfaceConfigOutput
if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil {
t.Fatal(err)
}
if ifaceRes.Body.Method != "dhcp" {
t.Errorf("expected Method to be dhcp, got %s", ifaceRes.Body.Method)
}
if ifaceRes.Body.Address != "192.168.1.10" || ifaceRes.Body.Prefix != 24 {
t.Errorf("expected augmented Address 192.168.1.10/24, got %s/%d", ifaceRes.Body.Address, ifaceRes.Body.Prefix)
}
if ifaceRes.Body.Gateway != "192.168.1.1" {
t.Errorf("expected augmented Gateway 192.168.1.1, got %s", ifaceRes.Body.Gateway)
}
if len(ifaceRes.Body.DNS) != 2 || ifaceRes.Body.DNS[0] != "1.1.1.1" || ifaceRes.Body.DNS[1] != "8.8.8.8" {
t.Errorf("expected augmented DNS [1.1.1.1, 8.8.8.8], got %v", ifaceRes.Body.DNS)
}
if ifaceRes.Body.IPv6 == nil || ifaceRes.Body.IPv6.Method != "auto" || ifaceRes.Body.IPv6.Address != "2001:db8::10" || ifaceRes.Body.IPv6.Prefix != 64 || ifaceRes.Body.IPv6.Gateway != "2001:db8::1" {
t.Errorf("expected augmented IPv6, got %+v", ifaceRes.Body.IPv6)
}
}