Why: Historical full sessions could reach self-profile actions, and a lost recovery response had no safe idempotent retry contract. Evidence: SELF receipts /tmp/iam-u3-full-password-profile.json, /tmp/iam-u3-recovery-idempotent-sqlite.json, /tmp/iam-u3-recovery-idempotent-pg.json, /tmp/iam-u3-account-sse-pass.json, /tmp/iam-u3-backend-idempotent-all.json, and /tmp/iam-u3-backend-idempotent-pg.json. Impact: Profile actions require a password-authenticated full cookie; duplicate owner confirmation or rotation returns status without plaintext; an active SSE stream stops before its next business frame after disable. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
233 lines
7.6 KiB
Go
233 lines
7.6 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func trustedRecoveryFixture(t *testing.T, st *Store) (Account, string) {
|
|
t.Helper()
|
|
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := st.CreatePendingAccount("admin", "alice", AccountProfile{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
activation, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
setup, _, err := st.RedeemLoginKey(activation)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := HashPassword("initial-owner-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.CompleteSetPasswordChecked(setup, hash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err = st.GetAccount(account.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, version, valid := st.VerifyLoginWithVersion("alice", "initial-owner-password")
|
|
if !valid {
|
|
t.Fatal("trusted owner could not log in")
|
|
}
|
|
session, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return account, session
|
|
}
|
|
|
|
func TestOwnerRecoveryConcurrentConfirmAndDisableAcrossPools(t *testing.T) {
|
|
st := openTestStore(t)
|
|
other := reopenAccountModelStore(t, st)
|
|
t.Logf("owner recovery concurrency dialect=%s two independent pools", st.Dialect())
|
|
account, proof := trustedRecoveryFixture(t, st)
|
|
proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
type result struct {
|
|
grant string
|
|
err error
|
|
}
|
|
start := make(chan struct{})
|
|
confirmed := make(chan result, 1)
|
|
disabled := make(chan error, 1)
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
grant, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version)
|
|
confirmed <- result{grant: grant, err: err}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
|
|
disabled <- err
|
|
}()
|
|
close(start)
|
|
wg.Wait()
|
|
if err := <-disabled; err != nil {
|
|
t.Fatalf("disable lost race: %v", err)
|
|
}
|
|
out := <-confirmed
|
|
if out.err == nil && out.grant == "" {
|
|
t.Fatal("confirm succeeded without a grant")
|
|
}
|
|
current, err := st.GetAccount(account.ID)
|
|
if err != nil || current.Enabled {
|
|
t.Fatalf("target remained effective after disable: %+v %v", current, err)
|
|
}
|
|
if out.grant != "" {
|
|
if _, _, err := other.RedeemLoginKey(out.grant); err == nil {
|
|
t.Fatal("grant survived concurrent disable")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOwnerRecoveryConcurrentConfirmHasOneWinnerAcrossPools(t *testing.T) {
|
|
st := openTestStore(t)
|
|
other := reopenAccountModelStore(t, st)
|
|
t.Logf("owner confirmation dialect=%s two independent pools", st.Dialect())
|
|
account, proof := trustedRecoveryFixture(t, st)
|
|
proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-2")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start := make(chan struct{})
|
|
type result struct {
|
|
grant string
|
|
err error
|
|
}
|
|
results := make(chan result, 2)
|
|
var wg sync.WaitGroup
|
|
for _, conn := range []*Store{st, other} {
|
|
wg.Add(1)
|
|
go func(conn *Store) {
|
|
defer wg.Done()
|
|
<-start
|
|
grant, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version)
|
|
results <- result{grant, err}
|
|
}(conn)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
wins, metadataOnly, conflicts := 0, 0, 0
|
|
for outcome := range results {
|
|
if outcome.err == nil && outcome.grant != "" {
|
|
wins++
|
|
} else if outcome.err == nil && outcome.grant == "" {
|
|
metadataOnly++
|
|
} else if errors.Is(outcome.err, ErrConflict) {
|
|
conflicts++
|
|
} else {
|
|
t.Fatal(outcome.err)
|
|
}
|
|
}
|
|
if wins != 1 || metadataOnly+conflicts != 1 {
|
|
t.Fatalf("concurrent confirmation plaintext winners=%d metadata-only=%d conflicts=%d", wins, metadataOnly, conflicts)
|
|
}
|
|
}
|
|
|
|
func TestOwnerProofRecoveryBindsPreexistingSessionAndRotatesGrant(t *testing.T) {
|
|
st := openTestStore(t)
|
|
t.Logf("owner recovery dialect=%s", st.Dialect())
|
|
account, proof := trustedRecoveryFixture(t, st)
|
|
proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "request-1")
|
|
if err != nil || proposal.Status != RecoveryProposalPending || proposal.ID == "" {
|
|
t.Fatalf("proposal: %+v %v", proposal, err)
|
|
}
|
|
_, version, _ := st.VerifyLoginWithVersion("alice", "initial-owner-password")
|
|
lateSession, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := st.ConfirmRecoveryProposal(lateSession, proposal.ID, proposal.Version); !errors.Is(err, ErrOwnerProofUnavailable) {
|
|
t.Fatalf("post-proposal session became proof: %v", err)
|
|
}
|
|
first, confirmed, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version)
|
|
if err != nil || first == "" || confirmed.Status != RecoveryProposalConfirmed {
|
|
t.Fatalf("confirm: %+v %v", confirmed, err)
|
|
}
|
|
if replay, state, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version); err != nil || replay != "" || state.Version != confirmed.Version {
|
|
t.Fatalf("duplicate confirmation replayed secret or changed state: %+v %v", state, err)
|
|
}
|
|
second, rotated, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation)
|
|
if err != nil || second == first || rotated.Generation != confirmed.Generation+1 {
|
|
t.Fatalf("rotate: %+v %v", rotated, err)
|
|
}
|
|
if replay, state, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation); err != nil || replay != "" || state.Version != rotated.Version {
|
|
t.Fatalf("duplicate rotation replayed secret or changed state: %+v %v", state, err)
|
|
}
|
|
if _, _, err := st.RedeemLoginKey(first); err == nil {
|
|
t.Fatal("rotated grant remained redeemable")
|
|
}
|
|
restricted, owner, err := st.RedeemLoginKey(second)
|
|
if err != nil || owner != "alice" {
|
|
t.Fatalf("redeem owner recovery: %v %q", err, owner)
|
|
}
|
|
newHash, err := HashPassword("recovered-owner-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.CompleteSetPasswordChecked(restricted, newHash); err != nil {
|
|
t.Fatalf("finish recovery: %v", err)
|
|
}
|
|
current, err := st.GetAccount(account.ID)
|
|
if err != nil || current.ID != account.ID || current.CredentialOrigin != CredentialOriginOwnerRecovery {
|
|
t.Fatalf("recovery identity or lineage changed: %+v %v", current, err)
|
|
}
|
|
if _, ok := st.VerifyLogin("alice", "recovered-owner-password"); !ok {
|
|
t.Fatal("recovered password login failed")
|
|
}
|
|
if _, _, ok := st.SessionInfo(proof); ok {
|
|
t.Fatal("old proof session survived recovery")
|
|
}
|
|
if _, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, rotated.Version); err == nil {
|
|
t.Fatal("completed proposal confirmed twice")
|
|
}
|
|
}
|
|
|
|
func TestLegacyLineageHasNoOnlineOwnerProof(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacy, err := HashPassword("legacy-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("legacy", "", legacy, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword("legacy"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := st.GetAccountByOwner("legacy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, version, ok := st.VerifyLoginWithVersion("legacy", "legacy-password-1")
|
|
if !ok {
|
|
t.Fatal("legacy password login should remain usable")
|
|
}
|
|
if _, err := st.CreateSessionVerified("legacy", SessionScopeFull, time.Hour, version); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "legacy-attempt"); !errors.Is(err, ErrOwnerProofUnavailable) {
|
|
t.Fatalf("legacy password session became proof: %v", err)
|
|
}
|
|
}
|