Why: U7 account audit and identity gates advanced on main while U3 account initialization and recovery were in progress. Evidence: SELF merge-tree checks passed with go test ./... and isolated PostgreSQL store/server tests; exact-head replay follows. Impact: Preserve pending-account grants, proof-linked recovery, protected CLI delivery, account serialization, and atomic audit. Refs: IAM U3 r1; U7 PR #185
188 lines
5.6 KiB
Go
188 lines
5.6 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func activeAccount(t *testing.T) *Store {
|
|
t.Helper()
|
|
s := openTestStore(t)
|
|
h, e := HashPassword("old-password")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e := s.CreateAccount("ada", "Ada", h, false); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e := s.ClearMustChangePassword("ada"); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
return s
|
|
}
|
|
func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) {
|
|
for _, kind := range []string{"password", "revoke-all"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
s := activeAccount(t)
|
|
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
|
if !ok {
|
|
t.Fatal("healthy login failed")
|
|
}
|
|
var err error
|
|
if kind == "password" {
|
|
h, e := HashPassword("new-password")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
_, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h})
|
|
} else {
|
|
_, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"})
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil {
|
|
t.Fatal("stale in-flight login could create a session after credential replacement")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) {
|
|
s := openTestStore(t)
|
|
if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var n int
|
|
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatal("atomic issuance dropped main account event")
|
|
}
|
|
}
|
|
func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) {
|
|
s := openTestStore(t)
|
|
if err := s.EnsureSystemIdentity("test-grant-audit", "sqlite"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _, err := s.RedeemLoginKey(out.Plain)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := HashPassword("new-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.SetAccountFault(ErrConflict)
|
|
if _, err := s.CompleteSetPasswordChecked(token, h); err == nil {
|
|
t.Fatal("checked password path bypassed the shared audited commit")
|
|
}
|
|
if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword {
|
|
t.Fatal("failed checked change left a password behind")
|
|
}
|
|
if _, _, ok := s.SessionInfo(token); !ok {
|
|
t.Fatal("failed checked change consumed its session")
|
|
}
|
|
}
|
|
|
|
func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) {
|
|
s := openTestStore(t)
|
|
_, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"})
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("missing owner must be rejected before issuing any key: %v", err)
|
|
}
|
|
var n int
|
|
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 {
|
|
t.Fatalf("orphan credential rows=%d err=%v", n, err)
|
|
}
|
|
}
|
|
|
|
func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) {
|
|
s := activeAccount(t)
|
|
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := s.CreateSession("ada")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := HashPassword("replacement-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := s.RedeemLoginKey(key); err == nil {
|
|
t.Fatal("unused recovery key issued before active password change still redeems")
|
|
}
|
|
}
|
|
|
|
func TestRejectedActiveChangeIsAudited(t *testing.T) {
|
|
s := activeAccount(t)
|
|
h, err := HashPassword("next-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "")
|
|
if !errors.Is(err, ErrBadCredentials) {
|
|
t.Fatalf("expected bad credentials, got %v", err)
|
|
}
|
|
count, err := s.CountAudit("ada", "rejected")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count)
|
|
}
|
|
}
|
|
|
|
func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) {
|
|
s := activeAccount(t)
|
|
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
|
if !ok {
|
|
t.Fatal("healthy verification missing")
|
|
}
|
|
h, err := HashPassword("new-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Represents an insert racing after the replacement's earlier DELETE.
|
|
token := "sess_private_stale_fixture"
|
|
_, err = s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
|
|
hashKey(token), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, ok := s.SessionInfo(token); ok {
|
|
t.Fatal("session carrying the retired auth_version became authorized")
|
|
}
|
|
}
|
|
|
|
func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) {
|
|
s := activeAccount(t)
|
|
token, err := s.CreateSession("ada")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := HashPassword("new-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull {
|
|
t.Fatal("active change did not retain its current full session")
|
|
}
|
|
}
|