package web
// Group C (S2) + Group D (v0.47.0 multi-endpoint management) — the /offsite page.
// v0.47.0 amends the S2 render pins DELIBERATELY for the multi-card layout + Endpoint
// column; the pubkey-title and empty-/hosts/-href assertions are kept verbatim.
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func renderOffsite(t *testing.T, s *Server) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleOffsite(rr, httptest.NewRequest(http.MethodGet, "/offsite", nil))
if rr.Code != http.StatusOK {
t.Fatalf("offsite render = %d", rr.Code)
}
return rr.Body.String()
}
func postOffsiteEndpoint(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/offsite/endpoints", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleOffsiteEndpointSave(rr, req)
return rr
}
// validEndpointForm returns a fully-valid add form; tests mutate single fields.
func validEndpointForm(id string) url.Values {
return url.Values{
"endpoint_id": {id},
"dns_name": {id + ".felhom.eu"},
"wg_port": {"443"},
"server_pubkey": {"CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk="},
"tunnel_subnet": {"10.78.0.0/24"},
"pbs_tunnel_ip": {"10.78.0.1"},
}
}
func TestOffsite_NoEndpoint(t *testing.T) {
s, _ := newTestServer(t)
html := renderOffsite(t, s)
if !strings.Contains(html, "Not configured") {
t.Errorf("no-endpoint state missing: %s", html[:200])
}
if !strings.Contains(html, "No WireGuard peers registered") {
t.Errorf("empty-peers state missing")
}
// The add-endpoint form is offered even before the first endpoint exists.
if !strings.Contains(html, `action="/offsite/endpoints"`) {
t.Errorf("add-endpoint form missing")
}
}
// E1 — two endpoint cards render (ids visible), per-endpoint peer counts derive from
// subnet membership, and the peer table's Endpoint column shows the containing endpoint.
func TestOffsite_EndpointAndPeers(t *testing.T) {
s, st := newTestServer(t)
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
}); err != nil {
t.Fatal(err)
}
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 51820,
ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=",
TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1",
}); err != nil {
t.Fatal(err)
}
st.UpsertHost(&store.Host{HostID: "hv1", CustomerID: "c1", APIKey: "k"})
if _, _, err := st.RegisterWGPeerForHost("hv1", "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="); err != nil {
t.Fatal(err)
}
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "unbound-test"); err != nil {
t.Fatal(err)
}
html := renderOffsite(t, s)
for _, want := range []string{
`data-endpoint-id="ep0"`, // ep0 card
`data-endpoint-id="ep1"`, // ep1 card
"ep0.felhom.eu:443", // ep0 address
"ep1.felhom.eu:51820", // ep1 address
"10.77.0.0/24", // ep0 subnet
"10.77.0.1:8007", // ep0 PBS tunnel addr
"10.77.0.2/32", // bound peer ip
"10.77.0.3/32", // unbound peer ip
`href="/hosts/hv1"`, // bound peer links to its host
"unbound-test", // note column
} {
if !strings.Contains(html, want) {
t.Errorf("offsite page missing %q", want)
}
}
// Per-endpoint peer counts: both allocated peers live in ep0's subnet; ep1 has none.
ep0Card := html[strings.Index(html, `data-endpoint-id="ep0"`):strings.Index(html, `data-endpoint-id="ep1"`)]
ep1Card := html[strings.Index(html, `data-endpoint-id="ep1"`):]
if !strings.Contains(ep0Card, "
2 | ") {
t.Error("ep0 card missing peer count 2")
}
if !strings.Contains(ep1Card, "0 | ") {
t.Error("ep1 card missing peer count 0")
}
// The peer table's Endpoint column shows ep0 for both peers.
if got := strings.Count(html, "ep0 | "); got != 2 {
t.Errorf("Endpoint column shows ep0 %d times, want 2", got)
}
// Unbound peer renders an em-dash host cell, not a broken link.
if strings.Contains(html, `href="/hosts/"`) {
t.Error("unbound peer rendered an empty host link")
}
// Full pubkey rides the title attribute (truncated display).
if !strings.Contains(html, `title="AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="`) {
t.Error("full pubkey missing from title attr")
}
}
// E5 — a peer whose IP falls in NO endpoint's subnet renders Endpoint "—".
func TestOffsite_OrphanPeerEndpointColumn(t *testing.T) {
s, st := newTestServer(t)
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
}); err != nil {
t.Fatal(err)
}
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "orphan"); err != nil {
t.Fatal(err) // allocates 10.77.0.2
}
// Replace ep0 with an endpoint on a DIFFERENT subnet → the peer's IP matches nothing.
if err := st.DeleteWGEndpoint("ep0"); err != nil {
t.Fatal(err)
}
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 443,
ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=",
TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1",
}); err != nil {
t.Fatal(err)
}
html := renderOffsite(t, s)
if !strings.Contains(html, "10.77.0.2/32") {
t.Fatal("orphan peer missing from the table")
}
if strings.Contains(html, "ep1 | ") {
t.Error("orphan peer wrongly attributed to ep1 — must render em dash")
}
}
// E2 — a valid POST upserts via SetWGEndpoint and redirects back to the page.
func TestOffsiteEndpointSave_Add(t *testing.T) {
s, st := newTestServer(t)
rr := postOffsiteEndpoint(t, s, validEndpointForm("ep1"))
if rr.Code != http.StatusSeeOther {
t.Fatalf("add = %d (%s), want 303", rr.Code, rr.Body.String())
}
eps, err := st.ListWGEndpoints()
if err != nil || len(eps) != 1 {
t.Fatalf("endpoints after add = %v / %v, want 1", eps, err)
}
if eps[0].EndpointID != "ep1" || eps[0].DNSName != "ep1.felhom.eu" || eps[0].WGPort != 443 ||
eps[0].TunnelSubnet != "10.78.0.0/24" || eps[0].PBSTunnelIP != "10.78.0.1" {
t.Errorf("stored endpoint = %+v", eps[0])
}
// The new card renders.
if !strings.Contains(renderOffsite(t, s), `data-endpoint-id="ep1"`) {
t.Error("new endpoint card missing from the page")
}
}
// E6 — every invalid form field → 400 and NOTHING stored.
func TestOffsiteEndpointSave_Validation(t *testing.T) {
s, st := newTestServer(t)
cases := []struct {
name string
field string
value string
}{
{"bad cidr", "tunnel_subnet", "10.78.0.0/240"},
{"not a cidr", "tunnel_subnet", "banana"},
{"pbs ip outside subnet", "pbs_tunnel_ip", "10.99.0.1"},
{"pbs ip garbage", "pbs_tunnel_ip", "not-an-ip"},
{"port zero", "wg_port", "0"},
{"port too big", "wg_port", "70000"},
{"port garbage", "wg_port", "abc"},
{"empty pubkey", "server_pubkey", ""},
{"bad endpoint id", "endpoint_id", "EP 1!"},
{"empty endpoint id", "endpoint_id", ""},
{"empty dns", "dns_name", ""},
}
for _, c := range cases {
form := validEndpointForm("ep1")
form.Set(c.field, c.value)
rr := postOffsiteEndpoint(t, s, form)
if rr.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", c.name, rr.Code)
}
if eps, _ := st.ListWGEndpoints(); len(eps) != 0 {
t.Fatalf("%s: endpoint STORED despite invalid input: %+v", c.name, eps)
}
}
}
// E3 — editing ep0's tunnel_subnet while peers are allocated inside the CURRENT subnet is
// refused with 409 and the stored subnet is UNCHANGED.
// RED-PROOF 4: removing the subnet-change guard makes this FAIL (subnet rewritten).
func TestOffsiteEndpointSave_SubnetChangeGuard(t *testing.T) {
s, st := newTestServer(t)
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
}); err != nil {
t.Fatal(err)
}
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "pin"); err != nil {
t.Fatal(err) // 10.77.0.2 — inside the current subnet
}
form := validEndpointForm("ep0")
form.Set("tunnel_subnet", "10.99.0.0/24")
form.Set("pbs_tunnel_ip", "10.99.0.1")
rr := postOffsiteEndpoint(t, s, form)
if rr.Code != http.StatusConflict {
t.Fatalf("subnet change with peers = %d, want 409", rr.Code)
}
ep, err := st.GetWGEndpoint()
if err != nil {
t.Fatal(err)
}
if ep.TunnelSubnet != "10.77.0.0/24" {
t.Errorf("stored subnet = %s — the refused edit CHANGED it", ep.TunnelSubnet)
}
// Non-subnet edits stay allowed while peers exist (e.g. a port move).
form = validEndpointForm("ep0")
form.Set("tunnel_subnet", "10.77.0.0/24")
form.Set("pbs_tunnel_ip", "10.77.0.1")
form.Set("wg_port", "51820")
if rr := postOffsiteEndpoint(t, s, form); rr.Code != http.StatusSeeOther {
t.Errorf("same-subnet edit = %d, want 303", rr.Code)
}
if ep, _ := st.GetWGEndpoint(); ep.WGPort != 51820 {
t.Errorf("port edit not stored: %+v", ep)
}
}
// E4 — deleting an endpoint with peers allocated in its subnet is refused with 409 and the
// row survives; a peer-free endpoint deletes cleanly; an unknown one 404s.
// RED-PROOF 3: removing the peers-in-subnet guard makes this FAIL (endpoint deleted).
func TestOffsiteEndpointDelete_Guard(t *testing.T) {
s, st := newTestServer(t)
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
}); err != nil {
t.Fatal(err)
}
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep1", DNSName: "ep1.felhom.eu", WGPort: 443,
ServerPubkey: "CgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=",
TunnelSubnet: "10.78.0.0/24", PBSTunnelIP: "10.78.0.1",
}); err != nil {
t.Fatal(err)
}
if _, _, err := st.AddWGPeer("AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", "", "pin"); err != nil {
t.Fatal(err) // 10.77.0.2 — pins ep0
}
del := func(id string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/offsite/endpoints/"+id+"/delete", nil)
rr := httptest.NewRecorder()
s.handleOffsiteEndpointDelete(rr, req, id)
return rr
}
if rr := del("ep0"); rr.Code != http.StatusConflict {
t.Fatalf("delete with peers = %d, want 409", rr.Code)
}
if eps, _ := st.ListWGEndpoints(); len(eps) != 2 {
t.Fatalf("endpoint count after refused delete = %d, want 2 (ep0 must survive)", len(eps))
}
if rr := del("ep1"); rr.Code != http.StatusSeeOther {
t.Errorf("peer-free delete = %d, want 303", rr.Code)
}
if eps, _ := st.ListWGEndpoints(); len(eps) != 1 || eps[0].EndpointID != "ep0" {
t.Errorf("endpoints after ep1 delete = %+v, want [ep0]", eps)
}
if rr := del("ghost"); rr.Code != http.StatusNotFound {
t.Errorf("unknown endpoint delete = %d, want 404", rr.Code)
}
}