mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
1800059368
* fix(admin): the instance always keeps an active admin; demote, disable and self-delete share one locked guard (BUG-3811) SetUserRole guarded the last admin with a subselect inside one UPDATE. Under Postgres READ COMMITTED two admins demoting each other each counted the other and both committed, leaving zero admins. Disable and account deletion had no last-admin guard at all, and the demote guard counted disabled admins. guardActiveAdminLossTx takes one advisory lock (pad:active-admins) and refuses with ErrLastAdmin when the target is the last ACTIVE human admin. Demote, disable and account deletion call it before any row lock (deletion right after its pad:account-deletion lock). The delete handler asks CheckActiveAdminLoss before the irreversible Stripe cancel. All three handlers answer 409 last_active_admin (demote was 400). Behaviour change: demoting, disabling or deleting the last active admin is refused even when a disabled admin exists. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(admin): race tests wait on the guard's lock, not a timer; drop the unguarded DeleteUser (BUG-3811) Codex r1 on #2178. The concurrent-pair tests committed the held removal after a fixed 300/500ms; a second removal that had not reached the guard by then ran sequentially and could pass without the lock. Both now commit only once pg_locks shows the second session waiting on pad:active-admins, and fail if it never waits or returns early. Store.DeleteUser deleted any user, the sole active admin included, without the guard. It had no callers; removed. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
146 lines
5.3 KiB
Go
146 lines
5.3 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// BUG-3811: the instance keeps at least one active human admin, and every
|
|
// door that would take the last one answers 409 last_active_admin.
|
|
|
|
// The sole admin's account deletion is refused BEFORE the Stripe cancel,
|
|
// which cannot be undone, and the account is untouched.
|
|
func TestBUG3811_SoleAdminCannotDeleteTheirAccount(t *testing.T) {
|
|
srv := testServer(t)
|
|
token := bootstrapFirstUser(t, srv, "only-admin@test.com", "Only Admin")
|
|
u, err := srv.store.GetUserByEmail("only-admin@test.com")
|
|
if err != nil || u == nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.store.SetUserStripeCustomerID(u.ID, "cus_sole_admin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &fakeSidecar{}
|
|
srv.SetCloudSidecar(fake)
|
|
|
|
rr := deleteAccountReq(srv, map[string]any{"password": "correct-horse-battery-staple"}, token)
|
|
if rr.Code != http.StatusConflict || errorCode(t, rr) != "last_active_admin" {
|
|
t.Fatalf("sole admin delete-account: %d %s, want 409 last_active_admin", rr.Code, rr.Body.String())
|
|
}
|
|
if n := fake.callCount(); n != 0 {
|
|
t.Errorf("CancelCustomer called %d times for a refused deletion, want 0", n)
|
|
}
|
|
stillThere(t, srv, u.ID)
|
|
|
|
// CONTROL: with a second active admin the same request deletes.
|
|
if _, err := srv.store.CreateUser(models.UserCreate{Email: "second-admin@test.com", Name: "Second", Password: "pw-second-12345", Role: "admin"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rr := deleteAccountReq(srv, map[string]any{"password": "correct-horse-battery-staple"}, token); rr.Code != http.StatusOK {
|
|
t.Fatalf("delete-account beside a second admin: %d %s, want 200", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// Demote and disable can only reach the refusal concurrently: the caller is
|
|
// an active admin and cannot target themselves. The interleaving is held
|
|
// open by hand on Postgres: a transaction takes the guard's lock and demotes
|
|
// admin A, the HTTP demote or disable of admin B (by A's own session,
|
|
// admitted before A's demotion) waits on it, and answers 409 once A's
|
|
// demotion commits.
|
|
func TestBUG3811_DemoteAndDisableAnswer409(t *testing.T) {
|
|
for _, door := range []struct{ name, method, path string }{
|
|
{"demote", "PATCH", "/api/v1/admin/users/%s"},
|
|
{"disable", "POST", "/api/v1/admin/users/%s/disable"},
|
|
} {
|
|
t.Run(door.name, func(t *testing.T) {
|
|
// Postgres only: the refusal is reachable only through a concurrent
|
|
// removal, and SQLite serializes writers. Skips without
|
|
// PAD_TEST_POSTGRES_URL (make test-pg).
|
|
srv := accountDeleteServer(t, store.DriverPostgres)
|
|
bootstrapFirstUser(t, srv, "boot@test.com", "Boot")
|
|
mk := func(email string) (*models.User, string) {
|
|
u, err := srv.store.CreateUser(models.UserCreate{Email: email, Name: email, Password: "pw-test-12345", Role: "admin"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := srv.store.CreateSession(u.ID, "web", "192.0.2.9", "", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u, tok
|
|
}
|
|
a, aTok := mk("a-3811@test.com")
|
|
b, _ := mk("b-3811@test.com")
|
|
boot, _ := srv.store.GetUserByEmail("boot@test.com")
|
|
if err := srv.store.SetUserRole(boot.ID, "member"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tx, err := srv.store.DB().Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext('pad:active-admins'))"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tx.Exec("UPDATE users SET role = 'member' WHERE id = $1", a.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var body any
|
|
if door.name == "demote" {
|
|
body = map[string]any{"role": "member"}
|
|
}
|
|
done := make(chan int, 1)
|
|
var rrBody string
|
|
var code string
|
|
go func() {
|
|
rr := doRequestWithCookieFrom(srv, door.method, fmt.Sprintf(door.path, b.ID), body, aTok, "192.0.2.9:1")
|
|
rrBody = rr.Body.String()
|
|
code = errorCode(t, rr)
|
|
done <- rr.Code
|
|
}()
|
|
// Commit only once the request is seen WAITING on the guard's
|
|
// lock, not after a fixed sleep (codex r1 on #2178).
|
|
waited := false
|
|
for deadline := time.Now().Add(10 * time.Second); !waited && time.Now().Before(deadline); {
|
|
var n int
|
|
if err := srv.store.DB().QueryRow(`SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted
|
|
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
AND objsubid = 1
|
|
AND classid = (((hashtext('pad:active-admins')::bigint) >> 32) & 4294967295)::oid
|
|
AND objid = ((hashtext('pad:active-admins')::bigint) & 4294967295)::oid`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if waited = n > 0; !waited {
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
if !waited {
|
|
t.Fatalf("the %s request never waited on the active-admin lock", door.name)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var status int
|
|
select {
|
|
case status = <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the request never returned")
|
|
}
|
|
if status != http.StatusConflict || code != "last_active_admin" {
|
|
t.Fatalf("%s of the last active admin: %d %s, want 409 last_active_admin", door.name, status, rrBody)
|
|
}
|
|
if got, _ := srv.store.GetUser(b.ID); got == nil || got.Role != "admin" || got.IsDisabled() {
|
|
t.Errorf("B was changed by a refused %s: %+v", door.name, got)
|
|
}
|
|
})
|
|
}
|
|
}
|