fix(provisioning): keep the Owner seat single; clash on the operator name stays retryable
Two defects live-drilled in the break-glass staff provisioning: - An Owner reset that typed any username other than the occupied seat took UpsertOwner's insert arm and silently minted a SECOND owner row, leaving the existing seat — possibly the compromised account the reset was meant to replace — live; every owner row is undeletable through the panel, so the tier could never converge back to one. provisionOwner now refuses with ownerSeatTakenError naming the seat (recoverable: the TUI routes back to the form); bootstrap still mints, and the seat's own username still resets in place. PGRepo gains OwnerUsername for the guard. - InsertOperator returned the raw driver error on a taken username while the console keys its rename prompt off api.ErrConflict — the "choose another name" leg died with SQLSTATE 23505 against real Postgres (the fake encoded the contract; PGRepo had drifted). Map the unique violation to ErrConflict and pin it in pgint. Live (auditfix37): fresh username refused naming the seat; seat reset kept the id/email with still exactly one owner; taken operator name returned to the form with the retry note, and the retyped name succeeded (drill rows cleaned).
This commit is contained in:
6 files changed
+173
-27
No files matched your search
@@ -19,14 +19,15 @@ import (
|
||||
// terminal. The design is passwordless: accounts carry no credential, and the
|
||||
// Owner completes first-login through the setup-token web flow.
|
||||
type fakeOwnerStore struct {
|
||||
upserts []upsertCall
|
||||
inserts []upsertCall
|
||||
settings map[string][]byte
|
||||
audits []api.AuditEntry
|
||||
tokens []setupTokenCall
|
||||
redeems []redeemCall
|
||||
users map[string]*api.StaffUser // keyed by username
|
||||
admins bool // AdminExists answer
|
||||
upserts []upsertCall
|
||||
inserts []upsertCall
|
||||
settings map[string][]byte
|
||||
audits []api.AuditEntry
|
||||
tokens []setupTokenCall
|
||||
redeems []redeemCall
|
||||
users map[string]*api.StaffUser // keyed by username
|
||||
admins bool // AdminExists answer
|
||||
ownerSeat string // OwnerUsername answer: the occupied seat, "" when none
|
||||
|
||||
// CompleteOwnerSetup's success result. redeemUserID defaults to the fresh id
|
||||
// the caller passes (the unlinked-UUID case) when left empty.
|
||||
@@ -40,6 +41,7 @@ type fakeOwnerStore struct {
|
||||
auditErr error
|
||||
userErr error // non-not-found error from UserByUsername
|
||||
adminErr error
|
||||
seatErr error
|
||||
redeemErr error
|
||||
createTokenErr error
|
||||
}
|
||||
@@ -80,6 +82,15 @@ func (f *fakeOwnerStore) UserByUsername(_ context.Context, username string) (*ap
|
||||
return nil, api.ErrNotFound
|
||||
}
|
||||
|
||||
// OwnerUsername reports the single active Owner seat. Tests set ownerSeat; the
|
||||
// zero value models a fresh install where bootstrap is free to mint.
|
||||
func (f *fakeOwnerStore) OwnerUsername(_ context.Context) (string, error) {
|
||||
if f.seatErr != nil {
|
||||
return "", f.seatErr
|
||||
}
|
||||
return f.ownerSeat, nil
|
||||
}
|
||||
|
||||
func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email string) error {
|
||||
if f.upsertErr != nil {
|
||||
return f.upsertErr
|
||||
@@ -201,6 +212,34 @@ func TestProvisionOwner(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an occupied seat refuses any other username", func(t *testing.T) {
|
||||
// The seat is the single owner row: upserting a fresh name would take the
|
||||
// insert arm and mint a SECOND owner, while the existing seat — possibly the
|
||||
// compromised account this reset was meant to replace — stays live, and no
|
||||
// supported path can delete an owner row.
|
||||
f := &fakeOwnerStore{ownerSeat: "seat-holder"}
|
||||
err := provisionOwner(ctx, f, "someone-else", "")
|
||||
if !errors.Is(err, api.ErrConflict) {
|
||||
t.Fatalf("error = %v, want it to wrap api.ErrConflict so the TUI routes back to the form", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"seat-holder"`) {
|
||||
t.Errorf("error = %q, want it to name the occupied seat", err)
|
||||
}
|
||||
if len(f.upserts) != 0 {
|
||||
t.Errorf("want no write against an occupied seat, got %d", len(f.upserts))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the occupied seat's own username still resets", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{ownerSeat: "seat-holder"}
|
||||
if err := provisionOwner(ctx, f, "seat-holder", "[email protected]"); err != nil {
|
||||
t.Fatalf("provisionOwner(reset): %v", err)
|
||||
}
|
||||
if len(f.upserts) != 1 || f.upserts[0].username != "seat-holder" || f.upserts[0].email != "[email protected]" {
|
||||
t.Fatalf("want 1 reset upsert for the seat, got %+v", f.upserts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("propagates a store error", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{upsertErr: errors.New("boom")}
|
||||
if err := provisionOwner(ctx, f, "owner", ""); err == nil {
|
||||
|
||||
Reference in new issue
Block a user