Add option to delete your account

This commit is contained in:
Jonathon Leight
2026-07-31 20:53:28 -04:00
parent fb7b45dbdf
commit 45677f0655
10 changed files with 1533 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
package auth
import (
"errors"
"net/http"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// Account deletion. The page's job is to make the consequences legible before
// anyone clicks: exactly which repeaters would be destroyed (with a link to hand
// each one to a steward instead), which organizations go or stay, and anything
// that blocks the deletion outright.
// deleteAccountErr bounces back to the confirm page with an error, so the user
// keeps the context rather than landing on the account page wondering.
func deleteAccountErr(w http.ResponseWriter, r *http.Request, msg string) {
web.RedirectErr(w, r, "/account/delete", msg)
}
// pageDeleteAccount renders the deletion confirm page.
func (s *Handlers) pageDeleteAccount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.Auth.CurrentUserID(ctx)
u, err := s.Store.GetUserByID(ctx, uid)
if err != nil {
s.ServerError(w, r, "could not load account", err)
return
}
preview, err := s.Store.PreviewUserDeletion(ctx, uid)
if err != nil {
s.ServerError(w, r, "could not load account", err)
return
}
s.Render(w, r, "delete_account.html", map[string]any{
"User": u,
"Preview": preview,
// Which proof of identity to ask for. An account can have both; the password
// field is shown when there is one, with the passkey button alongside.
"HasPassword": u.PasswordHash != nil,
"HasPasskeys": preview.Passkeys > 0,
// Repeater transfer lives on the app host, so links out need its origin.
"AppOrigin": s.Auth.AppOrigin(r),
"Error": r.URL.Query().Get("error"),
})
}
// handleDeleteAccount verifies the person is still present — a password, or a
// passkey assertion completed in the last ReauthWindow — and then deletes the
// account. The store re-checks every blocker inside its transaction, so this
// handler's job is the identity proof and the messaging.
func (s *Handlers) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.Auth.CurrentUserID(ctx)
u, err := s.Store.GetUserByID(ctx, uid)
if err != nil {
s.ServerError(w, r, "could not load account", err)
return
}
// A fresh passkey assertion satisfies this outright; otherwise fall back to the
// password. An account with neither can't reach here — every account has at
// least one sign-in method, enforced when removing one.
if !s.Auth.ReauthFresh(ctx) {
switch pw := r.FormValue("password"); {
case u.PasswordHash == nil:
deleteAccountErr(w, r, "Verify with your passkey to delete your account.")
return
case pw == "":
deleteAccountErr(w, r, "Enter your password, or verify with your passkey, to delete your account.")
return
case !s.Auth.PasswordMatches(u, pw):
deleteAccountErr(w, r, "That password is incorrect.")
return
}
}
switch err := s.Store.DeleteUser(ctx, uid); {
case errors.Is(err, store.ErrSoleOrgAdmin):
deleteAccountErr(w, r, "You're the only admin of an organization that still has other members. "+
"Make someone else an admin there first, then come back.")
case errors.Is(err, store.ErrLastSiteAdmin):
deleteAccountErr(w, r, "You're the last administrator of this MeshTender instance. "+
"Give another account the manage-users capability first.")
case errors.Is(err, store.ErrNotFound):
// Already gone (a double submit, or deleted in another tab). Treat it as
// done rather than as an error: the outcome the user asked for holds.
s.finishDeletion(w, r)
case err != nil:
s.ServerError(w, r, "could not delete account", err)
default:
s.finishDeletion(w, r)
}
}
// finishDeletion tears down the session and lands on the sign-in page with a
// confirmation. Every other host drops to anonymous on its next request anyway —
// the logins row cascaded away with the account, and a missing row reads as
// revoked — but clearing this host's session too means the browser isn't left
// holding a cookie for an account that no longer exists.
func (s *Handlers) finishDeletion(w http.ResponseWriter, r *http.Request) {
_ = s.Auth.Logout(r.Context())
web.RedirectFlash(w, r, "/login", "ok", "Your account and everything in it have been deleted.")
}
+127
View File
@@ -0,0 +1,127 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/jleight/meshtender/internal/web"
)
// Re-authentication ("sudo mode"): proving, right now, that the person driving
// an authenticated session is still the account holder. A live session says
// somebody signed in here at some point — not that the person about to destroy
// the account is the one who did. Deleting an account is irreversible and one
// click from an unattended browser, so it demands a fresh proof.
//
// Password holders re-enter their password (handled at the point of use, where
// the form already is); passkey-only accounts complete an assertion here, which
// stamps the session. Either way, the proof expires quickly.
// sessKeyReauthAt holds the unix seconds of the last successful identity proof.
// Stored as an int64 rather than a time.Time: scs serializes session data with
// gob, and an integer needs no type registration to survive a round trip.
const sessKeyReauthAt = "reauth_at"
// ReauthWindow is how long a proof of presence authorizes a sensitive action.
// Long enough to read a confirmation page and think, short enough that a walk-
// away between the ceremony and the click doesn't hand someone the account.
const ReauthWindow = 5 * time.Minute
// MarkReauth records a successful identity proof on the current session.
func (s *Service) MarkReauth(ctx context.Context) {
s.Sessions.Put(ctx, sessKeyReauthAt, time.Now().Unix())
}
// ReauthFresh reports whether this session proved its identity within
// ReauthWindow.
func (s *Service) ReauthFresh(ctx context.Context) bool {
at := s.Sessions.GetInt64(ctx, sessKeyReauthAt)
return at != 0 && time.Since(time.Unix(at, 0)) < ReauthWindow
}
// ReauthPasskeyBegin starts an assertion ceremony against the signed-in user's
// own credentials. Unlike LoginBegin it takes no username: the account is
// whoever the session says it is, so this can't be used to probe for accounts.
func (s *Service) ReauthPasskeyBegin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.CurrentUserID(ctx)
if uid == 0 {
httpError(w, r, http.StatusUnauthorized, "not signed in", nil)
return
}
u, err := s.store.GetUserByID(ctx, uid)
if err != nil {
httpError(w, r, http.StatusInternalServerError, "load user", err)
return
}
waUser, err := s.loadWebAuthnUser(ctx, u)
if err != nil {
httpError(w, r, http.StatusInternalServerError, "load credentials", err)
return
}
if len(waUser.creds) == 0 {
httpError(w, r, http.StatusBadRequest, "no passkey registered for this account", nil)
return
}
options, sessionData, err := s.wa.BeginLogin(waUser)
if err != nil {
httpError(w, r, http.StatusInternalServerError, "begin verification", err)
return
}
if err := s.stashCeremony(ctx, uid, sessionData); err != nil {
httpError(w, r, http.StatusInternalServerError, "save ceremony", err)
return
}
writeJSON(w, options)
}
// ReauthPasskeyFinish completes the assertion and stamps the session as freshly
// verified. It grants no new access on its own — the sensitive handler decides
// what a fresh stamp is worth.
func (s *Service) ReauthPasskeyFinish(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.CurrentUserID(ctx)
ceremonyUID, sessionData, ok := s.popCeremony(ctx)
if !ok {
httpError(w, r, http.StatusBadRequest, "no verification in progress", nil)
return
}
// The ceremony must belong to the session driving it. Begin only ever stashes
// the current user, so this is belt-and-braces against a ceremony stashed by
// some other flow (a half-finished sign-in) being spent as a re-auth here.
if uid == 0 || ceremonyUID != uid {
httpError(w, r, http.StatusUnauthorized, "verification failed", nil)
return
}
u, err := s.store.GetUserByID(ctx, uid)
if err != nil {
httpError(w, r, http.StatusInternalServerError, "load user", err)
return
}
waUser, err := s.loadWebAuthnUser(ctx, u)
if err != nil {
httpError(w, r, http.StatusInternalServerError, "load credentials", err)
return
}
cred, err := s.wa.FinishLogin(waUser, *sessionData, r)
if err != nil {
// Same treatment as LoginFinish: the go-webauthn detail can leak internals,
// so log it and return something generic.
web.LogError(r, "webauthn: finish reauth", err)
httpError(w, r, http.StatusUnauthorized, "verification failed", nil)
return
}
// Persist the updated sign counter / clone-warning state, as login does.
if blob, err := json.Marshal(cred); err == nil {
_ = s.store.UpdateCredential(ctx, cred.ID, blob)
}
s.MarkReauth(ctx)
writeJSON(w, authResult{OK: true})
}
// AppOrigin is the app host's origin for this request, so auth-host pages can
// link into the app (the delete page offers repeater transfers, which live
// there).
func (s *Service) AppOrigin(r *http.Request) string { return s.appOrigin(r) }
+16
View File
@@ -333,6 +333,22 @@
</div>
</div>
{{/* Deletion is a page, not a button here: what it removes depends on what you
own, and that has to be shown before anyone commits to it. */}}
<div class="card mt-3 border-danger">
<div class="card-header"><h2 class="card-title text-danger">Delete account</h2></div>
<div class="card-body">
<p class="text-secondary">
Permanently delete your account, your public profile, and the repeaters you own. This can't be
undone — the next page shows exactly what would go, and lets you hand any repeater to a steward
first.
</p>
<a class="btn btn-outline-danger" href="/account/delete" data-testid="delete-account">
{{template "icon-trash" "me-1"}}Delete my account…
</a>
</div>
</div>
<!-- Rename-passkey modal: populated from the clicked row's data-* attributes. -->
<div class="modal modal-blur fade" id="rename-passkey" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
+194
View File
@@ -0,0 +1,194 @@
{{define "title"}}Delete your account · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h1 class="page-title fs-1">Delete your account?</h1></div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
{{if .Preview.Blocked}}
{{/* Blocked: show only what must be resolved. Rendering the confirm form under a
"you can't do this yet" banner just invites a click that will fail. */}}
<div class="card">
<div class="card-header"><h2 class="card-title">Not yet — someone has to take over first</h2></div>
<div class="card-body">
<p class="text-secondary">
Your account can't be deleted while other people depend on it for administration. Once these are
resolved, come back here and it'll go through.
</p>
{{if .Preview.LastSiteAdmin}}
<div class="alert alert-warning" role="alert">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">You're the last administrator of this site</h3>
<div class="text-secondary">
Deleting your account would leave MeshTender with nobody able to manage users. Give another
account the manage-users capability first.
</div>
</div>
</div>
{{end}}
{{if .Preview.OrgsBlocked}}
<div class="alert alert-warning mb-0" role="alert">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h3 class="alert-title">
You're the only admin of {{if eq (len .Preview.OrgsBlocked) 1}}an organization{{else}}some organizations{{end}} with other members
</h3>
<div class="text-secondary">
Make someone else an admin in each of these, or remove the other members:
<ul class="mt-2 mb-0">
{{range .Preview.OrgsBlocked}}
<li><a href="{{$.AppOrigin}}/orgs/{{.Slug}}/members">{{.Name}}</a> — {{.Members}} members</li>
{{end}}
</ul>
</div>
</div>
</div>
{{end}}
</div>
</div>
<a class="back-link mt-3" href="/account">{{template "icon-arrow-left" "me-1"}}Back to account</a>
{{else}}
<div class="card">
<div class="card-header"><h2 class="card-title">What deleting removes</h2></div>
<div class="card-body">
<p class="text-secondary">
This deletes your account, your profile at <strong>/u/{{.User.Username}}</strong>, your sign-in
methods, and the things below. <strong>It can't be undone</strong>, and you'll be signed out
everywhere immediately.
</p>
{{if .Preview.Repeaters}}
<div class="hr-text hr-text-left">Repeaters you own</div>
<p class="text-secondary">
These are deleted outright — with their documentation, maintenance history, command log, and
everyone else's access to them. If a repeater is staying on the air, hand it to a steward instead;
it keeps all of that and its public page address.
</p>
<div class="list-group list-group-flush mb-3">
{{range .Preview.Repeaters}}
<div class="list-group-item d-flex align-items-center gap-2 flex-wrap px-0" data-testid="doomed-repeater">
<span class="fw-bold">{{.Name}}</span>
{{if .Stewards}}
<a class="btn btn-sm ms-auto" href="{{$.AppOrigin}}/repeaters/{{.PublicID}}/transfer">
Transfer to a steward
</a>
{{else}}
<span class="text-secondary ms-auto small">
No stewards — <a href="{{$.AppOrigin}}/repeaters/{{.PublicID}}/share">share it with someone</a>
to hand it over instead
</span>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .Preview.OrgsDeleted}}
<div class="hr-text hr-text-left">Organizations deleted with your account</div>
<p class="text-secondary">You're the only member of {{if eq (len .Preview.OrgsDeleted) 1}}this one{{else}}these{{end}}, so nothing is left behind:</p>
<ul class="text-secondary">
{{range .Preview.OrgsDeleted}}<li data-testid="doomed-org">{{.Name}}</li>{{end}}
</ul>
{{end}}
{{if .Preview.OrgsLeft}}
<div class="hr-text hr-text-left">Organizations you'll simply leave</div>
<p class="text-secondary">These carry on without you:</p>
<ul class="text-secondary">
{{range .Preview.OrgsLeft}}<li>{{.Name}}</li>{{end}}
</ul>
{{end}}
{{/* Each line is conditional: listing "0 passkeys" or access to "0 repeaters"
reads as a bug, and padding the consequences with non-consequences makes
the real ones easier to skim past. */}}
<div class="hr-text hr-text-left">Also removed</div>
<ul class="text-secondary mb-0">
{{if .Preview.Passkeys}}
<li>{{.Preview.Passkeys}} passkey{{if ne .Preview.Passkeys 1}}s{{end}}{{if .HasPassword}} and your password{{end}}</li>
{{else if .HasPassword}}
<li>Your password</li>
{{end}}
{{if .Preview.SharedWithUser}}
<li>Your access to {{.Preview.SharedWithUser}} repeater{{if ne .Preview.SharedWithUser 1}}s{{end}} other people share with you</li>
{{end}}
<li>Your display name, bio, location, callsign, links, and email address</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h2 class="card-title">What stays</h2></div>
<div class="card-body">
<p class="text-secondary mb-0">
Commands you ran on <em>other people's</em> repeaters stay in those repeaters' logs, and maintenance
notes you wrote stay in their history — recorded against the name you had at the time, with no link
back to an account. Removing them would tear holes in records their owners rely on. Your username is
held in reserve for 90 days so nobody can pick it up and inherit those mentions.
</p>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h2 class="card-title">Confirm it's you</h2></div>
<div class="card-body">
<form method="post" action="/account/delete" id="delete-account-form"
data-confirm="Permanently delete your account? This cannot be undone.">
{{if .HasPassword}}
{{/* Deliberately not `required`: a passkey holder verifies instead of
typing, and browser validation would block that submit. The server
accepts either proof and says which is missing. */}}
<label class="form-label" for="password">Your password</label>
<div class="input-group mb-3">
<input type="password" class="form-control" id="password" name="password"
autocomplete="current-password">
<span class="input-group-text">
<button type="button" class="link-secondary border-0 bg-transparent p-0" data-pwtoggle="password"
aria-pressed="false" aria-label="Show password" title="Show password">{{template "icon-eye" ""}}</button>
</span>
</div>
{{end}}
<div class="btn-list">
<button type="submit" class="btn btn-danger" data-testid="confirm-delete">
{{template "icon-trash" "me-1"}}Delete my account
</button>
<a class="btn" href="/account">Cancel</a>
</div>
</form>
{{if and .HasPasskeys (not .HasPassword)}}
{{/* Passkey-only: there's no password to type, so the ceremony IS the
confirmation. The button verifies, then submits the form above. */}}
<p class="text-secondary mt-3 mb-2">Verify with your passkey to enable deletion.</p>
<button type="button" class="btn btn-outline-danger" id="delete-reauth-btn"
data-form="delete-account-form" data-testid="verify-passkey">
Verify with passkey
</button>
<div class="mt-2" id="passkey-status" role="status" aria-live="polite"></div>
{{else if .HasPasskeys}}
<p class="text-secondary mt-3 mb-2">Or verify with a passkey instead of typing your password:</p>
<button type="button" class="btn btn-outline-danger" id="delete-reauth-btn"
data-form="delete-account-form" data-testid="verify-passkey">
Verify with passkey
</button>
<div class="mt-2" id="passkey-status" role="status" aria-live="polite"></div>
{{end}}
</div>
</div>
<a class="back-link mt-3" href="/account">{{template "icon-arrow-left" "me-1"}}Back to account</a>
{{end}}
</div>
</div>
<script src="{{ asset "/static/webauthn.js" }}"></script>
{{end}}
+8
View File
@@ -87,6 +87,14 @@ func (s *Handlers) Routes() chi.Router {
r.Post("/account/email/verify", s.handleResendEmailVerification)
r.Post("/account/passkeys/rename", s.handleRenamePasskey)
r.Post("/account/passkeys/delete", s.handleDeletePasskey)
r.Get("/account/delete", s.pageDeleteAccount)
r.Post("/account/delete", s.handleDeleteAccount)
// Re-auth ceremony for passkey holders confirming a sensitive action.
// Session-gated (it asserts against the signed-in user's own
// credentials), so it lives here rather than with the public /api
// sign-in ceremonies.
r.Post("/account/reauth/passkey/begin", s.Auth.ReauthPasskeyBegin)
r.Post("/account/reauth/passkey/finish", s.Auth.ReauthPasskeyFinish)
})
// Bare visits to the auth host go to the sign-in page.
+197
View File
@@ -0,0 +1,197 @@
package core
import (
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/jleight/meshtender/internal/store"
)
// TestDeleteAccountPageShowsConsequences: the confirm page has to state what
// would actually be destroyed, and offer the handover for a repeater that has a
// steward — that link is the whole reason transfer exists.
func TestDeleteAccountPageShowsConsequences(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
authSSO(t, ts, h, "bootstrapadmin")
sso := authSSO(t, ts, h, "leaving")
u, err := st.GetUserByUsername(ctx, "leaving")
if err != nil {
t.Fatal(err)
}
rep := newOwnedRepeater(t, st, ctx, u.ID, "Hilltop")
steward, err := st.CreateUser(ctx, "keeper", "")
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 := do(t, ts, h.auth, "/account/delete", sso)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /account/delete = %d, want 200", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
page := string(raw)
if !strings.Contains(page, "Hilltop") {
t.Fatal("confirm page doesn't name the repeater that would be destroyed")
}
if !strings.Contains(page, "/repeaters/"+rep.PublicID+"/transfer") {
t.Fatal("confirm page doesn't offer to transfer a repeater that has a steward")
}
// The account page must lead here.
acct := do(t, ts, h.auth, "/account", sso)
defer acct.Body.Close()
accRaw, _ := io.ReadAll(acct.Body)
if !strings.Contains(string(accRaw), `href="/account/delete"`) {
t.Fatal("account page has no link to delete the account")
}
}
// TestDeleteAccountRequiresPassword is the re-auth gate: a live session alone
// must not be enough to destroy the account.
func TestDeleteAccountRequiresPassword(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
authSSO(t, ts, h, "bootstrapadmin")
sso := authSSO(t, ts, h, "cautious")
u, err := st.GetUserByUsername(ctx, "cautious")
if err != nil {
t.Fatal(err)
}
// No password at all.
empty := post(t, ts, h.auth, "/account/delete", url.Values{}, sso)
empty.Body.Close()
assertRedirect(t, empty, "/account/delete", "delete with no proof")
if loc, _ := url.Parse(empty.Header.Get("Location")); loc.Query().Get("error") == "" {
t.Fatal("refusal carried no error message")
}
// Wrong password.
wrong := post(t, ts, h.auth, "/account/delete", url.Values{"password": {"not-the-password"}}, sso)
wrong.Body.Close()
assertRedirect(t, wrong, "/account/delete", "delete with wrong password")
if _, err := st.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("account was deleted without a valid password: %v", err)
}
}
// TestDeleteAccountSucceeds: with the right password the account and its data go,
// the session is torn down, and the freed username is held in reserve.
func TestDeleteAccountSucceeds(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
authSSO(t, ts, h, "bootstrapadmin")
sso := authSSO(t, ts, h, "goodbye")
u, err := st.GetUserByUsername(ctx, "goodbye")
if err != nil {
t.Fatal(err)
}
rep := newOwnedRepeater(t, st, ctx, u.ID, "Doomed")
resp := post(t, ts, h.auth, "/account/delete", url.Values{"password": {testPassword}}, sso)
resp.Body.Close()
assertRedirect(t, resp, "/login", "delete account")
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Query().Get("ok") == "" {
t.Fatal("successful deletion carried no confirmation")
}
if _, err := st.GetUserByID(ctx, u.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("GetUserByID after delete = %v, want ErrNotFound", err)
}
if _, err := st.RepeaterIDByPublicID(ctx, rep.PublicID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("owned repeater survived the account: %v", err)
}
// The session is dead everywhere: the old cookie no longer authenticates on
// the auth host (the logins row cascaded away with the account).
after := do(t, ts, h.auth, "/account", sso)
defer after.Body.Close()
if after.StatusCode == http.StatusOK {
t.Fatal("the deleted account's session still opens the account page")
}
}
// TestDeleteAccountBlockedBySoleOrgAdmin: the page explains the blocker instead
// of offering a button that would fail, and the POST refuses too (a user who
// skips the page must not get further than one who reads it).
func TestDeleteAccountBlockedBySoleOrgAdmin(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
authSSO(t, ts, h, "bootstrapadmin")
sso := authSSO(t, ts, h, "clubadmin")
u, err := st.GetUserByUsername(ctx, "clubadmin")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "Radio Club", u.ID)
if err != nil {
t.Fatal(err)
}
member, err := st.CreateUser(ctx, "clubmember", "")
if err != nil {
t.Fatal(err)
}
if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
page := do(t, ts, h.auth, "/account/delete", sso)
defer page.Body.Close()
raw, _ := io.ReadAll(page.Body)
body := string(raw)
if !strings.Contains(body, "Radio Club") {
t.Fatal("confirm page doesn't name the org blocking deletion")
}
if strings.Contains(body, `data-testid="confirm-delete"`) {
t.Fatal("confirm page offers a delete button that could only fail")
}
resp := post(t, ts, h.auth, "/account/delete", url.Values{"password": {testPassword}}, sso)
resp.Body.Close()
assertRedirect(t, resp, "/account/delete", "blocked delete")
if _, err := st.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("account deleted despite being an org's only admin: %v", err)
}
}
// TestReauthPasskeyBeginRequiresSession: the re-auth ceremony is session-scoped
// and takes no username, so it can't be used anonymously or as an account oracle.
func TestReauthPasskeyBeginRequiresSession(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
anon := post(t, ts, h.auth, "/account/reauth/passkey/begin", url.Values{})
defer anon.Body.Close()
// Unauthenticated requests are bounced by the session guard before reaching
// the handler; either way they must not get a challenge.
if anon.StatusCode == http.StatusOK {
t.Fatalf("anonymous reauth/begin = 200, want a rejection")
}
}
// TestReauthPasskeyBeginWithoutPasskey: an account with no passkey gets a clean
// 400 rather than an unusable challenge.
func TestReauthPasskeyBeginWithoutPasskey(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
authSSO(t, ts, h, "bootstrapadmin")
sso := authSSO(t, ts, h, "nopasskey")
resp := post(t, ts, h.auth, "/account/reauth/passkey/begin", url.Values{}, sso)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("reauth/begin with no passkey = %d, want 400", resp.StatusCode)
}
}
+126
View File
@@ -0,0 +1,126 @@
//go:build browser
package e2e
import (
"errors"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/jleight/meshtender/internal/store"
)
// waitForUserGone polls until the account is gone, so the assertion doesn't race
// the server side of the delete request.
func waitForUserGone(t *testing.T, e *e2eServer, id int64) bool {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
_, err := e.store.GetUserByID(e.ctx, id)
if errors.Is(err, store.ErrNotFound) {
return true
}
time.Sleep(200 * time.Millisecond)
}
return false
}
// TestE2EDeleteAccountWithPasskey is the passkey-only deletion path, which no Go
// test can reach: the account has no password, so the ONLY way to prove presence
// is a real assertion. The button runs the ceremony against a virtual
// authenticator and, once the server stamps the session, submits the form.
//
// It also proves the confirm page runs clean under the strict CSP — this page
// carries a password toggle, a confirm gate and a WebAuthn ceremony, all of
// which are exactly the things a CSP breaks silently.
func TestE2EDeleteAccountWithPasskey(t *testing.T) {
e := newE2EServer(t)
// The first account is auto-promoted to superadmin and could never delete
// itself (it'd be the last administrator), so park one before the subject.
e.login(t, "e2edeleteadmin")
victim, cookie := e.login(t, "e2edeleteme")
ctx, cancel, watch := startBrowser(t)
defer cancel()
if err := virtualAuthenticator(ctx); err != nil {
t.Fatalf("set up virtual authenticator: %v", err)
}
acceptDialogs(ctx)
// Register a passkey on the account page, so the account has a passkey and no
// password — the state that forces the re-auth ceremony.
if err := chromedp.Run(ctx,
setSessionCookie(cookie),
chromedp.Navigate(e.authURL+"/account"),
chromedp.WaitVisible("#add-passkey-btn", chromedp.ByID),
chromedp.Click("#add-passkey-btn", chromedp.ByID),
); err != nil {
t.Fatalf("add a passkey: %v", err)
}
deadline := time.Now().Add(20 * time.Second)
for {
creds, err := e.store.GetCredentials(e.ctx, victim.ID)
if err != nil {
t.Fatalf("GetCredentials: %v", err)
}
if len(creds) == 1 {
break
}
if time.Now().After(deadline) {
t.Fatal("passkey registration never landed")
}
time.Sleep(200 * time.Millisecond)
}
// Now delete the account: verify with the passkey, which submits the form.
if err := chromedp.Run(ctx,
chromedp.Navigate(e.authURL+"/account/delete"),
chromedp.WaitVisible(`[data-testid="verify-passkey"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="verify-passkey"]`, chromedp.ByQuery),
waitForLocation(e.authURL+"/login"),
); err != nil {
var status string
_ = chromedp.Run(ctx, chromedp.Text("#passkey-status", &status, chromedp.ByID, chromedp.AtLeast(0)))
t.Fatalf("drive the delete page: %v (page status = %q)", err, status)
}
if !waitForUserGone(t, e, victim.ID) {
t.Fatal("the account still exists after a verified deletion")
}
watch.assertClean(t)
}
// TestE2EDeleteAccountWithoutVerifying: clicking delete without proving presence
// must not destroy the account. This is the whole point of the re-auth gate —
// a live session on an unattended browser isn't enough.
func TestE2EDeleteAccountWithoutVerifying(t *testing.T) {
e := newE2EServer(t)
e.login(t, "e2ekeepadmin")
victim, cookie := e.login(t, "e2ekeepme")
e.setPassword(t, victim.ID, "correct-horse-battery")
ctx, cancel, watch := startBrowser(t)
defer cancel()
acceptDialogs(ctx)
var errText string
if err := chromedp.Run(ctx,
setSessionCookie(cookie),
chromedp.Navigate(e.authURL+"/account/delete"),
// Submit with the password field left empty.
chromedp.Click(`[data-testid="confirm-delete"]`, chromedp.ByQuery),
waitForLocation(e.authURL+"/account/delete?error="),
chromedp.Text(".alert-danger", &errText, chromedp.ByQuery),
); err != nil {
t.Fatalf("drive the delete page: %v", err)
}
if errText == "" {
t.Fatal("no error shown after submitting without a password")
}
if _, err := e.store.GetUserByID(e.ctx, victim.ID); err != nil {
t.Fatalf("account was deleted without any proof of presence: %v", err)
}
watch.assertClean(t)
}
+260
View File
@@ -0,0 +1,260 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
// Account deletion. The schema does most of the work — every FK to users either
// cascades or nulls out — so this file is about the two things the schema can't
// decide: what must BLOCK a deletion (leaving an org or the instance with no
// admin), and what must be cleaned up alongside the row.
//
// What deliberately survives, anonymised: the command log keeps its write-time
// sender_username, maintenance entries keep author_name, and orgs/config profiles
// keep their created_by history as NULL. That's by design (see migration 0020) —
// the record of what was done to a repeater outlives the person who did it.
var (
// ErrSoleOrgAdmin blocks deletion: the user is the only admin of an org that
// still has other members, which a cascade would leave adminless.
ErrSoleOrgAdmin = errors.New("store: sole admin of an org with other members")
// ErrLastSiteAdmin blocks deletion: no one else holds cap_manage_users.
ErrLastSiteAdmin = errors.New("store: last site administrator")
)
// DeletionOrg is one of the user's organizations, classified by what deleting
// their account would do to it.
type DeletionOrg struct {
ID int64
Slug string
Name string
Role string
Members int
}
// DeletionRepeater is one owned repeater that would be deleted, with the number
// of stewards who could receive it instead (a transfer is the alternative to
// destroying the site's documentation and history).
type DeletionRepeater struct {
PublicID string
Name string
Stewards int
}
// DeletionPreview is everything the confirm page needs to tell the truth about
// what deletion would do, plus the blockers that would refuse it.
type DeletionPreview struct {
// Repeaters they own; deleting the account deletes these outright.
Repeaters []DeletionRepeater
// OrgsDeleted are orgs where they're the only member — nobody else is left to
// keep them, so they go with the account.
OrgsDeleted []DeletionOrg
// OrgsLeft are orgs that simply lose a member.
OrgsLeft []DeletionOrg
// OrgsBlocked are orgs where they're the sole admin but others remain: someone
// else must be promoted first.
OrgsBlocked []DeletionOrg
// LastSiteAdmin is set when no other account holds cap_manage_users.
LastSiteAdmin bool
// SharedWithUser counts repeaters other people share with them (access lost,
// but nothing of anyone else's is destroyed).
SharedWithUser int
// Passkeys they have registered.
Passkeys int
}
// Blocked reports whether deletion would be refused as things stand.
func (p *DeletionPreview) Blocked() bool { return p.LastSiteAdmin || len(p.OrgsBlocked) > 0 }
// orgClassifySQL classifies every org the user belongs to in one pass: the org,
// their role in it, and the member/admin counts that decide whether deleting the
// account would leave it adminless. $1 is the user id.
const orgClassifySQL = `
SELECT o.id, o.slug, o.name, m.role,
(SELECT count(*) FROM org_members x WHERE x.org_id = o.id) AS members,
(SELECT count(*) FROM org_members x WHERE x.org_id = o.id AND x.role = 'admin') AS admins
FROM org_members m
JOIN organizations o ON o.id = m.org_id
WHERE m.user_id = $1
ORDER BY lower(o.name), o.id`
// classifiedOrg is one row of orgClassifySQL.
type classifiedOrg struct {
DeletionOrg
Admins int
}
// scanClassifiedOrgs reads orgClassifySQL rows.
func scanClassifiedOrgs(rows pgx.Rows) ([]classifiedOrg, error) {
return collectRows(rows, func(r pgx.Row) (classifiedOrg, error) {
var c classifiedOrg
err := r.Scan(&c.ID, &c.Slug, &c.Name, &c.Role, &c.Members, &c.Admins)
return c, err
})
}
// blocksDeletion reports whether this membership stops the account going: the
// user is an admin, the only one, and other people are still in the org.
func (c classifiedOrg) blocksDeletion() bool {
return c.Role == "admin" && c.Admins <= 1 && c.Members > 1
}
// goesWithAccount reports whether the org should be deleted alongside the
// account: the user is its only member, so nothing of anyone else's is in it.
func (c classifiedOrg) goesWithAccount() bool { return c.Members <= 1 }
// PreviewUserDeletion assembles what deleting userID would do. It is a read-only
// snapshot for the confirm page — DeleteUser re-checks every blocker inside its
// transaction, so a stale preview can't let a blocked deletion through.
func (s *Store) PreviewUserDeletion(ctx context.Context, userID int64) (*DeletionPreview, error) {
p := &DeletionPreview{}
rows, err := s.pool.Query(ctx, `
SELECT r.public_id, r.name,
(SELECT count(*) FROM repeater_shares rs
WHERE rs.repeater_id = r.id AND rs.steward) AS stewards
FROM repeaters r WHERE r.owner_id = $1
ORDER BY lower(r.name), r.id`, userID)
if err != nil {
return nil, fmt.Errorf("preview repeaters: %w", err)
}
p.Repeaters, err = collectRows(rows, func(r pgx.Row) (DeletionRepeater, error) {
var d DeletionRepeater
err := r.Scan(&d.PublicID, &d.Name, &d.Stewards)
return d, err
})
if err != nil {
return nil, fmt.Errorf("scan preview repeaters: %w", err)
}
orgRows, err := s.pool.Query(ctx, orgClassifySQL, userID)
if err != nil {
return nil, fmt.Errorf("preview orgs: %w", err)
}
orgs, err := scanClassifiedOrgs(orgRows)
if err != nil {
return nil, fmt.Errorf("scan preview orgs: %w", err)
}
for _, o := range orgs {
switch {
case o.blocksDeletion():
p.OrgsBlocked = append(p.OrgsBlocked, o.DeletionOrg)
case o.goesWithAccount():
p.OrgsDeleted = append(p.OrgsDeleted, o.DeletionOrg)
default:
p.OrgsLeft = append(p.OrgsLeft, o.DeletionOrg)
}
}
if err := s.pool.QueryRow(ctx, `
SELECT
(SELECT cap_manage_users FROM users WHERE id = $1)
AND (SELECT count(*) FROM users WHERE cap_manage_users) <= 1,
(SELECT count(*) FROM repeater_shares WHERE user_id = $1),
(SELECT count(*) FROM webauthn_credentials WHERE user_id = $1)`,
userID).Scan(&p.LastSiteAdmin, &p.SharedWithUser, &p.Passkeys); err != nil {
return nil, fmt.Errorf("preview counts: %w", err)
}
return p, nil
}
// DeleteUser permanently deletes an account and everything the schema cascades
// from it: passkeys, logins (which drops every host session at once), profile
// links, org memberships, shares, and the repeaters they own along with those
// repeaters' invites, docs, confirmations, maintenance and command history.
//
// It refuses with ErrLastSiteAdmin or ErrSoleOrgAdmin rather than leaving the
// instance or an organization with nobody able to administer it. Both checks run
// under row locks inside the transaction, so two people deleting simultaneously
// can't both see "someone else is still an admin" and race the count to zero.
//
// Orgs where the user is the only member are deleted with the account — there is
// nobody left to hand them to, and everything in them is the departing user's.
//
// Returns ErrNotFound if the account is already gone.
func (s *Store) DeleteUser(ctx context.Context, userID int64) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
var username string
var siteAdmin bool
if err := tx.QueryRow(ctx,
`SELECT username, cap_manage_users FROM users WHERE id = $1 FOR UPDATE`,
userID).Scan(&username, &siteAdmin); err != nil {
return notFoundOr(err, "lock user")
}
// Locking every site-admin row serializes concurrent admin deletions: the
// second one blocks, then re-reads a set that no longer contains the first
// and correctly finds itself to be the last.
if siteAdmin {
rows, err := tx.Query(ctx, `SELECT id FROM users WHERE cap_manage_users FOR UPDATE`)
if err != nil {
return fmt.Errorf("lock site admins: %w", err)
}
admins, err := collectRows(rows, scanID)
if err != nil {
return fmt.Errorf("lock site admins: %w", err)
}
if len(admins) <= 1 {
return ErrLastSiteAdmin
}
}
// Lock the membership rows of every org the user belongs to before
// classifying them, so a concurrent leave/demote elsewhere can't change the
// answer between the check and the delete (the same guarantee
// guardLastAdminTx gives the leave path).
if _, err := tx.Exec(ctx, `
SELECT 1 FROM org_members
WHERE org_id IN (SELECT org_id FROM org_members WHERE user_id = $1)
FOR UPDATE`, userID); err != nil {
return fmt.Errorf("lock org memberships: %w", err)
}
orgRows, err := tx.Query(ctx, orgClassifySQL, userID)
if err != nil {
return fmt.Errorf("classify orgs: %w", err)
}
orgs, err := scanClassifiedOrgs(orgRows)
if err != nil {
return fmt.Errorf("scan orgs: %w", err)
}
var orphaned []int64
for _, o := range orgs {
if o.blocksDeletion() {
return ErrSoleOrgAdmin
}
if o.goesWithAccount() {
orphaned = append(orphaned, o.ID)
}
}
if len(orphaned) > 0 {
if _, err := tx.Exec(ctx,
`DELETE FROM organizations WHERE id = ANY($1)`, orphaned); err != nil {
return fmt.Errorf("delete solo orgs: %w", err)
}
}
// Reserve the freed username for the usual release cooldown. Profiles are
// public at /u/{username} and @handles are baked into command logs and
// maintenance notes, so a name freed by deletion must not be claimable the
// next minute by someone inheriting that history. The row's user_id nulls
// out with the cascade below, and nameReservedByOther treats NULL as "not
// you" for every caller — so it's reserved against everyone, which is what
// a deleted account needs (nobody can prove they were its owner).
//
// new_username is empty: this is a release, not a rename to something.
if _, err := tx.Exec(ctx, `
INSERT INTO username_changes (user_id, old_username, new_username, changed_by)
VALUES ($1, $2, '', $1)`, userID, username); err != nil {
return fmt.Errorf("reserve released username: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID); err != nil {
return fmt.Errorf("delete user: %w", err)
}
return nil
})
}
+470
View File
@@ -0,0 +1,470 @@
package store
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
// userFK is one foreign key pointing at users(id), with its ON DELETE action.
type userFK struct {
Table string
Column string
Action rune // pg_constraint.confdeltype: 'c' cascade, 'n' set null, 'a'/'r' block
}
// userFKs reads every FK referencing users(id) straight from the catalog, so the
// tests below cover the schema as it actually is rather than a list that rots.
func userFKs(t *testing.T, st *Store, ctx context.Context) []userFK {
t.Helper()
rows, err := st.pool.Query(ctx, `
SELECT c.conrelid::regclass::text, a.attname, c.confdeltype::text
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
WHERE c.contype = 'f' AND c.confrelid = 'users'::regclass
ORDER BY 1, 2`)
if err != nil {
t.Fatalf("read user FKs: %v", err)
}
fks, err := collectRows(rows, func(r pgx.Row) (userFK, error) {
var f userFK
var action string
err := r.Scan(&f.Table, &f.Column, &action)
if action != "" {
f.Action = rune(action[0])
}
return f, err
})
if err != nil {
t.Fatalf("scan user FKs: %v", err)
}
return fks
}
// bootstrapAdmin creates the instance's first account, which store.CreateUser
// automatically promotes to superadmin. Deletion tests call this first so their
// subject is an ORDINARY user — otherwise the subject is itself the last site
// admin and every deletion is (correctly) refused.
func bootstrapAdmin(t *testing.T, st *Store, ctx context.Context) *User {
t.Helper()
admin, err := st.CreateUser(ctx, "instanceadmin", "")
if err != nil {
t.Fatalf("create bootstrap admin: %v", err)
}
if !admin.CapManageUsers {
t.Fatal("the first account was not promoted to site admin; fixture assumption is stale")
}
return admin
}
// populatedDeletionFixture builds an account that has touched as much of the
// schema as it reasonably can — owned repeater, someone else's shared repeater,
// passkey, login, auth code, email token, profile link, org membership, console
// session, command log, maintenance entry, rename history — so the deletion
// tests are exercising real rows rather than an empty account.
func populatedDeletionFixture(t *testing.T, st *Store, ctx context.Context) (victim *User, ownRepeater *Repeater) {
t.Helper()
bootstrapAdmin(t, st, ctx)
victim, err := st.CreateUser(ctx, "victim", "Vic Tim")
if err != nil {
t.Fatalf("create user: %v", err)
}
other, err := st.CreateUser(ctx, "bystander", "")
if err != nil {
t.Fatalf("create other: %v", err)
}
ownRepeater, err = st.CreateRepeater(ctx, &Repeater{
OwnerID: victim.ID, Name: "Theirs", PublicKeyHex: strings.Repeat("a", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
// A repeater owned by someone else, shared with the victim (with a grant):
// their access goes, the repeater itself must not.
theirs, err := st.CreateRepeater(ctx, &Repeater{
OwnerID: other.ID, Name: "Not theirs", PublicKeyHex: strings.Repeat("b", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create other repeater: %v", err)
}
if _, err := st.AddShare(ctx, theirs.ID, victim.ID); err != nil {
t.Fatalf("add share: %v", err)
}
catalog, err := st.ListCommands(ctx)
if err != nil || len(catalog) == 0 {
t.Fatalf("list commands: %v", err)
}
if err := st.SetShareCommands(ctx, theirs.ID, victim.ID, []int64{catalog[0].ID}); err != nil {
t.Fatalf("set share commands: %v", err)
}
if err := st.AddCredential(ctx, victim.ID, []byte("cred-id"), []byte(`{}`), "laptop"); err != nil {
t.Fatalf("add credential: %v", err)
}
loginID, err := st.CreateLogin(ctx, victim.ID)
if err != nil {
t.Fatalf("create login: %v", err)
}
if _, err := st.CreateAuthCode(ctx, victim.ID, loginID, "/"); err != nil {
t.Fatalf("create auth code: %v", err)
}
if _, err := st.CreateEmailToken(ctx, victim.ID, PurposeResetPassword, "", time.Hour); err != nil {
t.Fatalf("create email token: %v", err)
}
if err := st.ReplaceUserLinks(ctx, victim.ID, []UserLink{{Platform: "web", URL: "https://example.com"}}); err != nil {
t.Fatalf("replace links: %v", err)
}
// A rename, so username_changes already holds rows for this user.
if err := st.SetUsername(ctx, victim.ID, "victim2", UsernameChangeContext{ChangedBy: victim.ID}, false); err != nil {
t.Fatalf("rename: %v", err)
}
// Console, command and maintenance history against SOMEONE ELSE'S repeater.
// That's the case worth testing: activity on a node that outlives the account
// must stay in its owner's audit trail, anonymised. (The same rows on their own
// repeater would simply cascade away with it, proving nothing about SET NULL.)
sessID, err := st.StartConsoleSession(ctx, theirs.ID, victim.ID)
if err != nil {
t.Fatalf("start console session: %v", err)
}
if _, err := st.LogCommand(ctx, theirs.ID, victim.ID, sessID, catalog[0].ID, "ver"); err != nil {
t.Fatalf("log command: %v", err)
}
if err := st.AddMaintenanceEntry(ctx, theirs.ID, victim.ID, "Vic Tim", "swapped antenna", time.Now()); err != nil {
t.Fatalf("add maintenance: %v", err)
}
return victim, ownRepeater
}
// TestDeleteUserLeavesNoReferences is the invariant that matters most for a
// privacy feature: after deletion, NO row anywhere still references the deleted
// id. It reads the FK list from the catalog rather than hardcoding tables, so a
// table added later without a cascade fails here instead of quietly retaining
// personal data (or blocking deletion outright with a NO ACTION rule).
func TestDeleteUserLeavesNoReferences(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
victim, _ := populatedDeletionFixture(t, st, ctx)
fks := userFKs(t, st, ctx)
// Guard the guard: if the introspection query ever stops finding anything,
// this test would pass by checking nothing at all.
if len(fks) < 15 {
t.Fatalf("found only %d FKs referencing users; introspection is broken", len(fks))
}
for _, fk := range fks {
if fk.Action != 'c' && fk.Action != 'n' {
t.Errorf("%s.%s references users with ON DELETE %q: deletion would be blocked, "+
"not cascaded — every reference to a user must cascade or null out",
fk.Table, fk.Column, string(fk.Action))
}
}
if err := st.DeleteUser(ctx, victim.ID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
for _, fk := range fks {
var n int
q := "SELECT count(*) FROM " + fk.Table + " WHERE " + fk.Column + " = $1" //nolint:gosec // identifiers come from the catalog, not user input
if err := st.pool.QueryRow(ctx, q, victim.ID).Scan(&n); err != nil {
t.Fatalf("count %s.%s: %v", fk.Table, fk.Column, err)
}
if n != 0 {
t.Errorf("%s.%s still has %d row(s) referencing the deleted user", fk.Table, fk.Column, n)
}
}
if _, err := st.GetUserByID(ctx, victim.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("GetUserByID after delete = %v, want ErrNotFound", err)
}
}
// TestDeleteUserKeepsAnonymisedHistory: the operational record of what was done
// to a repeater must outlive the account that did it (migration 0020's promise),
// and other people's repeaters must survive their access being deleted.
func TestDeleteUserKeepsAnonymisedHistory(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
victim, ownRepeater := populatedDeletionFixture(t, st, ctx)
// The victim's own repeater goes; the one merely shared with them stays.
var otherRepeaters int
if err := st.pool.QueryRow(ctx,
`SELECT count(*) FROM repeaters WHERE owner_id <> $1`, victim.ID).Scan(&otherRepeaters); err != nil {
t.Fatal(err)
}
if err := st.DeleteUser(ctx, victim.ID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
var owned int
if err := st.pool.QueryRow(ctx,
`SELECT count(*) FROM repeaters WHERE public_id = $1`, ownRepeater.PublicID).Scan(&owned); err != nil {
t.Fatal(err)
}
if owned != 0 {
t.Fatal("the deleted user's own repeater survived")
}
var left int
if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM repeaters`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != otherRepeaters {
t.Fatalf("repeaters left = %d, want %d (other people's must survive)", left, otherRepeaters)
}
// The command log keeps its write-time username snapshot, with the user nulled.
var sender *string
var uid *int64
if err := st.pool.QueryRow(ctx,
`SELECT sender_username, user_id FROM command_log LIMIT 1`).Scan(&sender, &uid); err != nil {
t.Fatalf("read command log: %v", err)
}
if uid != nil {
t.Fatal("command_log.user_id was not nulled")
}
if sender == nil || *sender == "" {
t.Fatal("command_log lost its sender_username snapshot; the audit trail is now anonymous AND empty")
}
// Same for the maintenance history on the surviving repeater: the entry stays,
// attributed to the write-time name rather than to nobody.
entries, err := st.ListMaintenance(ctx, theirsID(t, st, ctx))
if err != nil {
t.Fatalf("list maintenance: %v", err)
}
if len(entries) != 1 {
t.Fatalf("maintenance entries = %d, want 1 (the entry outlives its author)", len(entries))
}
if entries[0].AuthorID != nil {
t.Fatal("maintenance author_id was not nulled")
}
if entries[0].AuthorName != "Vic Tim" {
t.Fatalf("maintenance author name = %q, want the write-time snapshot", entries[0].AuthorName)
}
}
// theirsID returns the id of the surviving (other owner's) repeater in the
// deletion fixture.
func theirsID(t *testing.T, st *Store, ctx context.Context) int64 {
t.Helper()
var id int64
if err := st.pool.QueryRow(ctx,
`SELECT id FROM repeaters WHERE name = 'Not theirs'`).Scan(&id); err != nil {
t.Fatalf("find surviving repeater: %v", err)
}
return id
}
// TestDeleteUserReservesUsername: a freed handle can't be claimed immediately.
// Public profiles live at /u/{username} and @handles are quoted in logs and
// maintenance notes, so an instantly-reusable name would let someone inherit a
// departed user's history.
func TestDeleteUserReservesUsername(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
bootstrapAdmin(t, st, ctx)
u, err := st.CreateUser(ctx, "departed", "")
if err != nil {
t.Fatal(err)
}
if err := st.DeleteUser(ctx, u.ID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
// Nobody may take the freed name — not a new signup...
if _, err := st.CreateUser(ctx, "departed", ""); !errors.Is(err, ErrUsernameReserved) {
t.Fatalf("CreateUser on a freed name = %v, want ErrUsernameReserved", err)
}
// ...nor an existing account renaming into it.
other, err := st.CreateUser(ctx, "opportunist", "")
if err != nil {
t.Fatal(err)
}
err = st.SetUsername(ctx, other.ID, "departed", UsernameChangeContext{ChangedBy: other.ID}, false)
if !errors.Is(err, ErrUsernameReserved) {
t.Fatalf("rename into a freed name = %v, want ErrUsernameReserved", err)
}
}
// TestDeleteUserSiteAdminGuard: the last administrator can't delete themselves
// out of the instance, but one of two can.
func TestDeleteUserSiteAdminGuard(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
first, err := st.CreateUser(ctx, "admin1", "")
if err != nil {
t.Fatal(err)
}
if err := st.SetCapabilities(ctx, first.ID, true, true); err != nil {
t.Fatal(err)
}
if err := st.DeleteUser(ctx, first.ID); !errors.Is(err, ErrLastSiteAdmin) {
t.Fatalf("deleting the only admin = %v, want ErrLastSiteAdmin", err)
}
second, err := st.CreateUser(ctx, "admin2", "")
if err != nil {
t.Fatal(err)
}
if err := st.SetCapabilities(ctx, second.ID, true, false); err != nil {
t.Fatal(err)
}
if err := st.DeleteUser(ctx, first.ID); err != nil {
t.Fatalf("deleting one of two admins: %v", err)
}
// And now the remaining one is the last, so they're stuck too.
if err := st.DeleteUser(ctx, second.ID); !errors.Is(err, ErrLastSiteAdmin) {
t.Fatalf("deleting the now-last admin = %v, want ErrLastSiteAdmin", err)
}
}
// TestDeleteUserOrgRules covers all three org outcomes: an org with other members
// but no other admin BLOCKS deletion; a solo org is deleted with the account; an
// org with another admin simply loses a member and survives intact.
func TestDeleteUserOrgRules(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
bootstrapAdmin(t, st, ctx)
leaver, err := st.CreateUser(ctx, "leaver", "")
if err != nil {
t.Fatal(err)
}
member, err := st.CreateUser(ctx, "member", "")
if err != nil {
t.Fatal(err)
}
// (a) sole admin, other members present → blocked.
shared, err := st.CreateOrg(ctx, "Shared Club", leaver.ID)
if err != nil {
t.Fatal(err)
}
if err := st.AddOrgMember(ctx, shared.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
// (b) solo org → goes with the account.
solo, err := st.CreateOrg(ctx, "Solo Org", leaver.ID)
if err != nil {
t.Fatal(err)
}
if err := st.DeleteUser(ctx, leaver.ID); !errors.Is(err, ErrSoleOrgAdmin) {
t.Fatalf("deleting the sole admin of a populated org = %v, want ErrSoleOrgAdmin", err)
}
// Nothing was half-applied by the refused deletion.
if _, err := st.GetOrg(ctx, solo.ID); err != nil {
t.Fatalf("solo org was deleted by a refused account deletion: %v", err)
}
if _, err := st.GetUserByID(ctx, leaver.ID); err != nil {
t.Fatalf("user was deleted despite the block: %v", err)
}
// Promoting someone else clears the block.
if err := st.SetOrgMemberRole(ctx, shared.ID, member.ID, "admin"); err != nil {
t.Fatal(err)
}
if err := st.DeleteUser(ctx, leaver.ID); err != nil {
t.Fatalf("DeleteUser after promoting a second admin: %v", err)
}
// (c) the shared org survives, minus the leaver; the solo org is gone.
if _, err := st.GetOrg(ctx, shared.ID); err != nil {
t.Fatalf("shared org did not survive: %v", err)
}
members, err := st.ListOrgMembers(ctx, shared.ID)
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || members[0].UserID != member.ID {
t.Fatalf("shared org members = %+v, want just the promoted member", members)
}
if _, err := st.GetOrg(ctx, solo.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("solo org = %v, want ErrNotFound (deleted with its only member)", err)
}
}
// TestPreviewUserDeletion: the confirm page's numbers must match what deletion
// actually does — including the steward count that decides whether a repeater
// can be handed over instead of destroyed.
func TestPreviewUserDeletion(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
victim, ownRepeater := populatedDeletionFixture(t, st, ctx)
// Give the owned repeater a steward, so the preview can offer a transfer.
successor, err := st.CreateUser(ctx, "successor", "")
if err != nil {
t.Fatal(err)
}
if _, err := st.AddShare(ctx, ownRepeater.ID, successor.ID); err != nil {
t.Fatal(err)
}
if err := st.SetShareSteward(ctx, ownRepeater.ID, successor.ID, true); err != nil {
t.Fatal(err)
}
solo, err := st.CreateOrg(ctx, "Solo", victim.ID)
if err != nil {
t.Fatal(err)
}
p, err := st.PreviewUserDeletion(ctx, victim.ID)
if err != nil {
t.Fatalf("PreviewUserDeletion: %v", err)
}
if p.Blocked() {
t.Fatalf("preview reports blocked, want deletable: %+v", p)
}
if len(p.Repeaters) != 1 || p.Repeaters[0].PublicID != ownRepeater.PublicID {
t.Fatalf("preview repeaters = %+v, want the one they own", p.Repeaters)
}
if p.Repeaters[0].Stewards != 1 {
t.Fatalf("preview steward count = %d, want 1 (a transfer is possible)", p.Repeaters[0].Stewards)
}
if len(p.OrgsDeleted) != 1 || p.OrgsDeleted[0].ID != solo.ID {
t.Fatalf("preview OrgsDeleted = %+v, want the solo org", p.OrgsDeleted)
}
if p.SharedWithUser != 1 {
t.Fatalf("preview SharedWithUser = %d, want 1", p.SharedWithUser)
}
if p.Passkeys != 1 {
t.Fatalf("preview Passkeys = %d, want 1", p.Passkeys)
}
// A blocked account reports the blocking org rather than a clean bill.
blocker, err := st.CreateUser(ctx, "blocked", "")
if err != nil {
t.Fatal(err)
}
club, err := st.CreateOrg(ctx, "Club", blocker.ID)
if err != nil {
t.Fatal(err)
}
if err := st.AddOrgMember(ctx, club.ID, victim.ID, "member"); err != nil {
t.Fatal(err)
}
bp, err := st.PreviewUserDeletion(ctx, blocker.ID)
if err != nil {
t.Fatal(err)
}
if !bp.Blocked() || len(bp.OrgsBlocked) != 1 || bp.OrgsBlocked[0].ID != club.ID {
t.Fatalf("preview for a sole admin = %+v, want blocked on the club", bp)
}
}
// TestDeleteUserMissing: deleting an already-deleted account is ErrNotFound, not
// a silent success.
func TestDeleteUserMissing(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
if err := st.DeleteUser(ctx, 999999); !errors.Is(err, ErrNotFound) {
t.Fatalf("DeleteUser(missing) = %v, want ErrNotFound", err)
}
}
+30
View File
@@ -153,6 +153,35 @@ async function passkeyLogin() {
}
}
// reauthPasskey re-verifies the ALREADY signed-in user before a sensitive action
// (account deletion), then submits the form named by the button's data-form.
//
// Unlike the sign-in ceremonies this posts no username — the server asserts
// against the session's own account — and grants no access by itself: it stamps
// the session as freshly verified, and the form's handler decides what that's
// worth. requestSubmit (not submit) so the form's [data-confirm] gate still runs.
async function reauthPasskey(e) {
const btn = e.currentTarget;
const form = document.getElementById(btn.getAttribute("data-form") || "");
try {
setStatus("Starting…");
const options = await postJSON("/account/reauth/passkey/begin", {});
const cred = await navigator.credentials.get({ publicKey: decodeRequest(options.publicKey) });
setStatus("Verifying…");
const result = await fetch("/account/reauth/passkey/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(encodeAssertion(cred)),
});
const data = await result.json().catch(() => ({}));
if (!result.ok) throw new Error(data.error || "verification failed");
setStatus("Verified.");
if (form) form.requestSubmit();
} catch (err) {
setStatus("Error: " + err.message);
}
}
// Tracks an in-flight conditional-mediation request so an explicit action can
// supersede the passive autofill prompt without the two colliding.
let conditionalAbort = null;
@@ -301,6 +330,7 @@ async function initSignupEmphasis() {
["add-passkey-btn", addPasskey],
["passkey-btn", passkeyButton],
["signup-passkey-btn", passkeyRegister],
["delete-reauth-btn", reauthPasskey],
];
bindings.forEach(function (b) {
var el = document.getElementById(b[0]);