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,214 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/config"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, string, string) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
store, err := preset.NewStore(filepath.Join(dir, "presets.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := config.Paths{Dir: dir, Presets: filepath.Join(dir, "presets.json"), Config: filepath.Join(dir, "config.json")}
|
||||
|
||||
s := New(store, netcfg.New(), paths, config.Default())
|
||||
raw, err := s.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, "http://" + u.Host, u.Query().Get("t")
|
||||
}
|
||||
|
||||
// do issues a request with no cookie jar, so each call is judged purely on
|
||||
// what it carries.
|
||||
func do(t *testing.T, method, url, token, header, body string) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: "ipswap_session", Value: token})
|
||||
}
|
||||
if header != "" {
|
||||
req.Header.Set(tokenHeader, header)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestServesOnLoopbackOnly(t *testing.T) {
|
||||
_, base, _ := newTestServer(t)
|
||||
if !strings.HasPrefix(base, "http://127.0.0.1:") {
|
||||
t.Errorf("editor must bind loopback only, got %s", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsMissingToken(t *testing.T) {
|
||||
_, base, _ := newTestServer(t)
|
||||
|
||||
resp := do(t, "GET", base+"/api/presets", "", "", "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("no token should be 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsWrongToken(t *testing.T) {
|
||||
_, base, _ := newTestServer(t)
|
||||
|
||||
resp := do(t, "GET", base+"/api/presets", strings.Repeat("a", 64), "", "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a wrong token should be 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTokenSetsCookie(t *testing.T) {
|
||||
_, base, token := newTestServer(t)
|
||||
|
||||
resp := do(t, "GET", base+"/api/presets?t="+token, "", "", "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("query token should authenticate, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "ipswap_session" {
|
||||
found = true
|
||||
if c.SameSite != http.SameSiteStrictMode {
|
||||
t.Error("session cookie must be SameSite=Strict")
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Error("session cookie must be HttpOnly")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected a session cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// A cookie alone must not be enough to mutate: any page in the browser can
|
||||
// make the browser send this cookie to 127.0.0.1, but it cannot set a header.
|
||||
func TestMutationRequiresHeaderNotJustCookie(t *testing.T) {
|
||||
_, base, token := newTestServer(t)
|
||||
|
||||
body := `{"name":"x","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}`
|
||||
|
||||
resp := do(t, "POST", base+"/api/presets", token, "", body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("cookie-only mutation should be 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp2 := do(t, "POST", base+"/api/presets", token, token, body)
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp2.Body)
|
||||
t.Errorf("cookie plus header should succeed, got %d: %s", resp2.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCRUD(t *testing.T) {
|
||||
_, base, token := newTestServer(t)
|
||||
|
||||
body := `{"name":"Control","group":"Riedel","adapter":"Ethernet","mode":"static",
|
||||
"primary":{"address":"192.168.42.100","prefix":24,"gateway":"192.168.42.1"},
|
||||
"dns":{"mode":"static","servers":["192.168.42.1"]}}`
|
||||
|
||||
resp := do(t, "POST", base+"/api/presets", token, token, body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("create failed: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var created preset.Preset
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatal("server should assign an id")
|
||||
}
|
||||
|
||||
list := do(t, "GET", base+"/api/presets", token, "", "")
|
||||
defer list.Body.Close()
|
||||
var got struct {
|
||||
Presets []preset.Preset `json:"presets"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := json.NewDecoder(list.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Presets) != 1 || got.Presets[0].Name != "Control" {
|
||||
t.Fatalf("list returned %+v", got.Presets)
|
||||
}
|
||||
|
||||
del := do(t, "DELETE", base+"/api/presets/"+created.ID, token, token, "")
|
||||
defer del.Body.Close()
|
||||
if del.StatusCode != http.StatusOK {
|
||||
t.Errorf("delete failed: %d", del.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidPresetIsRejected(t *testing.T) {
|
||||
_, base, token := newTestServer(t)
|
||||
|
||||
body := `{"name":"bad","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}`
|
||||
resp := do(t, "POST", base+"/api/presets", token, token, body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a static preset with no primary should be 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopRejectsSubsequentRequests(t *testing.T) {
|
||||
s, base, token := newTestServer(t)
|
||||
s.Stop()
|
||||
|
||||
if _, err := http.Get(base + "/api/presets?t=" + token); err == nil {
|
||||
t.Error("expected the listener to be closed after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexIsServed(t *testing.T) {
|
||||
_, base, token := newTestServer(t)
|
||||
|
||||
resp := do(t, "GET", base+"/?t="+token, "", "", "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("index should be served, got %d", resp.StatusCode)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(b), "ipswap") {
|
||||
t.Error("index.html does not look like the editor")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user