Why: Administrator-issued password and login-key paths could take over target accounts, while old credentials and plugin handles ignored current account state. Evidence: SELF receipts /tmp/iam-u3-backend-all-replay.json, /tmp/iam-u3-backend-final-pg-core.json, /tmp/iam-u3-backend-build.json, and focused HTTP/concurrency receipts in /tmp/iam-u3-*.json. Impact: Adds trusted pending-account initialization and reissue, preexisting owner-session proof, proposal/rotation/grant transactions, server-side admin search, current Host guards, and legacy HTTP/CLI closure. Production cutover and downstream pins remain pending. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
154 lines
4.3 KiB
Go
154 lines
4.3 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestAccountStateDisablesCredentialsWithoutRestoringThem(t *testing.T) {
|
|
st := openTestStore(t)
|
|
adminHash, err := HashPassword("admin-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("admin", "", adminHash, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword("admin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
userHash, err := HashPassword("user-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("user", "", userHash, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword("user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, err := st.CreateAPIKey("user", "existing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := st.CreateSession("user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := st.GetAccountByOwner("user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := st.AuthOwner(key); ok {
|
|
t.Fatal("disabled user's API key authenticated")
|
|
}
|
|
if _, _, ok := st.SessionInfo(session); ok {
|
|
t.Fatal("disabled user's session authenticated")
|
|
}
|
|
if _, _, ok := st.VerifyLoginWithVersion("user", "user-password-1"); ok {
|
|
t.Fatal("disabled user logged in")
|
|
}
|
|
if _, err := st.CreateSession("user"); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("disabled user gained a new session: %v", err)
|
|
}
|
|
if _, err := st.CreateAPIKey("user", "new"); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("disabled user gained a new key: %v", err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion+1, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := st.AuthOwner(key); ok {
|
|
t.Fatal("revoked key was restored by enable")
|
|
}
|
|
if _, _, ok := st.SessionInfo(session); ok {
|
|
t.Fatal("revoked session was restored by enable")
|
|
}
|
|
if _, _, ok := st.VerifyLoginWithVersion("user", "user-password-1"); !ok {
|
|
t.Fatal("retained password could not log in after enable")
|
|
}
|
|
}
|
|
|
|
func TestAccountStateConcurrentLastTwoManagersAcrossPools(t *testing.T) {
|
|
st := openTestStore(t)
|
|
other := reopenAccountModelStore(t, st)
|
|
t.Logf("last managers dialect=%s two independent pools", st.Dialect())
|
|
for _, owner := range []string{"admin-a", "admin-b"} {
|
|
if err := st.CreateAccount(owner, "", "existing-hash", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword(owner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
a, _ := st.GetAccountByOwner("admin-a")
|
|
b, _ := st.GetAccountByOwner("admin-b")
|
|
start := make(chan struct{})
|
|
results := make(chan error, 2)
|
|
var wg sync.WaitGroup
|
|
for i, item := range []struct {
|
|
owner string
|
|
account Account
|
|
}{{"admin-a", a}, {"admin-b", b}} {
|
|
conn := []*Store{st, other}[i]
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, err := conn.SetAccountEnabled(item.owner, item.account.ID, item.account.StateVersion, false)
|
|
results <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
wins, conflicts := 0, 0
|
|
for err := range results {
|
|
if err == nil {
|
|
wins++
|
|
} else if errors.Is(err, ErrConflict) {
|
|
conflicts++
|
|
} else {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if wins != 1 || conflicts != 1 {
|
|
t.Fatalf("last-admin race wins=%d conflicts=%d", wins, conflicts)
|
|
}
|
|
a, _ = st.GetAccount(a.ID)
|
|
b, _ = st.GetAccount(b.ID)
|
|
if a.Enabled == b.Enabled {
|
|
t.Fatalf("must retain exactly one operable manager: a=%v b=%v", a.Enabled, b.Enabled)
|
|
}
|
|
}
|
|
|
|
func TestAccountStateProtectsLastOperableSystemManager(t *testing.T) {
|
|
st := openTestStore(t)
|
|
hash, err := HashPassword("admin-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, owner := range []string{"admin-a", "admin-b"} {
|
|
if err := st.CreateAccount(owner, "", hash, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword(owner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
a, _ := st.GetAccountByOwner("admin-a")
|
|
b, _ := st.GetAccountByOwner("admin-b")
|
|
if _, err := st.SetAccountEnabled("admin-a", a.ID, a.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin-b", b.ID, b.StateVersion, false); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("last operable manager disabled: %v", err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin-a", b.ID, b.StateVersion, false); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("disabled manager changed another account: %v", err)
|
|
}
|
|
}
|