mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Add option to delete your account
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user