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,228 @@
|
||||
package preset
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "presets.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sample() Preset {
|
||||
return Preset{
|
||||
Name: "Artist frame — control",
|
||||
Group: "Riedel",
|
||||
Adapter: "Ethernet",
|
||||
Mode: ModeStatic,
|
||||
Primary: &Address{Address: "192.168.42.100", Prefix: 24, Gateway: "192.168.42.1"},
|
||||
DNS: DNS{Mode: ModeStatic, Servers: []string{"192.168.42.1"}},
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped example doubles as the documented export format, so a change
|
||||
// that breaks it breaks every preset pack already in circulation.
|
||||
func TestExamplePackDecodes(t *testing.T) {
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", "examples", "preset-pack.json"))
|
||||
if err != nil {
|
||||
t.Skipf("example pack not present: %v", err)
|
||||
}
|
||||
f, err := Decode(b)
|
||||
if err != nil {
|
||||
t.Fatalf("examples/preset-pack.json does not validate: %v", err)
|
||||
}
|
||||
if len(f.Presets) == 0 {
|
||||
t.Fatal("example pack is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileStartsEmpty(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if len(s.All()) != 0 {
|
||||
t.Errorf("a fresh store should have no presets, got %d", len(s.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAssignsIDAndPersists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "presets.json")
|
||||
s, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
saved, err := s.Put(sample())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ID == "" {
|
||||
t.Fatal("Put should have assigned an id")
|
||||
}
|
||||
|
||||
reopened, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("saved file did not reload: %v", err)
|
||||
}
|
||||
if got, ok := reopened.Get(saved.ID); !ok || got.Name != saved.Name {
|
||||
t.Errorf("preset did not survive a reload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutRejectsInvalid(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
bad := sample()
|
||||
bad.Primary.Address = "not-an-ip"
|
||||
if _, err := s.Put(bad); err == nil {
|
||||
t.Error("expected a validation error for a bad address")
|
||||
}
|
||||
|
||||
// A secondary carrying a gateway is silently dropped by netsh, so it is
|
||||
// rejected rather than saved as a lie.
|
||||
withGW := sample()
|
||||
withGW.Secondary = []Address{{Address: "10.0.0.5", Prefix: 24, Gateway: "10.0.0.1"}}
|
||||
if _, err := s.Put(withGW); err == nil {
|
||||
t.Error("expected a validation error for a secondary with a gateway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsWrongVersion(t *testing.T) {
|
||||
_, err := Decode([]byte(`{"version": 99, "presets": []}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "version") {
|
||||
t.Errorf("expected a version error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsDuplicateIDs(t *testing.T) {
|
||||
doc := `{"version":1,"presets":[
|
||||
{"id":"a","name":"one","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}},
|
||||
{"id":"a","name":"two","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}`
|
||||
if _, err := Decode([]byte(doc)); err == nil {
|
||||
t.Error("expected a duplicate-id error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportMergeKeepsBothOnIDCollision(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
saved, err := s.Put(sample())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pack, err := s.Export()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := s.Import(pack, ImportMerge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Added != 1 || len(res.Renamed) != 1 {
|
||||
t.Fatalf("expected 1 added and 1 renamed, got %+v", res)
|
||||
}
|
||||
if len(s.All()) != 2 {
|
||||
t.Fatalf("merge should keep both, got %d presets", len(s.All()))
|
||||
}
|
||||
|
||||
// The original must be untouched: a colleague's pack silently overwriting
|
||||
// a working preset is the failure this guards against.
|
||||
orig, ok := s.Get(saved.ID)
|
||||
if !ok || orig.Name != saved.Name {
|
||||
t.Errorf("original preset was modified: %+v", orig)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, p := range s.All() {
|
||||
if strings.HasSuffix(p.Name, "(imported)") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the colliding import should have been suffixed with (imported)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportReplaceDiscardsExisting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
doc := `{"version":1,"presets":[{"id":"z","name":"only","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}`
|
||||
if _, err := s.Import([]byte(doc), ImportReplace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all := s.All()
|
||||
if len(all) != 1 || all[0].ID != "z" {
|
||||
t.Errorf("replace should leave exactly the incoming presets, got %+v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected import must change nothing: partial application would leave the
|
||||
// user with a half-imported pack and no way to tell what landed.
|
||||
func TestImportRejectsWithoutMutating(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(s.All())
|
||||
|
||||
bad := `{"version":1,"presets":[{"id":"x","name":"broken","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}]}`
|
||||
if _, err := s.Import([]byte(bad), ImportMerge); err == nil {
|
||||
t.Fatal("expected the import to be rejected")
|
||||
}
|
||||
if len(s.All()) != before {
|
||||
t.Errorf("a rejected import changed the store: %d -> %d", before, len(s.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFiltersByGroup(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := sample()
|
||||
other.Name = "Management"
|
||||
other.Group = "Herespace"
|
||||
if _, err := s.Put(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := s.Export("Riedel")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := Decode(b)
|
||||
if err != nil {
|
||||
t.Fatalf("a filtered export must still be a valid pack: %v", err)
|
||||
}
|
||||
if len(f.Presets) != 1 || f.Presets[0].Group != "Riedel" {
|
||||
t.Errorf("group filter returned %+v", f.Presets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUngroupedSortsLast(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
loose := sample()
|
||||
loose.Name = "Loose"
|
||||
loose.Group = ""
|
||||
if _, err := s.Put(loose); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Put(sample()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
groups := s.Groups()
|
||||
if len(groups) != 2 || groups[len(groups)-1] != "Ungrouped" {
|
||||
t.Errorf("Ungrouped should sort last, got %v", groups)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user