package switcher import ( "errors" "path/filepath" "strings" "sync" "testing" "time" "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" "gitea.apointless.space/bsncubed/ipswap/internal/preset" ) // fakeManager records what was applied and lets a test drive adapter state. type fakeManager struct { mu sync.Mutex live map[string]netcfg.LiveConfig applied [][]netcfg.Command err error // onApply runs inside Apply, for exercising concurrency. onApply func() } func (f *fakeManager) Adapters() ([]netcfg.Adapter, error) { return []netcfg.Adapter{{Name: "Ethernet", Up: true}}, nil } func (f *fakeManager) Current(adapter string) (netcfg.LiveConfig, error) { f.mu.Lock() defer f.mu.Unlock() cfg, ok := f.live[adapter] if !ok { return netcfg.LiveConfig{}, errors.New("adapter not found") } return cfg, nil } func (f *fakeManager) Apply(plan []netcfg.Command) error { if f.onApply != nil { f.onApply() } f.mu.Lock() defer f.mu.Unlock() f.applied = append(f.applied, plan) return f.err } func setup(t *testing.T) (*Switcher, *preset.Store, *fakeManager) { t.Helper() store, err := preset.NewStore(filepath.Join(t.TempDir(), "presets.json")) if err != nil { t.Fatal(err) } mgr := &fakeManager{live: map[string]netcfg.LiveConfig{ "Ethernet": { Adapter: "Ethernet", DHCP: true, Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}}, Gateways: []string{"192.168.1.1"}, DNS: []string{"192.168.1.1"}, DNSFromDHCP: true, }, }} return New(store, mgr), store, mgr } func staticPreset() preset.Preset { return preset.Preset{ Name: "Control", Group: "Riedel", 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"}}, } } func TestPreviewReadsLiveState(t *testing.T) { sw, store, _ := setup(t) saved, err := store.Put(staticPreset()) if err != nil { t.Fatal(err) } p, live, err := sw.Preview(saved.ID) if err != nil { t.Fatal(err) } if p.Name != "Control" { t.Errorf("preview returned preset %q", p.Name) } if len(live.Addresses) != 1 || live.Addresses[0].Address != "192.168.1.87" { t.Errorf("preview did not return the live config: %+v", live) } } func TestPreviewUnknownID(t *testing.T) { sw, _, _ := setup(t) if _, _, err := sw.Preview("nope"); !errors.Is(err, ErrNotFound) { t.Errorf("Preview of an unknown id = %v, want ErrNotFound", err) } } func TestApplyRunsThePlan(t *testing.T) { sw, store, mgr := setup(t) saved, err := store.Put(staticPreset()) if err != nil { t.Fatal(err) } if err := sw.Apply(saved.ID); err != nil { t.Fatal(err) } if len(mgr.applied) != 1 { t.Fatalf("expected one plan to be applied, got %d", len(mgr.applied)) } joined := "" for _, c := range mgr.applied[0] { joined += c.String() + "\n" } for _, want := range []string{"192.168.42.100", "255.255.255.0", "10.0.10.50", "dnsservers"} { if !strings.Contains(joined, want) { t.Errorf("plan missing %q:\n%s", want, joined) } } } // The live read happens inside Apply, not carried over from Preview: between // the prompt appearing and the user clicking yes, the adapter can change. func TestApplyRereadsLiveState(t *testing.T) { sw, store, mgr := setup(t) saved, err := store.Put(staticPreset()) if err != nil { t.Fatal(err) } if _, _, err := sw.Preview(saved.ID); err != nil { t.Fatal(err) } // The adapter picks up two static addresses after the preview. mgr.mu.Lock() mgr.live["Ethernet"] = netcfg.LiveConfig{ Adapter: "Ethernet", Addresses: []preset.Address{{Address: "10.1.1.1", Prefix: 24}, {Address: "10.2.2.2", Prefix: 24}}, } mgr.mu.Unlock() if err := sw.Apply(saved.ID); err != nil { t.Fatal(err) } var deletes int for _, c := range mgr.applied[0] { if c.Args[2] == "delete" { deletes++ } } if deletes != 2 { t.Errorf("expected 2 deletes for the addresses present at apply time, got %d", deletes) } } func TestApplyUnknownID(t *testing.T) { sw, _, _ := setup(t) if err := sw.Apply("nope"); !errors.Is(err, ErrNotFound) { t.Errorf("Apply of an unknown id = %v, want ErrNotFound", err) } } // Two front ends can now start an apply. Interleaving two netsh sequences on // one adapter would leave it matching neither preset. func TestApplyIsSerialised(t *testing.T) { sw, store, mgr := setup(t) saved, err := store.Put(staticPreset()) if err != nil { t.Fatal(err) } var concurrent, maxConcurrent int var mu sync.Mutex mgr.onApply = func() { mu.Lock() concurrent++ if concurrent > maxConcurrent { maxConcurrent = concurrent } mu.Unlock() // Hold the "apply" open long enough that an unserialised second one // would be observed overlapping. Without this the counter would go up // and straight back down and the test could never fail. time.Sleep(5 * time.Millisecond) mu.Lock() concurrent-- mu.Unlock() } var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() _ = sw.Apply(saved.ID) }() } wg.Wait() if maxConcurrent > 1 { t.Errorf("applies overlapped: max concurrency %d", maxConcurrent) } if len(mgr.applied) != 8 { t.Errorf("expected 8 applies, got %d", len(mgr.applied)) } } func TestActiveMatchesLivePreset(t *testing.T) { sw, store, mgr := setup(t) dhcp := preset.Preset{ Name: "DHCP", Adapter: "Ethernet", Mode: preset.ModeDHCP, DNS: preset.DNS{Mode: preset.ModeDHCP}, } saved, err := store.Put(dhcp) if err != nil { t.Fatal(err) } if _, err := store.Put(staticPreset()); err != nil { t.Fatal(err) } st := sw.Active() if st.ActiveID != saved.ID { t.Errorf("active id = %q, want %q (the DHCP preset matches the live state)", st.ActiveID, saved.ID) } if st.ActiveName != "DHCP" { t.Errorf("active name = %q", st.ActiveName) } if _, ok := st.Adapters["Ethernet"]; !ok { t.Error("Active should report the adapter state it read") } // Nothing matches once the adapter moves to a static address. mgr.mu.Lock() mgr.live["Ethernet"] = netcfg.LiveConfig{ Adapter: "Ethernet", Addresses: []preset.Address{{Address: "172.16.0.9", Prefix: 24}}, } mgr.mu.Unlock() if st := sw.Active(); st.ActiveID != "" { t.Errorf("expected no active preset, got %q", st.ActiveName) } } // Fifty presets across three adapters should be three reads, not fifty. func TestActiveReadsEachAdapterOnce(t *testing.T) { sw, store, mgr := setup(t) for i := 0; i < 10; i++ { p := staticPreset() p.Name = "preset" if _, err := store.Put(p); err != nil { t.Fatal(err) } } st := sw.Active() if len(st.Adapters) != 1 { t.Errorf("expected one adapter read, got %d", len(st.Adapters)) } _ = mgr }