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:
2026-08-20 13:29:35 +10:00
commit 23dcfb393f
46 changed files with 5782 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package preset
import (
"encoding/json"
"fmt"
"strings"
)
// ImportMode is what to do with the presets already on disk.
type ImportMode string
const (
// ImportMerge keeps existing presets and adds the incoming ones.
ImportMerge ImportMode = "merge"
// ImportReplace discards everything already saved.
ImportReplace ImportMode = "replace"
)
// Export renders the presets as a shippable preset pack. Passing one or more
// group names filters to just those groups; passing none exports everything.
//
// The output is the same shape as presets.json so a pack can be dropped
// straight in as a starting file, and it stays hand-editable.
func (s *Store) Export(groups ...string) ([]byte, error) {
want := make(map[string]bool, len(groups))
for _, g := range groups {
want[strings.ToLower(strings.TrimSpace(g))] = true
}
out := File{Version: SchemaVersion}
for _, p := range s.All() {
if len(want) > 0 && !want[strings.ToLower(p.GroupOrUngrouped())] {
continue
}
out.Presets = append(out.Presets, p)
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return nil, fmt.Errorf("encoding export: %w", err)
}
return append(b, '\n'), nil
}
// ImportResult reports what an import did, for the summary shown afterwards.
type ImportResult struct {
Added int `json:"added"`
Replaced int `json:"replaced"`
Renamed []string `json:"renamed,omitempty"`
}
// Import validates an incoming preset file in full before touching anything,
// then applies it. A file that fails validation changes nothing on disk.
//
// On merge, an incoming preset whose id already exists is kept alongside the
// existing one: it gets a fresh id and "(imported)" appended to its name, so a
// colleague's pack can never silently overwrite what you already had.
func (s *Store) Import(data []byte, mode ImportMode) (ImportResult, error) {
incoming, err := Decode(data)
if err != nil {
return ImportResult{}, err
}
var res ImportResult
switch mode {
case ImportReplace:
s.mu.Lock()
s.file = File{Version: SchemaVersion, Presets: incoming.Presets}
res.Added = len(incoming.Presets)
s.mu.Unlock()
case ImportMerge:
s.mu.Lock()
existing := make(map[string]bool, len(s.file.Presets))
for _, p := range s.file.Presets {
existing[p.ID] = true
}
for _, p := range incoming.Presets {
if existing[p.ID] {
p.ID = NewID()
p.Name = p.Name + " (imported)"
res.Renamed = append(res.Renamed, p.Name)
}
existing[p.ID] = true
s.file.Presets = append(s.file.Presets, p)
res.Added++
}
s.mu.Unlock()
default:
return ImportResult{}, fmt.Errorf("unknown import mode %q", mode)
}
if err := s.Save(); err != nil {
// Reload so the in-memory copy matches whatever is actually on disk.
_ = s.Load()
return ImportResult{}, err
}
return res, nil
}
+71
View File
@@ -0,0 +1,71 @@
package preset
import (
"fmt"
"net/netip"
"strconv"
"strings"
)
// ParsePrefix accepts every spelling of a subnet mask the editor is required to
// take — "24", "/24" and "255.255.255.0" — and returns the prefix length.
//
// Only contiguous masks are accepted. 255.255.0.255 is a typo, not a netmask,
// and letting it through would produce a netsh call that fails confusingly.
func ParsePrefix(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty mask")
}
s = strings.TrimPrefix(s, "/")
if strings.Contains(s, ".") {
addr, err := netip.ParseAddr(s)
if err != nil || !addr.Is4() {
return 0, fmt.Errorf("%q is not a dotted-quad netmask", s)
}
b := addr.As4()
mask := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
ones := 0
for ones < 32 && mask&(1<<uint(31-ones)) != 0 {
ones++
}
// Everything below the leading run of ones must be zero.
if ones < 32 && mask<<uint(ones) != 0 {
return 0, fmt.Errorf("%q is not a contiguous netmask", s)
}
return ones, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%q is not a prefix length", s)
}
if n < 0 || n > 32 {
return 0, fmt.Errorf("prefix /%d out of range", n)
}
return n, nil
}
// MaskString renders a prefix length as the dotted-quad netmask netsh expects
// on the command line. netsh will not take "/24".
func MaskString(prefix int) string {
if prefix < 0 || prefix > 32 {
prefix = 32
}
var mask uint32
if prefix > 0 {
mask = ^uint32(0) << uint(32-prefix)
}
return fmt.Sprintf("%d.%d.%d.%d", mask>>24&0xff, mask>>16&0xff, mask>>8&0xff, mask&0xff)
}
// FormatPrefix renders a prefix in whichever notation the user last chose.
// dotted controls the style; see config.Settings.MaskStyle.
func FormatPrefix(prefix int, dotted bool) string {
if dotted {
return MaskString(prefix)
}
return "/" + strconv.Itoa(prefix)
}
+66
View File
@@ -0,0 +1,66 @@
package preset
import "testing"
func TestParsePrefix(t *testing.T) {
ok := []struct {
in string
want int
}{
{"24", 24},
{"/24", 24},
{" /24 ", 24},
{"255.255.255.0", 24},
{"255.255.255.255", 32},
{"0.0.0.0", 0},
{"255.255.254.0", 23},
{"128.0.0.0", 1},
{"32", 32},
{"0", 0},
}
for _, tc := range ok {
got, err := ParsePrefix(tc.in)
if err != nil {
t.Errorf("ParsePrefix(%q) returned error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParsePrefix(%q) = %d, want %d", tc.in, got, tc.want)
}
}
bad := []string{"", "33", "-1", "255.255.0.255", "1.2.3", "abc", "255.0.255.0", "/", "24.5"}
for _, in := range bad {
if got, err := ParsePrefix(in); err == nil {
t.Errorf("ParsePrefix(%q) = %d, want error", in, got)
}
}
}
func TestMaskStringRoundTrip(t *testing.T) {
for p := 0; p <= 32; p++ {
got, err := ParsePrefix(MaskString(p))
if err != nil {
t.Fatalf("ParsePrefix(MaskString(%d)) errored: %v", p, err)
}
if got != p {
t.Errorf("round trip of /%d gave /%d (mask %s)", p, got, MaskString(p))
}
}
}
func TestMaskStringKnown(t *testing.T) {
cases := map[int]string{
0: "0.0.0.0",
8: "255.0.0.0",
16: "255.255.0.0",
24: "255.255.255.0",
30: "255.255.255.252",
32: "255.255.255.255",
}
for prefix, want := range cases {
if got := MaskString(prefix); got != want {
t.Errorf("MaskString(%d) = %s, want %s", prefix, got, want)
}
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package preset holds the on-disk preset model and everything that reads or
// writes it. It is deliberately free of Windows-specific code so the whole
// model can be exercised by tests on any platform.
package preset
import (
"fmt"
"net/netip"
"strings"
)
// SchemaVersion is written into every file we save and checked on every file we
// load. Bump it only for a breaking change, and add a migration when you do.
const SchemaVersion = 1
// Mode is how an adapter gets its addresses.
type Mode string
const (
ModeStatic Mode = "static"
ModeDHCP Mode = "dhcp"
)
// File is the top-level shape of presets.json.
type File struct {
Version int `json:"version"`
Presets []Preset `json:"presets"`
}
// Preset is one saved adapter configuration.
type Preset struct {
ID string `json:"id"`
Name string `json:"name"`
// Group drives the tray submenus. Empty means "Ungrouped".
Group string `json:"group,omitempty"`
// Adapter is the Windows friendly name, e.g. "Ethernet" or "Wi-Fi 2".
Adapter string `json:"adapter"`
Mode Mode `json:"mode"`
// Primary is required when Mode is static and ignored when it is dhcp.
Primary *Address `json:"primary,omitempty"`
// Secondary addresses are added after the primary is set.
Secondary []Address `json:"secondary,omitempty"`
DNS DNS `json:"dns"`
Notes string `json:"notes,omitempty"`
}
// Address is a single IPv4 address with its prefix length, and optionally the
// default gateway reached through it.
type Address struct {
Address string `json:"address"`
// Prefix is the mask stored as a prefix length. The editor accepts
// dotted-quad on input and converts here; see ParsePrefix.
Prefix int `json:"prefix"`
Gateway string `json:"gateway,omitempty"`
// GatewayMetric 0 means automatic, which is what netsh wants for "let
// Windows decide".
GatewayMetric int `json:"gateway_metric,omitempty"`
}
// DNS is the resolver configuration for a preset.
type DNS struct {
Mode Mode `json:"mode"`
Servers []string `json:"servers,omitempty"`
}
// GroupOrUngrouped is the submenu a preset belongs under.
func (p Preset) GroupOrUngrouped() string {
if strings.TrimSpace(p.Group) == "" {
return "Ungrouped"
}
return p.Group
}
// Validate reports the first structural problem with a preset. Import rejects
// a whole file rather than partially applying it, so this needs to be strict.
func (p Preset) Validate() error {
if strings.TrimSpace(p.ID) == "" {
return fmt.Errorf("preset has no id")
}
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("preset %s has no name", p.ID)
}
if strings.TrimSpace(p.Adapter) == "" {
return fmt.Errorf("preset %q has no adapter", p.Name)
}
switch p.Mode {
case ModeDHCP:
// Nothing else to check: any addresses present are ignored on apply.
case ModeStatic:
if p.Primary == nil {
return fmt.Errorf("preset %q is static but has no primary address", p.Name)
}
if err := p.Primary.validate(); err != nil {
return fmt.Errorf("preset %q primary: %w", p.Name, err)
}
for i, s := range p.Secondary {
if err := s.validate(); err != nil {
return fmt.Errorf("preset %q secondary %d: %w", p.Name, i+1, err)
}
// A gateway on a secondary address is silently dropped by
// `netsh add address`, so reject it rather than pretend.
if s.Gateway != "" {
return fmt.Errorf("preset %q secondary %d: secondary addresses cannot carry a gateway", p.Name, i+1)
}
}
default:
return fmt.Errorf("preset %q has unknown mode %q", p.Name, p.Mode)
}
switch p.DNS.Mode {
case ModeDHCP:
case ModeStatic:
if len(p.DNS.Servers) == 0 {
return fmt.Errorf("preset %q has static DNS but no servers", p.Name)
}
for _, s := range p.DNS.Servers {
if _, err := parseV4(s); err != nil {
return fmt.Errorf("preset %q dns server %q: %w", p.Name, s, err)
}
}
default:
return fmt.Errorf("preset %q has unknown dns mode %q", p.Name, p.DNS.Mode)
}
return nil
}
func (a Address) validate() error {
if _, err := parseV4(a.Address); err != nil {
return fmt.Errorf("address %q: %w", a.Address, err)
}
if a.Prefix < 0 || a.Prefix > 32 {
return fmt.Errorf("prefix /%d out of range", a.Prefix)
}
if a.Gateway != "" {
if _, err := parseV4(a.Gateway); err != nil {
return fmt.Errorf("gateway %q: %w", a.Gateway, err)
}
}
if a.GatewayMetric < 0 {
return fmt.Errorf("gateway metric %d is negative", a.GatewayMetric)
}
return nil
}
// String renders an address the way the confirmation dialog shows it.
func (a Address) String() string { return fmt.Sprintf("%s/%d", a.Address, a.Prefix) }
func parseV4(s string) (netip.Addr, error) {
addr, err := netip.ParseAddr(strings.TrimSpace(s))
if err != nil {
return netip.Addr{}, fmt.Errorf("not an IP address")
}
if !addr.Is4() {
// v1 is IPv4 only; see "Out of scope" in claude.md.
return netip.Addr{}, fmt.Errorf("not an IPv4 address")
}
return addr, nil
}
+238
View File
@@ -0,0 +1,238 @@
package preset
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Store owns presets.json. Every mutation goes through it so the file is
// written atomically and the in-memory copy never drifts from disk.
type Store struct {
path string
mu sync.RWMutex
file File
}
// NewStore opens (or creates) the preset file at path.
func NewStore(path string) (*Store, error) {
s := &Store{path: path, file: File{Version: SchemaVersion}}
if err := s.Load(); err != nil {
return nil, err
}
return s, nil
}
// Load re-reads the file from disk. A missing file is not an error: a fresh
// install has no presets yet and should start with an empty list rather than
// refusing to launch.
func (s *Store) Load() error {
b, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
s.mu.Lock()
s.file = File{Version: SchemaVersion}
s.mu.Unlock()
return nil
}
if err != nil {
return fmt.Errorf("reading %s: %w", s.path, err)
}
f, err := Decode(b)
if err != nil {
return fmt.Errorf("reading %s: %w", s.path, err)
}
s.mu.Lock()
s.file = f
s.mu.Unlock()
return nil
}
// Decode parses and fully validates a preset file. Import uses it too, which is
// why it rejects the whole document on the first bad preset rather than
// dropping the offender and continuing.
func Decode(b []byte) (File, error) {
var f File
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return File{}, fmt.Errorf("not a valid preset file: %w", err)
}
if f.Version != SchemaVersion {
return File{}, fmt.Errorf("preset file is version %d, this build understands version %d", f.Version, SchemaVersion)
}
seen := make(map[string]bool, len(f.Presets))
for _, p := range f.Presets {
if err := p.Validate(); err != nil {
return File{}, err
}
if seen[p.ID] {
return File{}, fmt.Errorf("duplicate preset id %q", p.ID)
}
seen[p.ID] = true
}
return f, nil
}
// Save writes the file atomically: a temp file in the same directory, then a
// rename. A half-written presets.json after a crash would lose every preset,
// and this is the user's only copy.
func (s *Store) Save() error {
s.mu.RLock()
b, err := json.MarshalIndent(s.file, "", " ")
s.mu.RUnlock()
if err != nil {
return fmt.Errorf("encoding presets: %w", err)
}
b = append(b, '\n')
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("creating preset directory: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".presets-*.json")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once the rename below succeeds
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("syncing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("replacing %s: %w", s.path, err)
}
return nil
}
// All returns a copy of the presets, sorted by group then name so the tray menu
// and the editor agree on ordering.
func (s *Store) All() []Preset {
s.mu.RLock()
out := make([]Preset, len(s.file.Presets))
copy(out, s.file.Presets)
s.mu.RUnlock()
sort.SliceStable(out, func(i, j int) bool {
gi, gj := out[i].GroupOrUngrouped(), out[j].GroupOrUngrouped()
if gi != gj {
// Ungrouped sinks to the bottom of the tray menu.
if gi == "Ungrouped" {
return false
}
if gj == "Ungrouped" {
return true
}
return strings.ToLower(gi) < strings.ToLower(gj)
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out
}
// Get returns the preset with the given id.
func (s *Store) Get(id string) (Preset, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, p := range s.file.Presets {
if p.ID == id {
return p, true
}
}
return Preset{}, false
}
// Put inserts or replaces a preset and saves. An empty ID means "new".
func (s *Store) Put(p Preset) (Preset, error) {
if strings.TrimSpace(p.ID) == "" {
p.ID = NewID()
}
if err := p.Validate(); err != nil {
return Preset{}, err
}
s.mu.Lock()
replaced := false
for i := range s.file.Presets {
if s.file.Presets[i].ID == p.ID {
s.file.Presets[i] = p
replaced = true
break
}
}
if !replaced {
s.file.Presets = append(s.file.Presets, p)
}
s.mu.Unlock()
return p, s.Save()
}
// Delete removes a preset by id and saves.
func (s *Store) Delete(id string) error {
s.mu.Lock()
kept := s.file.Presets[:0]
found := false
for _, p := range s.file.Presets {
if p.ID == id {
found = true
continue
}
kept = append(kept, p)
}
s.file.Presets = kept
s.mu.Unlock()
if !found {
return fmt.Errorf("no preset with id %q", id)
}
return s.Save()
}
// Groups lists the distinct groups in tray-menu order.
func (s *Store) Groups() []string {
seen := map[string]bool{}
var out []string
for _, p := range s.All() {
g := p.GroupOrUngrouped()
if !seen[g] {
seen[g] = true
out = append(out, g)
}
}
return out
}
// NewID mints a preset id. claude.md shows ULID-shaped ids in the example file,
// but nothing depends on them being sortable or on their length — an id only
// has to be unique and stable — so this stays dependency-free: a millisecond
// timestamp for rough ordering plus 64 bits of randomness.
func NewID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand does not fail in practice on Windows; if it somehow
// does, a timestamp-only id is still unique enough to save with.
return fmt.Sprintf("%013x", time.Now().UnixMilli())
}
return fmt.Sprintf("%013x%s", time.Now().UnixMilli(), hex.EncodeToString(b[:]))
}
+228
View File
@@ -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)
}
}