Files
ipswap/internal/server/server_test.go
T
bsncubed f267c430d9 Add installer, launch-to-browser, and switching from the web GUI
Three features plus the design-system swap.

Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap
with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches
the app's asInvoker manifest, and it is also what lets the in-app updater
replace the .exe later without elevation — a Program Files install could not.
It offers a desktop shortcut and an optional start-with-Windows entry, and
reuses the app's own single-instance mutex as AppMutex so setup notices a
running copy, since a running .exe cannot be overwritten. Uninstall leaves
presets.json, config.json and the log in place.

Launching. Opening ipswap opens the editor in a browser, starting the tray
first if it is not already up; if it is, the running copy opens the browser and
the second process exits. internal/instance does this with a loopback control
listener whose port and token live in a mode-600 session.json, plus a
session-local named mutex on Windows to settle a launch race. A stale session
file from a crash is detected by a failed call and treated as "no primary", so
it can never wedge startup. The Run-key entry now passes --background, because
a browser tab at every login is not wanted.

Switching from the browser. New endpoints for active state, apply preview and
apply. The browser confirms against the same before/after text the tray's
MessageBox shows, built from a live read at prompt time. Apply requires the
session token in a header like every other mutation — it changes the machine's
network, so it is not a weaker case than editing a preset. A netsh refusal for
lack of elevation comes back as 409 with a flag, so the page can point at
"Relaunch as administrator" instead of showing a generic failure.

To avoid two copies of that sequence, internal/switcher now owns everything
between "the user said yes" and "the adapter changed", and both front ends call
it. It serialises applies: two interleaved netsh sequences on one adapter would
leave it matching neither preset, and there are now two ways to start one.

CSS. apointless.css is vendored from bsncubed/css and left untouched; the
previous file was the boarding-pass stylesheet and its class names did not
match this markup. ipswap.css adds only what the design system does not ship —
page chrome, tables, modals, stat tiles, a success alert — on its tokens, so
re-pulling apointless.css restyles the app.

Not verified: the installer is uncompiled (no Inno Setup or wine on this box)
and the UI is not visually rendered (headless Firefox hangs here). Both are
checked as far as the tooling allows — assets and endpoints serve, and every
DOM id the JS touches exists in the markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:32:15 +10:00

319 lines
9.1 KiB
Go

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"
"gitea.apointless.space/bsncubed/ipswap/internal/switcher"
)
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")}
mgr := netcfg.New()
s := New(store, switcher.New(store, mgr), mgr, 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)
}
}
// createPreset returns the id of a freshly saved DHCP preset.
func createPreset(t *testing.T, base, token string) string {
t.Helper()
body := `{"name":"DHCP","group":"General","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}`
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("could not create preset: %d %s", resp.StatusCode, b)
}
var p preset.Preset
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
t.Fatal(err)
}
return p.ID
}
func TestActiveReportsAdapterState(t *testing.T) {
_, base, token := newTestServer(t)
createPreset(t, base, token)
resp := do(t, "GET", base+"/api/active", token, "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var st struct {
ActiveID string `json:"active_id"`
Adapters map[string]netcfg.LiveConfig `json:"adapters"`
}
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
t.Fatal(err)
}
if _, ok := st.Adapters["Ethernet"]; !ok {
t.Errorf("expected the preset's adapter to be reported, got %+v", st.Adapters)
}
}
// The preview is what the browser confirms against, so it must carry the same
// before/after text the tray's MessageBox shows.
func TestPreviewReturnsConfirmText(t *testing.T) {
_, base, token := newTestServer(t)
id := createPreset(t, base, token)
resp := do(t, "GET", base+"/api/presets/"+id+"/preview", token, "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out struct {
Text string `json:"text"`
Current netcfg.LiveConfig `json:"current"`
Preset preset.Preset `json:"preset"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
for _, want := range []string{"CURRENT", "NEW", `Apply preset "DHCP"`} {
if !strings.Contains(out.Text, want) {
t.Errorf("preview text missing %q:\n%s", want, out.Text)
}
}
if out.Preset.ID != id {
t.Errorf("preview returned preset %q, want %q", out.Preset.ID, id)
}
}
func TestPreviewUnknownPresetIs404(t *testing.T) {
_, base, token := newTestServer(t)
resp := do(t, "GET", base+"/api/presets/nope/preview", token, "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
}
// Applying changes the machine's network, so it must be as protected as any
// other mutation — a cookie alone must not be enough.
func TestApplyRequiresTokenHeader(t *testing.T) {
_, base, token := newTestServer(t)
id := createPreset(t, base, token)
resp := do(t, "POST", base+"/api/presets/"+id+"/apply", token, "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("cookie-only apply should be 403, got %d", resp.StatusCode)
}
}
func TestApplyUnknownPresetIs404(t *testing.T) {
_, base, token := newTestServer(t)
resp := do(t, "POST", base+"/api/presets/nope/apply", token, token, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, 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")
}
}