mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-09 13:15:33 +00:00
Transfer repeater ownership
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> 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> 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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+`
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user