diff --git a/internal/core/endpoints_repeater_test.go b/internal/core/endpoints_repeater_test.go index 28d0ee3..0def8ad 100644 --- a/internal/core/endpoints_repeater_test.go +++ b/internal/core/endpoints_repeater_test.go @@ -149,6 +149,54 @@ func TestRepeaterCrudPosts(t *testing.T) { assertRedirect(t, delRep, "/", "delete repeater") } +// TestAddRepeaterExposePublicPage: the add form's "Publish a public page" +// checkbox is honored at creation (not only on the edit page). Present → the +// repeater publishes a public page; absent → it does not. +func TestAddRepeaterExposePublicPage(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + _, sess := appLogin(t, ts, st, ctx, h.app, "exposer") + + exposed := func(t *testing.T, values url.Values) bool { + t.Helper() + resp := post(t, ts, h.app, "/repeaters", values, sess) + resp.Body.Close() + loc, _ := url.Parse(resp.Header.Get("Location")) + parts := strings.Split(loc.Path, "/") + if resp.StatusCode != http.StatusSeeOther || len(parts) < 3 { + t.Fatalf("create repeater = %d %q, want 303 → /repeaters/{id}/added", resp.StatusCode, resp.Header.Get("Location")) + } + var got bool + if err := st.Pool().QueryRow(ctx, + `SELECT expose_public_page FROM repeaters WHERE public_id = $1`, parts[2]).Scan(&got); err != nil { + t.Fatalf("read expose_public_page: %v", err) + } + return got + } + + radio := func(v url.Values) url.Values { + v.Set("radio_freq_mhz", "869.525") + v.Set("radio_bw_khz", "250") + v.Set("radio_sf", "11") + v.Set("radio_cr", "5") + return v + } + + onID, _ := meshcore.GenerateLocalIdentity(rand.Reader) + if !exposed(t, radio(url.Values{ + "name": {"Public Rep"}, "public_key": {onID.String()}, "expose_public_page": {"1"}, + })) { + t.Error("expose_public_page=1 at add time did not publish a public page") + } + + offID, _ := meshcore.GenerateLocalIdentity(rand.Reader) + if exposed(t, radio(url.Values{ + "name": {"Private Rep"}, "public_key": {offID.String()}, + })) { + t.Error("repeater added without the checkbox should not publish a public page") + } +} + // #81 create link, #82 delete link, #85 share commands, #86 steward, #83 unshare, // #87 org participation. func TestRepeaterSharePosts(t *testing.T) { diff --git a/internal/core/repeater_setup.go b/internal/core/repeater_setup.go index ed74003..392bb96 100644 --- a/internal/core/repeater_setup.go +++ b/internal/core/repeater_setup.go @@ -250,14 +250,15 @@ func (s *Handlers) handleSetupComplete(w http.ResponseWriter, r *http.Request) { } rep, err := s.Store.CreateRepeater(r.Context(), &store.Repeater{ - OwnerID: uid, - Name: name, - PublicKeyHex: pubHex, - RadioFreqHz: freq, - RadioBwHz: bw, - RadioSF: int16(sf), - RadioCR: int16(cr), - ShowOnPublicOrg: r.FormValue("show_on_public_org") != "", + OwnerID: uid, + Name: name, + PublicKeyHex: pubHex, + RadioFreqHz: freq, + RadioBwHz: bw, + RadioSF: int16(sf), + RadioCR: int16(cr), + ShowOnPublicOrg: r.FormValue("show_on_public_org") != "", + ExposePublicPage: r.FormValue("expose_public_page") != "", }) if errors.Is(err, store.ErrDuplicate) { http.Error(w, "you already added a repeater with that public key", http.StatusConflict) diff --git a/internal/core/repeater_setup_test.go b/internal/core/repeater_setup_test.go index 007d632..e53698c 100644 --- a/internal/core/repeater_setup_test.go +++ b/internal/core/repeater_setup_test.go @@ -131,7 +131,7 @@ func TestSerialSetupFlow(t *testing.T) { complete := url.Values{ "name": {"Hilltop"}, "public_key": {pub}, "radio_freq_mhz": {"910.525"}, "radio_bw_khz": {"62.5"}, "radio_sf": {"7"}, "radio_cr": {"5"}, - "lat": {"15.0"}, "lon": {"35.0"}, + "lat": {"15.0"}, "lon": {"35.0"}, "expose_public_page": {"1"}, } cResp := post(t, ts, h.app, "/repeaters/setup/complete", complete, sess) if cResp.StatusCode != http.StatusOK { @@ -160,6 +160,9 @@ func TestSerialSetupFlow(t *testing.T) { if got.Latitude == nil || got.Longitude == nil || *got.Latitude != 15.0 || *got.Longitude != 35.0 { t.Errorf("location not stored: %v, %v", got.Latitude, got.Longitude) } + if !got.ExposePublicPage { + t.Error("expose_public_page from the setup form was not persisted") + } } // TestBuildSetupCommands locks the from-scratch command order (header → profile diff --git a/internal/core/repeaters.go b/internal/core/repeaters.go index 52d8df2..64b19fd 100644 --- a/internal/core/repeaters.go +++ b/internal/core/repeaters.go @@ -201,14 +201,15 @@ func (s *Handlers) handleAddRepeater(w http.ResponseWriter, r *http.Request) { } rep, err := s.Store.CreateRepeater(r.Context(), &store.Repeater{ - OwnerID: uid, - Name: name, - PublicKeyHex: pubHex, - RadioFreqHz: freq, - RadioBwHz: bw, - RadioSF: int16(sf), - RadioCR: int16(cr), - ShowOnPublicOrg: r.FormValue("show_on_public_org") != "", + OwnerID: uid, + Name: name, + PublicKeyHex: pubHex, + RadioFreqHz: freq, + RadioBwHz: bw, + RadioSF: int16(sf), + RadioCR: int16(cr), + ShowOnPublicOrg: r.FormValue("show_on_public_org") != "", + ExposePublicPage: r.FormValue("expose_public_page") != "", }) if errors.Is(err, store.ErrDuplicate) { addErr(w, r, "You already added a repeater with that public key.") diff --git a/internal/core/templates/add_repeater.html b/internal/core/templates/add_repeater.html index c2d4482..a0ad1dc 100644 --- a/internal/core/templates/add_repeater.html +++ b/internal/core/templates/add_repeater.html @@ -237,6 +237,10 @@ Show this repeater on your organizations' public map. +
@@ -313,6 +317,10 @@ Show this repeater on your organizations' public map. +
diff --git a/internal/e2e/add_repeater_public_page_test.go b/internal/e2e/add_repeater_public_page_test.go new file mode 100644 index 0000000..924eb5d --- /dev/null +++ b/internal/e2e/add_repeater_public_page_test.go @@ -0,0 +1,47 @@ +//go:build browser + +package e2e + +import ( + "context" + "testing" + "time" + + cdplog "github.com/chromedp/cdproto/log" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// TestE2EAddRepeaterPublicPageOption verifies the "Publish a public page" +// checkbox is offered on both add-repeater detail steps — the add-existing (kiss) +// form field and the USB-setup (serial) toggle that serial-setup.js reads — so +// the option isn't edit-page-only. Both steps must render clean under the CSP. +func TestE2EAddRepeaterPublicPageOption(t *testing.T) { + srv := newE2EServer(t) + _, cookie := srv.login(t, "pager") + + bctx, cancel, watch := startBrowser(t) + defer cancel() + + cases := []struct{ name, url, selector string }{ + {"add-existing", "/repeaters/add?step=details&method=kiss", `input[name="expose_public_page"]`}, + {"usb-setup", "/repeaters/add?step=serial&method=serial", `#expose_public_page`}, + } + for _, c := range cases { + runCtx, cancelRun := context.WithTimeout(bctx, 30*time.Second) + // WaitVisible on the checkbox itself proves it rendered and is visible. + err := chromedp.Run(runCtx, + network.Enable(), + cdplog.Enable(), + setSessionCookie(cookie), + chromedp.Navigate(srv.appURL+c.url), + chromedp.WaitVisible(c.selector, chromedp.ByQuery), + ) + cancelRun() + if err != nil { + t.Errorf("%s step: public-page checkbox (%s) not visible: %v", c.name, c.selector, err) + } + } + + watch.assertClean(t) +} diff --git a/internal/store/repeaters.go b/internal/store/repeaters.go index cee4d08..275d5e7 100644 --- a/internal/store/repeaters.go +++ b/internal/store/repeaters.go @@ -87,12 +87,12 @@ func (s *Store) CreateRepeater(ctx context.Context, r *Repeater) (*Repeater, err } var out Repeater err = s.pool.QueryRow(ctx, ` - INSERT INTO repeaters (public_id, owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, show_on_public_org) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, public_id, owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, confirmed, confirmed_at, created_at, show_on_public_org`, - publicID, r.OwnerID, r.Name, r.PublicKeyHex, r.RadioFreqHz, r.RadioBwHz, r.RadioSF, r.RadioCR, r.ShowOnPublicOrg). + INSERT INTO repeaters (public_id, owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, show_on_public_org, expose_public_page) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, public_id, owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, confirmed, confirmed_at, created_at, show_on_public_org, expose_public_page`, + publicID, r.OwnerID, r.Name, r.PublicKeyHex, r.RadioFreqHz, r.RadioBwHz, r.RadioSF, r.RadioCR, r.ShowOnPublicOrg, r.ExposePublicPage). Scan(&out.ID, &out.PublicID, &out.OwnerID, &out.Name, &out.PublicKeyHex, &out.RadioFreqHz, &out.RadioBwHz, - &out.RadioSF, &out.RadioCR, &out.Confirmed, &out.ConfirmedAt, &out.CreatedAt, &out.ShowOnPublicOrg) + &out.RadioSF, &out.RadioCR, &out.Confirmed, &out.ConfirmedAt, &out.CreatedAt, &out.ShowOnPublicOrg, &out.ExposePublicPage) if isUniqueViolation(err) { return nil, ErrDuplicate } diff --git a/internal/web/static/serial-setup.js b/internal/web/static/serial-setup.js index f318d88..8e89fa7 100644 --- a/internal/web/static/serial-setup.js +++ b/internal/web/static/serial-setup.js @@ -320,7 +320,11 @@ form.set("lat", loc.lat.toFixed(6)); form.set("lon", loc.lon.toFixed(6)); } - if ($("show_on_public_org").checked) form.set("show_on_public_org", "1"); + // Optional visibility toggles — guarded so a trimmed-down template can omit them. + var showOrg = $("show_on_public_org"); + if (showOrg && showOrg.checked) form.set("show_on_public_org", "1"); + var exposePage = $("expose_public_page"); + if (exposePage && exposePage.checked) form.set("expose_public_page", "1"); var resp = await fetch("/repeaters/setup/complete", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },