Files
Agent-System-Backend/internal/store/recovery_proposals_test.go
T
weichen 3e01614939 Require password lineage and make recovery retries metadata-only
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
2026-10-14 04:58:31 +00:00

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)
}
}