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") } }