Scaffold ipswap: tray-driven Windows IP preset switcher
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>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user