ci / vet-and-test (pull_request) Successful in 49s
Why: Concurrent password changes, recovery key reissue, and session admission could accept retired credentials or omit rejected audits. Evidence: Local Go vet and full tests passed; real PostgreSQL store suite, deterministic interleavings, and two-process contention passed with SELF receipts. Impact: Account writes and session admission serialize per owner; stale sessions fail closed; checked rejections carry an audit row. Refs: Agent-System-Dev #486; Agent-System-Backend #185; merge queue Issue #518
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.CreateAccount("ada", "Ada", "", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeActivate, "admin", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _, err := s.RedeemLoginKey(key)
|
|
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")
|
|
}
|
|
}
|