Transfer repeater ownership

This commit is contained in:
Jonathon Leight
2026-07-30 22:17:52 -04:00
parent 799b9d1d0c
commit fb7b45dbdf
22 changed files with 1101 additions and 67 deletions
+11 -2
View File
@@ -23,6 +23,15 @@ func appLogin(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Co
if err != nil {
t.Fatalf("create user: %v", err)
}
return u, appSession(t, ts, st, ctx, host, u)
}
// appSession signs an EXISTING user in, returning an app-host session cookie. It
// is appLogin without the account creation, for tests that need a session for
// someone another part of the fixture already created (a steward, a share
// recipient) — calling appLogin for them would fail on the duplicate username.
func appSession(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Context, host string, u *store.User) *http.Cookie {
t.Helper()
loginID, err := st.CreateLogin(ctx, u.ID)
if err != nil {
t.Fatalf("create login: %v", err)
@@ -35,9 +44,9 @@ func appLogin(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Co
resp.Body.Close()
c := cookieByName(resp, "meshtender_session")
if c == nil {
t.Fatalf("no app session cookie after handoff for %q", username)
t.Fatalf("no app session cookie after handoff for %q", u.Username)
}
return u, c
return c
}
// testConfig/testAuthConfig give the integration tests the app/auth/root hosts,
+2 -1
View File
@@ -104,9 +104,10 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) {
"Radio": radio,
"ContactURI": contactURI,
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
}
// When the owner has published a public page, surface its link plus a QR code
// to print and place inside the enclosure (the NFC/QR tap target).
// for it.
if isOwner && rep.ExposePublicPage {
publicURL := s.Origin(r, s.rootHost()) + "/r/" + rep.PublicID
data["PublicPageURL"] = publicURL
+23 -27
View File
@@ -61,19 +61,17 @@
</p>
<div class="alert alert-danger" role="alert">
<div class="d-flex">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">This grants full admin control to MeshTender</h3>
<ul class="mb-0 text-secondary">
<li>This grants admin to <strong>MeshTender itself</strong><strong>not</strong> to other
MeshTender users. Letting specific people operate the repeater is a separate step you control later
via sharing and contributing to organizations.</li>
<li>MeshTender will not abuse this access — but that is a <strong>promise, not a technical limit</strong>.</li>
<li>This is a standing grant. It remains until <strong>you</strong> revoke it on the repeater
(see below) — removing the repeater from this site does not revoke MeshTender's access.</li>
</ul>
</div>
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">This grants full admin control to MeshTender</h3>
<ul class="mb-0 text-secondary">
<li>This grants admin to <strong>MeshTender itself</strong><strong>not</strong> to other
MeshTender users. Letting specific people operate the repeater is a separate step you control later
via sharing and contributing to organizations.</li>
<li>MeshTender will not abuse this access — but that is a <strong>promise, not a technical limit</strong>.</li>
<li>This is a standing grant. It remains until <strong>you</strong> revoke it on the repeater
(see below) — removing the repeater from this site does not revoke MeshTender's access.</li>
</ul>
</div>
</div>
@@ -81,18 +79,16 @@
{{/* Add-existing only: USB setup sets the location explicitly, so there's no
fetch to disclose there. */}}
<div class="alert alert-info" role="alert">
<div class="d-flex">
<div>{{template "icon-map-pin" "alert-icon"}}</div>
<div>
<h3 class="alert-title">MeshTender will read the repeater's location</h3>
<p class="text-secondary mb-0">
When you confirm this repeater — or use “Fetch location” later — MeshTender reads its coordinates by
running <code>get lat</code> / <code>get lon</code> through your modem, <strong>on its own behalf</strong>
(not as a command you or anyone you share with runs). It uses them to place the repeater on the map and to
tailor its <code>region</code> configuration. You stay in control: you can keep it off the public map, and
you decide which commands other people may run on it.
</p>
</div>
<div>{{template "icon-map-pin" "alert-icon"}}</div>
<div>
<h3 class="alert-title">MeshTender will read the repeater's location</h3>
<p class="text-secondary mb-0">
When you confirm this repeater — or use “Fetch location” later — MeshTender reads its coordinates by
running <code>get lat</code> / <code>get lon</code> through your modem, <strong>on its own behalf</strong>
(not as a command you or anyone you share with runs). It uses them to place the repeater on the map and to
tailor its <code>region</code> configuration. You stay in control: you can keep it off the public map, and
you decide which commands other people may run on it.
</p>
</div>
</div>
{{end}}
@@ -241,7 +237,7 @@
</label>
<label class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="expose_public_page">
<span class="form-check-label">Publish a <strong>public page</strong> for this repeater (the NFC/QR target) with its docs, stewards, and location.</span>
<span class="form-check-label">Publish a <strong>public page</strong> for this repeater with its docs, stewards, and location.</span>
</label>
<div class="btn-list mt-3">
@@ -321,7 +317,7 @@
</label>
<label class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="expose_public_page" value="1">
<span class="form-check-label">Publish a <strong>public page</strong> for this repeater (the NFC/QR target) with its docs, stewards, and location.</span>
<span class="form-check-label">Publish a <strong>public page</strong> for this repeater with its docs, stewards, and location.</span>
</label>
<div class="btn-list mt-3">
+17 -6
View File
@@ -14,16 +14,27 @@
all go away. This can't be undone.
</p>
<div class="alert alert-danger" role="alert">
<div class="d-flex">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">This does not revoke MeshTender's access</h3>
<div class="text-secondary">Your repeater still trusts MeshTender's identity at the firmware level. If you want MeshTender to truly lose access, revoke it on the device too — see below.</div>
{{/* Deleting is rarely what someone wants when a node stays on the air and
only its keeper is changing — offer the handover before the red button. */}}
<div class="alert alert-info" role="alert">
<div>
<h3 class="alert-title">Is this repeater staying on the air?</h3>
<div class="text-secondary">
If someone else is taking it over, <a href="/repeaters/{{.Repeater.PublicID}}/transfer">transfer
ownership to a steward</a> instead. Its documentation, maintenance history, and public page
address survive the handover; deleting throws all of that away.
</div>
</div>
</div>
<div class="alert alert-danger" role="alert">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">This does not revoke MeshTender's access</h3>
<div class="text-secondary">Your repeater still trusts MeshTender's identity at the firmware level. If you want MeshTender to truly lose access, revoke it on the device too — see below.</div>
</div>
</div>
{{template "revokedoc" .RevokeCommand}}
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/delete" class="mt-3">
+1 -1
View File
@@ -42,7 +42,7 @@
<label class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="expose_public_page" value="1"{{if .Repeater.ExposePublicPage}} checked{{end}}>
<span class="form-check-label">Publish a <strong>public page</strong> for this repeater (the NFC/QR target) with its docs, stewards, and location.</span>
<span class="form-check-label">Publish a <strong>public page</strong> for this repeater with its docs, stewards, and location.</span>
</label>
<div class="btn-list mt-3">
+5 -4
View File
@@ -3,6 +3,7 @@
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="row row-cards">
<!-- Left column -->
@@ -110,22 +111,22 @@
<div class="card-header"><h2 class="card-title">Public page</h2></div>
<div class="card-body">
{{if and .Repeater.ExposePublicPage .PublicPageURL}}
<p class="text-secondary small">Anyone with this link (or who taps an NFC tag / scans the QR inside the enclosure) sees the public documentation and stewards.</p>
<p class="text-secondary small">Anyone with this link sees the public documentation and stewards.</p>
<div class="input-group mb-2">
<code class="form-control font-monospace" id="public-url">{{.PublicPageURL}}</code>
<button type="button" class="btn" data-copy-target="#public-url">{{template "icon-copy" ""}}</button>
</div>
{{if .PublicPageQR}}
<button class="btn btn-sm w-100" type="button" data-bs-toggle="collapse" data-bs-target="#public-qr" aria-expanded="false" aria-controls="public-qr">{{template "icon-qrcode" "me-1"}}<span class="collapse-show">Show</span><span class="collapse-hide">Hide</span>&nbsp;enclosure QR code</button>
<button class="btn btn-sm w-100" type="button" data-bs-toggle="collapse" data-bs-target="#public-qr" aria-expanded="false" aria-controls="public-qr">{{template "icon-qrcode" "me-1"}}<span class="collapse-show">Show</span><span class="collapse-hide">Hide</span>&nbsp;QR code</button>
<div class="collapse" id="public-qr">
<div class="text-center mt-3">
<img src="{{.PublicPageQR}}" width="200" height="200" class="img-fluid" style="max-width:200px" alt="Public page QR code for {{.Repeater.Name}}">
<p class="text-secondary small mt-2 mb-0">Print and place inside the enclosure. Tapping/scanning opens this repeater's public page.</p>
<p class="text-secondary small mt-2 mb-0">Scanning it opens this repeater's public page.</p>
</div>
</div>
{{end}}
{{else if not .Repeater.ExposePublicPage}}
<p class="text-secondary small mb-0">{{template "icon-world" "me-1"}}No public page. Enable one in <a href="/repeaters/{{.Repeater.PublicID}}/edit">Edit details</a> to get a link and QR code for the enclosure.</p>
<p class="text-secondary small mb-0">{{template "icon-world" "me-1"}}No public page. Enable one in <a href="/repeaters/{{.Repeater.PublicID}}/edit">Edit details</a> to get a link and QR code.</p>
{{end}}
</div>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@
</div>
<div class="card-body">
<p class="text-secondary">
What this node is and how to service it — safe for anyone who taps the NFC tag or scans the QR code.
What this node is and how to service it — safe for anyone who can reach the public page.
Shown on the public page when one is published.
<strong>Do not put gate codes, lock combinations, or other site-access secrets here</strong> — use the internal section below for those.
</p>
+16 -3
View File
@@ -75,8 +75,16 @@
</div>
<div class="card mt-3">
<div class="card-body">
<div class="card-header">
<h2 class="card-title">People with access</h2>
<div class="card-actions">
<button type="button" class="btn" data-testid="transfer-ownership"
data-bs-toggle="modal" data-bs-target="#transfer-modal"
hx-get="/repeaters/{{.Repeater.PublicID}}/transfer"
hx-target="#transfer-modal-content" hx-swap="innerHTML">Transfer ownership</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary">
Mark a trusted person a <strong>steward</strong> to make them a co-operator: they can run
<strong>every</strong> command (including risky ones), just like you, and they're listed as a
@@ -124,8 +132,8 @@
</div>
</div>
<!-- Shared modals; the People "Create single-use link" button and each org's
"Manage access" button load their fragments here via htmx. -->
<!-- Shared modals; the "Create single-use link", per-person "Manage access",
and "Transfer ownership" buttons load their fragments here via htmx. -->
<div class="modal modal-blur fade" id="invite-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content" id="invite-modal-content"></div>
@@ -136,6 +144,11 @@
<div class="modal-content" id="person-modal-content"></div>
</div>
</div>
<div class="modal modal-blur fade" id="transfer-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content" id="transfer-modal-content"></div>
</div>
</div>
{{template "limits-modal-shell" .}}
<a class="back-link mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
@@ -0,0 +1,127 @@
{{/* Transfer ownership, in two presentations over ONE copy of the content: the
"transfer-modal" fragment htmx swaps into the share page's #transfer-modal,
and a standalone page for direct links (the delete page offers one) and for
browsers that never ran the htmx request. The shared blocks below are the
single source of the copy, so the two can't drift apart.
The form posts normally — a full navigation back to the repeater page, which
also closes the modal — exactly as the sibling invite/person-access modals
do. No client JS beyond ui.js's [data-confirm] gate. */}}
{{/* The recipient picker, or the explainer when there's nobody eligible. */}}
{{define "transfer-choices"}}
{{if .Stewards}}
<div class="list-group list-group-flush mb-3">
{{range $i, $s := .Stewards}}
<label class="list-group-item px-0 d-flex align-items-center gap-2" data-testid="steward-choice">
<input class="form-check-input m-0" type="radio" name="user_id" value="{{$s.UserID}}"
{{if eq $i 0}}checked{{end}} required>
<span class="fw-bold">{{$s.Name}}</span>
<span class="text-secondary">@{{$s.Username}}</span>
</label>
{{end}}
</div>
{{/* Icon and body are DIRECT children of .alert: it's a flex row with its own
gap, so wrapping them in a .d-flex (which has none) butts the icon against
the title. */}}
<div class="alert alert-warning mb-0" role="alert">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">What changes for you</h3>
<div class="text-secondary">
You become a <strong>steward</strong> of this repeater, so you keep full access to operate it —
but the new owner controls its settings, sharing, and whether it stays published, and they can
remove your access. Any unused share links you created are revoked. Only the new owner can
transfer it back.
</div>
</div>
</div>
{{else}}
{{/* No stewards yet: the fix is on the sharing page, so say so plainly rather
than rendering an empty picker. */}}
{{/* One child element: .alert is a flex ROW, so a bare heading beside its body
text lays out as two columns instead of a title above a paragraph. */}}
<div class="alert alert-info mb-0" role="alert">
<div>
<h3 class="alert-title">No stewards yet</h3>
<div class="text-secondary">
You can only transfer a repeater to a steward — someone you've shared it with and marked as a
co-maintainer. Share it with the person taking over, then turn on <strong>Steward</strong> in their
“Manage access” panel, and come back here.
</div>
</div>
</div>
{{end}}
{{end}}
{{define "transfer-intro"}}
<p class="text-secondary">
Hand “{{.Repeater.Name}}” to one of its stewards. The repeater keeps everything that makes it findable
and serviceable — its documentation, maintenance history, command log, location, and its public page
address, which keeps working unchanged.
</p>
{{if .Stewards}}
<p class="text-secondary">
Only stewards can receive a repeater: they already run every command you can and are listed publicly as
backup maintainers, so they're the people you've already trusted with it.
</p>
{{end}}
{{end}}
{{/* --- modal presentation --- */}}
{{define "transfer-modal"}}
<div class="modal-header">
<h2 class="modal-title">Transfer ownership</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{template "transfer-intro" .}}
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/transfer" id="transfer-form"
data-confirm="Transfer this repeater? The new owner controls it from then on, and you can't undo this yourself.">
{{template "transfer-choices" .}}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">Cancel</button>
{{if .Stewards}}
<button type="submit" form="transfer-form" class="btn btn-primary" data-testid="confirm-transfer">Transfer ownership</button>
{{else}}
<a class="btn btn-primary" href="/repeaters/{{.Repeater.PublicID}}/share">Go to sharing</a>
{{end}}
</div>
{{end}}
{{/* --- standalone page presentation --- */}}
{{define "title"}}Transfer {{.Repeater.Name}} · MeshTender{{end}}
{{define "header"}}{{template "repeater-header" .Nav}}{{end}}
{{define "content"}}
{{template "repeater-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-header"><h2 class="card-title">Transfer ownership</h2></div>
<div class="card-body">
{{template "transfer-intro" .}}
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/transfer" class="mt-3"
data-confirm="Transfer this repeater? The new owner controls it from then on, and you can't undo this yourself.">
{{template "transfer-choices" .}}
<div class="btn-list mt-3">
{{if .Stewards}}
<button type="submit" class="btn btn-primary" data-testid="confirm-transfer">Transfer ownership</button>
{{end}}
<a class="btn" href="/repeaters/{{.Repeater.PublicID}}/share">
{{if .Stewards}}Cancel{{else}}Go to sharing{{end}}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<a class="back-link mt-3" href="/repeaters/{{.Repeater.PublicID}}/share">{{template "icon-arrow-left" "me-1"}}Back to sharing</a>
{{end}}
+89
View File
@@ -0,0 +1,89 @@
package core
import (
"errors"
"net/http"
"strconv"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// Ownership transfer hands a repeater to one of its stewards — the co-maintainers
// the owner already designated. It is the alternative to deleting a node when its
// owner moves on: the site's documentation, history, and public page address stay
// with the repeater instead of going away with the account.
// transferPath is the transfer page for a repeater, by public id.
func transferPath(publicID string) string { return "/repeaters/" + publicID + "/transfer" }
// transferErr bounces back to the transfer page with an error banner, so the
// owner can pick a different steward rather than losing the page.
func transferErr(w http.ResponseWriter, r *http.Request, msg string) {
web.RedirectErr(w, r, transferPath(repeaterParam(r)), msg)
}
// pageTransferRepeater renders the owner-only recipient picker: which stewards
// can receive this repeater, and what changes when one does. With no stewards it
// explains how to designate one instead of offering an empty form.
//
// htmx gets the modal fragment (the share page's "Transfer ownership" button);
// a direct visit — the delete page links here, and a browser that never ran the
// htmx request lands here too — gets the standalone page. Both render the same
// content blocks, so there is one copy of the wording.
func (s *Handlers) pageTransferRepeater(w http.ResponseWriter, r *http.Request) {
rep, id, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
stewards, err := s.Store.ListStewards(r.Context(), id)
if err != nil {
s.ServerError(w, r, "could not load stewards", err)
return
}
data := map[string]any{
"Repeater": rep,
"Nav": web.RepeaterNav(rep.PublicID, rep.Name, rep.OwnerName(), true, "sharing"),
"Stewards": stewards,
"Error": r.URL.Query().Get("error"),
}
if r.Header.Get("HX-Request") != "" {
data["Layout"] = "transfer-modal"
}
s.Render(w, r, "transfer_repeater.html", data)
}
// handleTransferRepeater hands the repeater to the chosen steward. Authorization
// is the store's job — it re-checks ownership and the steward flag inside the
// transaction — so a stale form naming a since-demoted steward is rejected there
// rather than trusted here.
func (s *Handlers) handleTransferRepeater(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
id, ok := s.requireOwned(w, r)
if !ok {
return
}
targetID, perr := strconv.ParseInt(r.FormValue("user_id"), 10, 64)
if perr != nil {
transferErr(w, r, "Choose who should take over this repeater.")
return
}
err := s.Store.TransferRepeaterToSteward(r.Context(), uid, id, targetID)
switch {
case errors.Is(err, store.ErrNotSteward):
transferErr(w, r, "That person isn't a steward of this repeater anymore. Make them a steward again, or choose someone else.")
case errors.Is(err, store.ErrDuplicate):
transferErr(w, r, "That person has already registered this repeater under their own account, so it can't be transferred to them. Ask them to remove their copy first.")
case errors.Is(err, store.ErrNotFound):
// Someone else already transferred it, or it was deleted, between the page
// rendering and this submit. The dashboard is the honest place to land.
dashErr(w, r, "Could not transfer repeater (only the current owner can).")
case err != nil:
s.ServerError(w, r, "could not transfer repeater", err)
default:
// The outgoing owner keeps access as a steward, so the repeater page still
// renders for them — the right place to see the handover took effect.
web.RedirectFlash(w, r, "/repeaters/"+repeaterParam(r), "ok",
"Ownership transferred. You're now a steward of this repeater.")
}
}
+237
View File
@@ -0,0 +1,237 @@
package core
import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
)
// TestTransferPageRendersStewards: the transfer page is owner-only and offers
// exactly the repeater's stewards as recipients — a plain shared user must not
// appear as a choice, since they can't receive it.
func TestTransferPageRendersStewards(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "xferowner")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Ridge Rep")
path := "/repeaters/" + rep.PublicID + "/transfer"
steward, err := st.CreateUser(ctx, "keeper", "")
if err != nil {
t.Fatal(err)
}
plain, err := st.CreateUser(ctx, "bystander", "")
if err != nil {
t.Fatal(err)
}
for _, u := range []int64{steward.ID, plain.ID} {
if _, err := st.AddShare(ctx, rep.ID, u); err != nil {
t.Fatal(err)
}
}
if err := st.SetShareSteward(ctx, rep.ID, steward.ID, true); err != nil {
t.Fatal(err)
}
resp := do(t, ts, h.app, path, sess)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET transfer = %d, want 200", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
page := string(raw)
if !strings.Contains(page, "@keeper") {
t.Fatal("transfer page does not offer the steward as a recipient")
}
if strings.Contains(page, "@bystander") {
t.Fatal("transfer page offers a non-steward as a recipient")
}
// Owner-only: the steward themselves gets a 404, not the transfer form.
stewardSess := appSession(t, ts, st, ctx, h.app, steward)
as := do(t, ts, h.app, path, stewardSess)
defer as.Body.Close()
if as.StatusCode != http.StatusNotFound {
t.Fatalf("GET transfer as steward = %d, want 404", as.StatusCode)
}
}
// TestTransferModalFragment: an htmx request gets the modal fragment — modal
// chrome, no page chrome — while the same URL visited directly (the delete page
// links to it, and a no-JS browser lands there) still gets the full page. Both
// must offer the same recipients, since they render one copy of the content.
func TestTransferModalFragment(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "modalowner")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Modal Rep")
path := "/repeaters/" + rep.PublicID + "/transfer"
steward, err := st.CreateUser(ctx, "modalsteward", "")
if err != nil {
t.Fatal(err)
}
if _, err := st.AddShare(ctx, rep.ID, steward.ID); err != nil {
t.Fatal(err)
}
if err := st.SetShareSteward(ctx, rep.ID, steward.ID, true); err != nil {
t.Fatal(err)
}
frag := doHX(t, ts, h.app, path, sess)
defer frag.Body.Close()
if frag.StatusCode != http.StatusOK {
t.Fatalf("HX GET transfer = %d, want 200", frag.StatusCode)
}
raw, _ := io.ReadAll(frag.Body)
fragment := string(raw)
if !strings.Contains(fragment, "modal-header") || !strings.Contains(fragment, `id="transfer-form"`) {
t.Fatal("htmx request did not return the transfer modal fragment")
}
if strings.Contains(fragment, "<!doctype") || strings.Contains(fragment, "repeater-tabs") {
t.Fatal("modal fragment carries full-page chrome")
}
if !strings.Contains(fragment, "@modalsteward") {
t.Fatal("modal fragment does not offer the steward as a recipient")
}
// Same URL without htmx: the standalone page, same recipient.
full := do(t, ts, h.app, path, sess)
defer full.Body.Close()
rawFull, _ := io.ReadAll(full.Body)
page := string(rawFull)
if full.StatusCode != http.StatusOK || !strings.Contains(page, "<!doctype") {
t.Fatalf("direct GET transfer = %d, want a full 200 page", full.StatusCode)
}
if !strings.Contains(page, "@modalsteward") {
t.Fatal("standalone transfer page does not offer the steward as a recipient")
}
}
// TestTransferPageWithoutStewards explains the empty case rather than rendering
// a picker with nothing in it.
func TestTransferPageWithoutStewards(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "lonelyowner")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Solo Rep")
resp := do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/transfer", sess)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET transfer = %d, want 200", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(raw), "No stewards yet") {
t.Fatal("transfer page with no stewards does not explain how to designate one")
}
if strings.Contains(string(raw), `name="user_id"`) {
t.Fatal("transfer page rendered a recipient picker with no stewards to pick")
}
}
// TestTransferRepeaterPost is the endpoint happy path: ownership moves and the
// outgoing owner lands on the repeater page (which they can still see, as the
// handover leaves them a steward).
func TestTransferRepeaterPost(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "handerover")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Summit Rep")
path := "/repeaters/" + rep.PublicID + "/transfer"
steward, err := st.CreateUser(ctx, "successor", "")
if err != nil {
t.Fatal(err)
}
if _, err := st.AddShare(ctx, rep.ID, steward.ID); err != nil {
t.Fatal(err)
}
if err := st.SetShareSteward(ctx, rep.ID, steward.ID, true); err != nil {
t.Fatal(err)
}
resp := post(t, ts, h.app, path, url.Values{"user_id": {strconv.FormatInt(steward.ID, 10)}}, sess)
resp.Body.Close()
assertRedirect(t, resp, "/repeaters/"+rep.PublicID, "transfer ownership")
loc, _ := url.Parse(resp.Header.Get("Location"))
if loc.Query().Get("ok") == "" {
t.Fatal("successful transfer carried no confirmation flash")
}
got, err := st.GetRepeaterOwned(ctx, steward.ID, rep.ID)
if err != nil || got.OwnerID != steward.ID {
t.Fatalf("ownership did not move: %v", err)
}
if isSteward, _ := st.IsSteward(ctx, rep.ID, owner.ID); !isSteward {
t.Fatal("outgoing owner was not left a steward")
}
}
// TestTransferRepeaterPostRejections covers the ways a submit can be wrong: a
// non-steward target (a stale form, since the picker only lists stewards), a
// missing choice, and a non-owner submitting at all. None may move the repeater.
func TestTransferRepeaterPostRejections(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "guardowner")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Guarded Rep")
path := "/repeaters/" + rep.PublicID + "/transfer"
plain, err := st.CreateUser(ctx, "notasteward", "")
if err != nil {
t.Fatal(err)
}
if _, err := st.AddShare(ctx, rep.ID, plain.ID); err != nil {
t.Fatal(err)
}
// A shared user who isn't a steward: back to the transfer page with an error.
bad := post(t, ts, h.app, path, url.Values{"user_id": {strconv.FormatInt(plain.ID, 10)}}, sess)
bad.Body.Close()
assertRedirect(t, bad, path, "transfer to non-steward")
if loc, _ := url.Parse(bad.Header.Get("Location")); loc.Query().Get("error") == "" {
t.Fatal("rejected transfer carried no error message")
}
// No choice at all.
empty := post(t, ts, h.app, path, url.Values{}, sess)
empty.Body.Close()
assertRedirect(t, empty, path, "transfer with no recipient")
// A non-owner submitting directly: 404 from the ownership gate, and no change.
otherSess := appSession(t, ts, st, ctx, h.app, plain)
sneaky := post(t, ts, h.app, path, url.Values{"user_id": {strconv.FormatInt(plain.ID, 10)}}, otherSess)
defer sneaky.Body.Close()
if sneaky.StatusCode != http.StatusNotFound {
t.Fatalf("non-owner transfer = %d, want 404", sneaky.StatusCode)
}
if got, err := st.GetRepeaterOwned(ctx, owner.ID, rep.ID); err != nil || got.OwnerID != owner.ID {
t.Fatalf("repeater changed hands despite every attempt being rejected (%v)", err)
}
}
// TestSharePageOpensTransferModal: the sharing page is where an owner manages
// stewards, so it must be the way they find the handover — via the modal shell
// and the htmx trigger that fills it.
func TestSharePageOpensTransferModal(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "linkowner")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Linked Rep")
resp := do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/share", sess)
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
page := string(raw)
if !strings.Contains(page, `hx-get="/repeaters/`+rep.PublicID+`/transfer"`) {
t.Fatal("share page has no htmx trigger to load the transfer modal")
}
if !strings.Contains(page, `id="transfer-modal-content"`) {
t.Fatal("share page has no modal shell for the transfer fragment to swap into")
}
}
+2
View File
@@ -227,6 +227,8 @@ func (s *Handlers) appRouter() chi.Router {
r.Post("/repeaters/{id}/share/link", s.handleCreateLink)
r.Post("/repeaters/{id}/share/link/delete", s.handleDeleteInvite)
r.Post("/repeaters/{id}/unshare", s.handleUnshare)
r.Get("/repeaters/{id}/transfer", s.pageTransferRepeater)
r.Post("/repeaters/{id}/transfer", s.handleTransferRepeater)
r.Get("/repeaters/{id}/share/{userID}/access", s.pagePersonAccess)
r.Post("/repeaters/{id}/share/{userID}/access", s.handleSavePersonAccess)
r.Get("/repeaters/{id}/orgs/{orgID}/limits", s.pageRepeaterOrgLimits)
+17
View File
@@ -43,6 +43,7 @@ import (
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/cdproto/security"
"github.com/chromedp/chromedp"
@@ -411,6 +412,22 @@ func startBrowser(t *testing.T) (context.Context, context.CancelFunc, *consoleWa
return ctx, cancel, watch
}
// acceptDialogs auto-accepts window.confirm/alert dialogs for the rest of the
// browser context's life. Needed by any flow behind a [data-confirm] gate (see
// ui.js): the dialog blocks the page, so a click that opens one never completes
// and the submit never reaches the server.
//
// The handler runs in its own goroutine because chromedp.Run can't be called
// from inside a target listener on the same context — the listener holds the
// dispatch loop, so an inline Run would deadlock against itself.
func acceptDialogs(ctx context.Context) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
if _, ok := ev.(*page.EventJavascriptDialogOpening); ok {
go func() { _ = chromedp.Run(ctx, page.HandleJavaScriptDialog(true)) }()
}
})
}
// devtoolsWebSocket asks the headless-shell container for its browser-level
// DevTools websocket URL, skipping the test if the container is down.
func devtoolsWebSocket(t *testing.T) string {
-15
View File
@@ -3,7 +3,6 @@
package e2e
import (
"context"
"strings"
"testing"
@@ -13,20 +12,6 @@ import (
"github.com/chromedp/chromedp"
)
// acceptDialogs auto-accepts window.confirm/alert dialogs raised in bctx.
//
// Needed because ui.js gates destructive actions behind window.confirm via
// [data-confirm]: with nothing answering the dialog, the page blocks and the click
// appears to do nothing at all. The handler runs the reply in a goroutine — CDP
// delivers events on the same loop chromedp.Run uses, so replying inline deadlocks.
func acceptDialogs(bctx context.Context) {
chromedp.ListenTarget(bctx, func(ev interface{}) {
if _, ok := ev.(*page.EventJavascriptDialogOpening); ok {
go func() { _ = chromedp.Run(bctx, page.HandleJavaScriptDialog(true)) }()
}
})
}
// TestE2EPasswordRecoveryRoundTrip drives the entire recovery story in a real
// browser, the way a locked-out user lives it: add an address, confirm it from the
// link, lose the password, ask for a reset from the sign-in page, follow that link,
+74
View File
@@ -0,0 +1,74 @@
//go:build browser
package e2e
import (
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
// TestE2ETransferOwnership drives the whole handover in a real browser: from the
// sharing page, through the htmx-loaded modal and its steward picker, past the
// window.confirm gate, to the repeater page confirming it took effect.
//
// The modal and the confirm gate are the parts Go tests can't reach — Bootstrap
// and htmx running under the strict CSP, plus ui.js's [data-confirm] — so a
// template that forgot a hook (or a CSP that blocked the script) would still
// pass every handler test while being unusable in a browser.
func TestE2ETransferOwnership(t *testing.T) {
srv := newE2EServer(t)
owner, cookie := srv.login(t, "e2exferowner")
rep := srv.newRepeater(t, owner.ID, "Handover Rep")
steward, err := srv.store.CreateUser(srv.ctx, "e2esuccessor", "")
if err != nil {
t.Fatalf("create steward: %v", err)
}
if _, err := srv.store.AddShare(srv.ctx, rep.ID, steward.ID); err != nil {
t.Fatalf("add share: %v", err)
}
if err := srv.store.SetShareSteward(srv.ctx, rep.ID, steward.ID, true); err != nil {
t.Fatalf("set steward: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
acceptDialogs(bctx)
shareURL := srv.appURL + "/repeaters/" + rep.PublicID + "/share"
repeaterURL := srv.appURL + "/repeaters/" + rep.PublicID
var flash string
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
page.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(shareURL),
// The handover has to be reachable from where stewards are managed: the
// button opens the shared modal and htmx fills it with the picker.
chromedp.Click(`[data-testid="transfer-ownership"]`, chromedp.ByQuery),
chromedp.WaitVisible(`#transfer-modal-content [data-testid="steward-choice"]`, chromedp.ByQuery),
chromedp.Click(`#transfer-modal-content [data-testid="confirm-transfer"]`, chromedp.ByQuery),
waitForLocation(repeaterURL),
chromedp.Text(`.alert-success`, &flash, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", shareURL, err)
}
if flash == "" {
t.Fatal("no confirmation banner after the transfer")
}
// The visible outcome must match the stored one.
got, err := srv.store.GetRepeaterOwned(srv.ctx, steward.ID, rep.ID)
if err != nil || got.OwnerID != steward.ID {
t.Fatalf("ownership did not move to the steward: %v", err)
}
if isSteward, _ := srv.store.IsSteward(srv.ctx, rep.ID, owner.ID); !isSteward {
t.Fatal("outgoing owner was not left a steward")
}
watch.assertClean(t)
}
+2 -2
View File
@@ -58,7 +58,7 @@ func (s *Handlers) Routes() chi.Router {
r.Get("/session/beacon", s.Auth.BeaconCallback)
// The root host is the public discovery surface, so it stays crawlable; only
// the single-use beacon path is disallowed. The two sensitive pages below
// (per-repeater NFC/QR targets, personal profiles) stay crawlable but send
// (per-repeater public pages, personal profiles) stay crawlable but send
// noindex, so they're dropped from search results rather than blocked.
r.Get("/robots.txt", web.RobotsTxt("User-agent: *\nDisallow: /session/\n"))
r.Get("/", s.pageLanding)
@@ -68,7 +68,7 @@ func (s *Handlers) Routes() chi.Router {
r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map
r.Get("/orgs/{id}/repeaters.json", s.orgRepeatersJSON) // map points (cached), fetched by both pages above
r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config
r.With(web.NoIndex).Get("/r/{id}", s.pageRepeaterPublic) // NFC/QR target — not for search
r.With(web.NoIndex).Get("/r/{id}", s.pageRepeaterPublic) // public repeater page — not for search
r.With(web.NoIndex).Get("/u/{username}", s.pageUserPublic) // personal profile — not for search
})
return r
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/jleight/meshtender/internal/web"
)
// pageRepeaterPublic renders a repeater's public page — the NFC/QR tap target —
// pageRepeaterPublic renders a repeater's public page —
// for anonymous visitors on the root host. It exists only when the owner opted
// in (expose_public_page); otherwise it 404s like any unknown resource. It shows
// only public-safe information: what the node is, where it is, the public
+1 -1
View File
@@ -246,7 +246,7 @@ func seedRepeaters(ctx context.Context, st *store.Store, f *gofakeit.Faker, user
if chance(f, 70) {
_ = st.SetRepeaterLocation(ctx, rep.ID, f.Float64Range(25, 49), f.Float64Range(-124, -67))
}
// ~40% also publish a standalone public page (the NFC/QR target).
// ~40% also publish a standalone public page.
if chance(f, 40) {
_ = st.UpdateRepeater(ctx, u.ID, rep.ID, rep.Name, preset.freq, preset.bw, preset.sf, preset.cr, showOnOrg, true)
}
+2 -2
View File
@@ -33,7 +33,7 @@ type Repeater struct {
// list, plus the map when coordinates are known.
ShowOnPublicOrg bool
// ExposePublicPage publishes a read-only public page for this repeater at its
// public_id URL (the NFC/QR tap target). Distinct consent from ShowOnPublicOrg.
// public_id URL. Distinct consent from ShowOnPublicOrg.
ExposePublicPage bool
// Documentation kept with the node. DocPublic is shown on the public page;
// DocInternal (site-access details) only to people with access.
@@ -252,7 +252,7 @@ func (s *Store) UpdateRepeaterDocs(ctx context.Context, ownerID, repeaterID int6
// GetRepeaterPublic returns a repeater by its public_id only if its owner has
// published a public page (expose_public_page), else ErrNotFound. No user scope:
// this backs the anonymous public page (the NFC/QR tap target).
// this backs the anonymous public page.
func (s *Store) GetRepeaterPublic(ctx context.Context, publicID string) (*Repeater, error) {
// $1 = 0 (no querying user): the Shared flag is irrelevant for the public view.
row := s.pool.QueryRow(ctx, repeaterSelect+`
+167
View File
@@ -0,0 +1,167 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
// Ownership transfer hands a repeater to one of its stewards. Stewards are
// already the designated co-maintainers — they run every command the owner can
// and are listed on the public page as who to call — so promoting one to owner
// is the smallest step that lets a site outlive the person who built it, and it
// is the alternative to deleting a node when its owner leaves.
//
// Only a steward can receive a repeater. That keeps the recipient set to people
// the owner has already vouched for and who already have access, rather than any
// account on the instance.
// ErrNotSteward is returned when a transfer names someone who does not currently
// hold a steward share on the repeater — a stale form (they were demoted while
// the page was open), or a hand-crafted request.
var ErrNotSteward = errors.New("store: recipient is not a steward")
// TransferRepeaterToSteward makes newOwnerID the owner of repeaterID, which
// currentOwnerID must currently own and newOwnerID must hold a steward share on.
//
// Everything that makes the node worth keeping moves with it: its public_id (so
// links to its public page keep resolving), documentation, command log,
// confirmations, location, and maintenance history. What changes is who controls
// it:
//
// - the new owner's own share is dropped (you don't hold a share on your own
// repeater), along with the per-command grants that went with it;
// - the outgoing owner becomes a steward — the mirror image of the promotion,
// so a handover isn't a cliff for the person who built the site. The new
// owner can remove them;
// - the outgoing owner's pending share links are deleted: they mint access to
// a node they no longer control;
// - the handover is appended to the maintenance history, which is where the
// next maintainer looks to find out what happened to a node.
//
// Returns ErrNotFound if the repeater doesn't exist or isn't owned by
// currentOwnerID, ErrNotSteward if the recipient isn't a steward, and
// ErrDuplicate if the recipient already registered this same public key under
// their own account (repeaters is UNIQUE on (owner_id, public_key_hex), so there
// is nowhere to put the incoming row).
func (s *Store) TransferRepeaterToSteward(ctx context.Context, currentOwnerID, repeaterID, newOwnerID int64) error {
if currentOwnerID == newOwnerID {
// An owner can't be their own steward, so this can only be a malformed
// request. Reject it before touching the database.
return ErrNotSteward
}
return s.inTx(ctx, func(tx pgx.Tx) error {
// Lock the repeater first. Two concurrent transfers serialize here, and the
// loser re-reads an owner_id that is no longer currentOwnerID and fails,
// rather than both passing the ownership check and the second silently
// overwriting the first.
var ownerID int64
if err := tx.QueryRow(ctx,
`SELECT owner_id FROM repeaters WHERE id = $1 FOR UPDATE`, repeaterID).Scan(&ownerID); err != nil {
return notFoundOr(err, "lock repeater")
}
if ownerID != currentOwnerID {
return ErrNotFound
}
// The steward flag is the authorization set, re-checked here rather than
// trusted from the form: a recipient demoted since the page rendered must
// not receive the node. Locked too, so a concurrent demotion can't land
// between this read and the commit.
var steward bool
err := tx.QueryRow(ctx,
`SELECT steward FROM repeater_shares WHERE repeater_id = $1 AND user_id = $2 FOR UPDATE`,
repeaterID, newOwnerID).Scan(&steward)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotSteward
}
if err != nil {
return fmt.Errorf("check steward: %w", err)
}
if !steward {
return ErrNotSteward
}
// Read the outgoing owner's name before the change, for the history entry.
// AddMaintenanceEntry's convention: the live display name, else username.
var outgoingName, outgoingHandle, incomingHandle string
if err := tx.QueryRow(ctx, `
SELECT COALESCE(NULLIF(o.display_name, ''), o.username), o.username, n.username
FROM users o, users n WHERE o.id = $1 AND n.id = $2`,
currentOwnerID, newOwnerID).Scan(&outgoingName, &outgoingHandle, &incomingHandle); err != nil {
return fmt.Errorf("load transfer names: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE repeaters SET owner_id = $2 WHERE id = $1`, repeaterID, newOwnerID); err != nil {
if isUniqueViolation(err) {
return ErrDuplicate
}
return fmt.Errorf("transfer repeater: %w", err)
}
// The new owner's share is now redundant. Drop its command grants too:
// share_commands is keyed (repeater_id, user_id, command_id) with no FK to
// repeater_shares, so it would otherwise outlive the share row and reapply
// if they were ever shared with again.
if _, err := tx.Exec(ctx,
`DELETE FROM share_commands WHERE repeater_id = $1 AND user_id = $2`,
repeaterID, newOwnerID); err != nil {
return fmt.Errorf("clear new owner grants: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM repeater_shares WHERE repeater_id = $1 AND user_id = $2`,
repeaterID, newOwnerID); err != nil {
return fmt.Errorf("clear new owner share: %w", err)
}
// The outgoing owner keeps access as a steward.
if _, err := tx.Exec(ctx, `
INSERT INTO repeater_shares (repeater_id, user_id, steward) VALUES ($1, $2, TRUE)
ON CONFLICT (repeater_id, user_id) DO UPDATE SET steward = TRUE`,
repeaterID, currentOwnerID); err != nil {
return fmt.Errorf("keep outgoing owner as steward: %w", err)
}
// Pending share links were minted by someone who no longer controls the
// node; redeeming one after the handover would grant access the new owner
// never agreed to. (Cascades invite_commands.)
if _, err := tx.Exec(ctx,
`DELETE FROM repeater_invites WHERE repeater_id = $1`, repeaterID); err != nil {
return fmt.Errorf("clear pending share links: %w", err)
}
// org_repeater_excludes is the OLD owner's per-org opt-outs. Participation
// requires the repeater's owner to be a member of the org, so any exclude
// naming an org the new owner isn't in is now inert — sweep it. Rows for
// orgs they share stay, so an opt-out that still means something is
// preserved rather than silently flipped back to visible.
//
// (org_command_optin is keyed by owner_id, not repeater_id: it is the old
// owner's own per-org command ceiling across all their nodes. It stays with
// them and simply stops applying to this one. Nothing to clean up.)
if _, err := tx.Exec(ctx, `
DELETE FROM org_repeater_excludes e
WHERE e.repeater_id = $1
AND NOT EXISTS (SELECT 1 FROM org_members m
WHERE m.org_id = e.org_id AND m.user_id = $2)`,
repeaterID, newOwnerID); err != nil {
return fmt.Errorf("sweep stale org excludes: %w", err)
}
// Record the handover in the human-readable history. author_id is the
// outgoing owner (who performed it); author_name is the write-time snapshot
// that survives their account being deleted — which is exactly the case
// this feature exists to serve.
if _, err := tx.Exec(ctx, `
INSERT INTO repeater_maintenance (repeater_id, author_id, author_name, note)
VALUES ($1, $2, $3, $4)`,
repeaterID, currentOwnerID, outgoingName,
fmt.Sprintf("Ownership transferred from @%s to @%s.", outgoingHandle, incomingHandle)); err != nil {
return fmt.Errorf("log transfer: %w", err)
}
return nil
})
}
+305
View File
@@ -0,0 +1,305 @@
package store
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// transferFixture builds the common setup: an owner, a repeater, and a second
// user shared on it. steward controls whether that second user is flagged a
// steward (the transfer-eligible state).
func transferFixture(t *testing.T, st *Store, ctx context.Context, key string, steward bool) (owner, target *User, rep *Repeater) {
t.Helper()
owner, err := st.CreateUser(ctx, "owner", "")
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err = st.CreateUser(ctx, "target", "")
if err != nil {
t.Fatalf("create target: %v", err)
}
rep, err = st.CreateRepeater(ctx, &Repeater{
OwnerID: owner.ID, Name: "Ridge", PublicKeyHex: strings.Repeat(key, 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
if _, err := st.AddShare(ctx, rep.ID, target.ID); err != nil {
t.Fatalf("add share: %v", err)
}
if steward {
if err := st.SetShareSteward(ctx, rep.ID, target.ID, true); err != nil {
t.Fatalf("set steward: %v", err)
}
}
return owner, target, rep
}
// TestTransferRepeaterToSteward is the happy path, asserting the whole
// post-conditions set together: ownership moves, the new owner's share and its
// grants are gone, the outgoing owner is left a steward, pending share links are
// revoked, and the site's history (docs, maintenance) survives the handover —
// outliving the builder being the entire point of the feature.
func TestTransferRepeaterToSteward(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, target, rep := transferFixture(t, st, ctx, "a", true)
// State that must survive: documentation and a maintenance entry.
if err := st.UpdateRepeaterDocs(ctx, owner.ID, rep.ID, "public doc", "gate code 1234"); err != nil {
t.Fatalf("update docs: %v", err)
}
if err := st.AddMaintenanceEntry(ctx, rep.ID, owner.ID, "owner", "swapped antenna", time.Now()); err != nil {
t.Fatalf("add maintenance: %v", err)
}
// State that must NOT survive: the recipient's own grants, and a pending link.
catalog, err := st.ListCommands(ctx)
if err != nil || len(catalog) == 0 {
t.Fatalf("list commands: %v (n=%d)", err, len(catalog))
}
if err := st.SetShareCommands(ctx, rep.ID, target.ID, []int64{catalog[0].ID}); err != nil {
t.Fatalf("set share commands: %v", err)
}
if _, err := st.CreateInvite(ctx, rep.ID, "pending", nil); err != nil {
t.Fatalf("create invite: %v", err)
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, target.ID); err != nil {
t.Fatalf("TransferRepeaterToSteward: %v", err)
}
// Ownership moved, and the node is reachable through the owner-only gate for
// the new owner but not the old one.
got, err := st.GetRepeaterOwned(ctx, target.ID, rep.ID)
if err != nil {
t.Fatalf("GetRepeaterOwned(new owner): %v", err)
}
if got.OwnerID != target.ID {
t.Fatalf("owner_id = %d, want %d (the new owner)", got.OwnerID, target.ID)
}
if _, err := st.GetRepeaterOwned(ctx, owner.ID, rep.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("GetRepeaterOwned(old owner) err = %v, want ErrNotFound", err)
}
// The public_id is stable: links and QR codes to the public page still resolve.
if got.PublicID != rep.PublicID {
t.Fatalf("public_id changed on transfer (%q → %q); links in the field would break", rep.PublicID, got.PublicID)
}
if got.DocPublic != "public doc" || got.DocInternal != "gate code 1234" {
t.Fatalf("docs lost on transfer: %q / %q", got.DocPublic, got.DocInternal)
}
// The new owner no longer holds a share on their own node, nor its grants.
if shared, _ := st.IsShared(ctx, rep.ID, target.ID); shared {
t.Fatal("new owner still holds a share on their own repeater")
}
cmds, err := st.ListShareCommandIDs(ctx, rep.ID, target.ID)
if err != nil {
t.Fatalf("list share commands: %v", err)
}
if len(cmds) != 0 {
t.Fatalf("new owner's share grants = %v, want none (share was dropped)", cmds)
}
// The outgoing owner keeps access, as a steward.
steward, err := st.IsSteward(ctx, rep.ID, owner.ID)
if err != nil {
t.Fatalf("IsSteward(old owner): %v", err)
}
if !steward {
t.Fatal("outgoing owner is not a steward; the handover should not be a cliff")
}
// Pending share links minted by the old owner are revoked.
invites, err := st.ListInvites(ctx, rep.ID)
if err != nil {
t.Fatalf("list invites: %v", err)
}
if len(invites) != 0 {
t.Fatalf("ListInvites = %d, want 0 (links from the previous owner are revoked)", len(invites))
}
// History survives, and the handover itself is recorded in it.
entries, err := st.ListMaintenance(ctx, rep.ID)
if err != nil {
t.Fatalf("list maintenance: %v", err)
}
if len(entries) != 2 {
t.Fatalf("maintenance entries = %d, want 2 (the original plus the transfer)", len(entries))
}
var logged bool
for _, e := range entries {
if strings.Contains(e.Note, "@owner") && strings.Contains(e.Note, "@target") {
logged = true
}
}
if !logged {
t.Fatalf("no maintenance entry naming both parties; got %+v", entries)
}
}
// TestTransferRepeaterRejectsNonSteward: a plain shared user is not a valid
// recipient. Only people the owner explicitly designated as co-maintainers are.
func TestTransferRepeaterRejectsNonSteward(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, target, rep := transferFixture(t, st, ctx, "b", false)
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, target.ID); !errors.Is(err, ErrNotSteward) {
t.Fatalf("transfer to non-steward err = %v, want ErrNotSteward", err)
}
if got, _ := st.GetRepeaterOwned(ctx, owner.ID, rep.ID); got == nil {
t.Fatal("repeater left the original owner despite the rejected transfer")
}
// Nor is a stranger with no share at all.
stranger, err := st.CreateUser(ctx, "stranger", "")
if err != nil {
t.Fatalf("create stranger: %v", err)
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, stranger.ID); !errors.Is(err, ErrNotSteward) {
t.Fatalf("transfer to stranger err = %v, want ErrNotSteward", err)
}
// Nor the owner themselves.
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, owner.ID); !errors.Is(err, ErrNotSteward) {
t.Fatalf("self-transfer err = %v, want ErrNotSteward", err)
}
}
// TestTransferRepeaterRejectsDemotedSteward is the stale-form regression: the
// steward flag is re-checked at write time, so a recipient demoted after the
// transfer page rendered does not receive the node.
func TestTransferRepeaterRejectsDemotedSteward(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, target, rep := transferFixture(t, st, ctx, "c", true)
// The page rendered while they were a steward; they are demoted before submit.
if err := st.SetShareSteward(ctx, rep.ID, target.ID, false); err != nil {
t.Fatalf("demote steward: %v", err)
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, target.ID); !errors.Is(err, ErrNotSteward) {
t.Fatalf("transfer to demoted steward err = %v, want ErrNotSteward", err)
}
if got, err := st.GetRepeaterOwned(ctx, owner.ID, rep.ID); err != nil || got.OwnerID != owner.ID {
t.Fatalf("ownership moved despite the rejected transfer (%v)", err)
}
}
// TestTransferRepeaterRequiresOwnership: only the current owner can transfer.
// A steward — who has full command access — must not be able to take the node,
// and a second transfer by the previous owner must fail once it has moved on.
func TestTransferRepeaterRequiresOwnership(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, target, rep := transferFixture(t, st, ctx, "d", true)
// The steward can't help themselves to it.
if err := st.TransferRepeaterToSteward(ctx, target.ID, rep.ID, target.ID); !errors.Is(err, ErrNotSteward) {
t.Fatalf("steward self-serve transfer err = %v, want a rejection", err)
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, target.ID); err != nil {
t.Fatalf("transfer: %v", err)
}
// The old owner is now only a steward; they can't transfer it again (to
// themselves or anyone else). This is also the concurrent-double-transfer
// outcome: the loser re-reads an owner_id that is no longer theirs.
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, owner.ID); !errors.Is(err, ErrNotSteward) {
t.Fatalf("re-transfer by previous owner err = %v, want a rejection", err)
}
third, err := st.CreateUser(ctx, "third", "")
if err != nil {
t.Fatalf("create third: %v", err)
}
if _, err := st.AddShare(ctx, rep.ID, third.ID); err != nil {
t.Fatalf("add share: %v", err)
}
if err := st.SetShareSteward(ctx, rep.ID, third.ID, true); err != nil {
t.Fatalf("set steward: %v", err)
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, third.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("transfer by previous owner err = %v, want ErrNotFound", err)
}
}
// TestTransferRepeaterDuplicateKey: the recipient already registered the same
// physical node under their own account. repeaters is UNIQUE (owner_id,
// public_key_hex), so the transfer has nowhere to land — it must surface as
// ErrDuplicate (a real message) and roll back entirely, not a 500 or a partial
// handover.
func TestTransferRepeaterDuplicateKey(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, target, rep := transferFixture(t, st, ctx, "e", true)
if _, err := st.CreateRepeater(ctx, &Repeater{
OwnerID: target.ID, Name: "Mine", PublicKeyHex: rep.PublicKeyHex,
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
}); err != nil {
t.Fatalf("create recipient's own copy: %v", err)
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, target.ID); !errors.Is(err, ErrDuplicate) {
t.Fatalf("transfer with colliding key err = %v, want ErrDuplicate", err)
}
// Rolled back: ownership unchanged, and the recipient still holds their share
// rather than having it deleted by a half-applied transfer.
if got, err := st.GetRepeaterOwned(ctx, owner.ID, rep.ID); err != nil || got.OwnerID != owner.ID {
t.Fatalf("ownership changed despite the failed transfer (%v)", err)
}
if shared, _ := st.IsShared(ctx, rep.ID, target.ID); !shared {
t.Fatal("recipient's share was dropped by a transfer that failed")
}
}
// TestTransferRepeaterSweepsStaleOrgExcludes: an exclude row is the OLD owner's
// opt-out. After the handover, one naming an org the new owner isn't in can never
// apply again (participation requires the owner be a member), so it is swept;
// one for an org they share still means something and is kept, so visibility
// doesn't silently flip back on.
func TestTransferRepeaterSweepsStaleOrgExcludes(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, target, rep := transferFixture(t, st, ctx, "f", true)
// oldOnly: only the outgoing owner is a member. shared: both are.
oldOnly, err := st.CreateOrg(ctx, "Old Only", owner.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
shared, err := st.CreateOrg(ctx, "Shared", owner.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
if err := st.AddOrgMember(ctx, shared.ID, target.ID, "member"); err != nil {
t.Fatalf("add org member: %v", err)
}
for _, org := range []*Org{oldOnly, shared} {
if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, true); err != nil {
t.Fatalf("exclude from org %d: %v", org.ID, err)
}
}
if err := st.TransferRepeaterToSteward(ctx, owner.ID, rep.ID, target.ID); err != nil {
t.Fatalf("TransferRepeaterToSteward: %v", err)
}
stale, err := st.IsRepeaterOrgExcluded(ctx, oldOnly.ID, rep.ID)
if err != nil {
t.Fatalf("IsRepeaterOrgExcluded: %v", err)
}
if stale {
t.Fatal("exclude for an org the new owner isn't in survived; it can never apply again")
}
kept, err := st.IsRepeaterOrgExcluded(ctx, shared.ID, rep.ID)
if err != nil {
t.Fatalf("IsRepeaterOrgExcluded: %v", err)
}
if !kept {
t.Fatal("exclude for a shared org was swept; the node would silently become visible there")
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ const RobotsDisallowAll = "User-agent: *\nDisallow: /\n"
// NoIndex sets X-Robots-Tag: noindex so a crawler that fetches the response drops
// it from its index. Used on public pages we don't want surfaced in search
// (per-repeater NFC/QR targets, personal profiles) and blanket on the app/auth
// (per-repeater public pages, personal profiles) and blanket on the app/auth
// hosts. The page must stay crawlable (i.e. NOT robots.txt-blocked) for a crawler
// to read this header — blocking and noindex together would hide the header.
func NoIndex(next http.Handler) http.Handler {