23dcfb393f
Implements the design in claude.md as a building skeleton: pure Go, no cgo, cross-compiles to a single Windows .exe from Linux. Architecture follows the spec's deliberate split. The fast path is native — tray icon, grouped submenus, a Win32 MessageBox showing a live before/after diff, then netsh. The slow path is an embedded web editor served on a random loopback port and opened in the default browser. Two decisions worth recording: Reads use GetAdaptersAddresses, writes use netsh. The spec left the enumeration mechanism open; parsing `netsh show config` breaks on a non-English Windows because the output is localised. DNS static-vs-DHCP origin is not exposed by that API, so it comes from one registry read. The netsh command plan is built in portable code. That puts the delete-every- existing-address step — the one that stops secondary addresses leaking across switches — under test without needing a Windows box. The editor requires the session token in a header for mutations, not just the cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie attached, but it cannot set a header. The updater refuses to install a release that publishes no SHA256. Not yet done: no group picker for export (the API supports it), no single-instance guard, and internal/server/web/app.css is reconstructed from the description in claude.md rather than the canonical apointless.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
5.3 KiB
Go
189 lines
5.3 KiB
Go
package netcfg
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
|
)
|
|
|
|
func staticPreset() preset.Preset {
|
|
return preset.Preset{
|
|
ID: "test",
|
|
Name: "Artist frame — control",
|
|
Adapter: "Ethernet",
|
|
Mode: preset.ModeStatic,
|
|
Primary: &preset.Address{
|
|
Address: "192.168.42.100",
|
|
Prefix: 24,
|
|
Gateway: "192.168.42.1",
|
|
},
|
|
Secondary: []preset.Address{{Address: "10.0.10.50", Prefix: 24}},
|
|
DNS: preset.DNS{Mode: preset.ModeStatic, Servers: []string{"192.168.42.1", "1.1.1.1"}},
|
|
}
|
|
}
|
|
|
|
func staticLive() LiveConfig {
|
|
return LiveConfig{
|
|
Adapter: "Ethernet",
|
|
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}, {Address: "172.16.0.5", Prefix: 16}},
|
|
Gateways: []string{"192.168.1.1"},
|
|
DNS: []string{"192.168.1.1"},
|
|
}
|
|
}
|
|
|
|
// The leak this guards against is the reason Plan exists: without a delete for
|
|
// every live address, secondaries from the previous preset stay attached.
|
|
func TestPlanDeletesEveryExistingAddress(t *testing.T) {
|
|
cmds := Plan(staticPreset(), staticLive())
|
|
|
|
var deletes []string
|
|
for _, c := range cmds {
|
|
if len(c.Args) > 3 && c.Args[2] == "delete" && c.Args[3] == "address" {
|
|
deletes = append(deletes, strings.Join(c.Args, " "))
|
|
}
|
|
}
|
|
if len(deletes) != 2 {
|
|
t.Fatalf("expected 2 delete commands, got %d: %v", len(deletes), deletes)
|
|
}
|
|
for _, want := range []string{"addr=192.168.1.87", "addr=172.16.0.5"} {
|
|
found := false
|
|
for _, d := range deletes {
|
|
if strings.Contains(d, want) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no delete command for %s: %v", want, deletes)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanOrdering(t *testing.T) {
|
|
cmds := Plan(staticPreset(), staticLive())
|
|
|
|
// deletes, then set primary, then add secondary, then DNS.
|
|
var kinds []string
|
|
for _, c := range cmds {
|
|
switch {
|
|
case c.Args[2] == "delete":
|
|
kinds = append(kinds, "delete")
|
|
case c.Args[2] == "set" && c.Args[3] == "address":
|
|
kinds = append(kinds, "set-addr")
|
|
case c.Args[2] == "add" && c.Args[3] == "address":
|
|
kinds = append(kinds, "add-addr")
|
|
case c.Args[3] == "dnsservers":
|
|
kinds = append(kinds, "dns")
|
|
}
|
|
}
|
|
want := []string{"delete", "delete", "set-addr", "add-addr", "dns", "dns"}
|
|
if strings.Join(kinds, ",") != strings.Join(want, ",") {
|
|
t.Errorf("plan order = %v, want %v", kinds, want)
|
|
}
|
|
}
|
|
|
|
func TestPlanUsesDottedMaskNotPrefix(t *testing.T) {
|
|
// netsh rejects "/24" outright.
|
|
cmds := Plan(staticPreset(), staticLive())
|
|
for _, c := range cmds {
|
|
for _, a := range c.Args {
|
|
if strings.HasPrefix(a, "/") {
|
|
t.Errorf("command passes a prefix-notation mask: %v", c.Args)
|
|
}
|
|
}
|
|
}
|
|
joined := strings.Join(cmds[2].Args, " ")
|
|
if !strings.Contains(joined, "255.255.255.0") {
|
|
t.Errorf("set address command lacks a dotted mask: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestPlanDHCPSkipsDeletes(t *testing.T) {
|
|
p := preset.Preset{
|
|
ID: "d", Name: "DHCP", Adapter: "Ethernet",
|
|
Mode: preset.ModeDHCP,
|
|
DNS: preset.DNS{Mode: preset.ModeDHCP},
|
|
}
|
|
cmds := Plan(p, staticLive())
|
|
if len(cmds) != 2 {
|
|
t.Fatalf("expected 2 commands for a DHCP preset, got %d: %v", len(cmds), cmds)
|
|
}
|
|
for _, c := range cmds {
|
|
if c.Args[2] == "delete" {
|
|
t.Errorf("DHCP plan should not delete addresses: %v", c.Args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A DHCP lease is not deletable with `netsh delete address`, and the static set
|
|
// replaces it anyway, so a live-DHCP adapter gets no delete commands.
|
|
func TestPlanSkipsDeletesWhenLiveIsDHCP(t *testing.T) {
|
|
live := staticLive()
|
|
live.DHCP = true
|
|
for _, c := range Plan(staticPreset(), live) {
|
|
if c.Args[2] == "delete" {
|
|
t.Errorf("should not delete DHCP-assigned addresses: %v", c.Args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanAdapterNameIsNotShellQuoted(t *testing.T) {
|
|
p := staticPreset()
|
|
p.Adapter = "Ethernet 3"
|
|
for _, c := range Plan(p, LiveConfig{Adapter: "Ethernet 3"}) {
|
|
for _, a := range c.Args {
|
|
if strings.HasPrefix(a, "name=") && strings.Contains(a, `"`) {
|
|
t.Errorf("adapter name should not carry quotes through os/exec: %q", a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatches(t *testing.T) {
|
|
p := staticPreset()
|
|
live := LiveConfig{
|
|
Adapter: "Ethernet",
|
|
Addresses: []preset.Address{{Address: "10.0.10.50", Prefix: 24}, {Address: "192.168.42.100", Prefix: 24}},
|
|
Gateways: []string{"192.168.42.1"},
|
|
DNS: []string{"192.168.42.1", "1.1.1.1"},
|
|
}
|
|
if !Matches(p, live) {
|
|
t.Error("preset should match an adapter carrying exactly its addresses")
|
|
}
|
|
|
|
extra := live
|
|
extra.Addresses = append(append([]preset.Address{}, live.Addresses...), preset.Address{Address: "10.9.9.9", Prefix: 24})
|
|
if Matches(p, extra) {
|
|
t.Error("a preset that is a subset of the live config is not the active preset")
|
|
}
|
|
|
|
reordered := live
|
|
reordered.DNS = []string{"1.1.1.1", "192.168.42.1"}
|
|
if Matches(p, reordered) {
|
|
t.Error("DNS order is significant and should not match")
|
|
}
|
|
|
|
dhcp := live
|
|
dhcp.DNSFromDHCP = true
|
|
if Matches(p, dhcp) {
|
|
t.Error("static-DNS preset should not match a DHCP-DNS adapter")
|
|
}
|
|
}
|
|
|
|
func TestConfirmTextShapes(t *testing.T) {
|
|
got := ConfirmText(staticPreset(), staticLive())
|
|
for _, want := range []string{
|
|
`Apply preset "Artist frame — control" to adapter "Ethernet"?`,
|
|
"CURRENT", "NEW",
|
|
"192.168.1.87/24",
|
|
"192.168.42.100/24",
|
|
"+ 10.0.10.50/24",
|
|
"Gateway: 192.168.42.1",
|
|
"DNS: 192.168.42.1, 1.1.1.1",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("confirm text missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|