fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
//go:build test
|
||||
|
||||
package twofa
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils/testdb"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
pool := testdb.CreateTestDatabase("crussell_test_internal_twofa")
|
||||
db.Conn = db.NewPoolProxy(pool)
|
||||
testdb.SeedBaseline(pool)
|
||||
code := m.Run()
|
||||
testdb.DestroyTestDatabase(pool, "crussell_test_internal_twofa")
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Package twofa owns the shared 2FA verification-code machinery: the
|
||||
// per-user brute-force attempt map, the constant-time code check, and the
|
||||
// exported verification entry point.
|
||||
//
|
||||
// Why this package exists (B11c coordination contract): handlers/user imports
|
||||
// handlers/payments (TwoFactorEnforced, SquareClient), so handlers/payments
|
||||
// CANNOT import handlers/user — Go would reject the cycle. The saved-card
|
||||
// charge gate (B6/B10, owned by the payments agent) needs to verify a real 2FA
|
||||
// challenge with the same brute-force lockout as the interactive endpoints, so
|
||||
// the verification core lives here, importing neither.
|
||||
//
|
||||
// Contract for the payments gate:
|
||||
//
|
||||
// err := twofa.VerifyForUser(ctx, userID, code)
|
||||
// if err != nil {
|
||||
// switch {
|
||||
// case errors.Is(err, twofa.ErrIncorrect):
|
||||
// // 400
|
||||
// case errors.Is(err, twofa.ErrLockedOut):
|
||||
// // 429
|
||||
// case errors.Is(err, twofa.ErrMissingOrExpired):
|
||||
// // 400 — user must request a fresh code
|
||||
// default:
|
||||
// // 500 (DB failure)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// The failed-attempt counter is keyed per user and resets ONLY on a successful
|
||||
// verify (or after the 10-minute attempt window elapses) — never on a fresh
|
||||
// code mint, so minting a new code cannot grant a fresh guessing budget (B11b).
|
||||
package twofa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// MaxAttempts is the number of consecutive failed verify attempts allowed
|
||||
// before the pending code is invalidated and a new one must be requested.
|
||||
const MaxAttempts = 5
|
||||
|
||||
// AttemptWindow bounds how long a per-user attempt counter lives before
|
||||
// resetting, and doubles as the stale-entry eviction horizon for the map.
|
||||
const AttemptWindow = 10 * time.Minute
|
||||
|
||||
// MaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
|
||||
// user IDs cannot grow it without bound. Counters are purely in-memory (the DB
|
||||
// schema is locked — there is no attempt column), so they reset on process
|
||||
// restart; the 10-minute pending-code expiry bounds the practical impact.
|
||||
// Declared as a var so the eviction policy is unit-testable at a small cap.
|
||||
var MaxTrackedAttempts = 10_000
|
||||
|
||||
// AttemptState tracks consecutive failed verify attempts for one user. The
|
||||
// per-user mutex serializes the whole verify critical section so concurrent
|
||||
// attempts from the same user cannot race the limit check. Count and LastAt are
|
||||
// atomic so the map eviction path can read them without taking the per-user
|
||||
// mutex (lock ordering forbids MapMu→st.Mu: Check holds st.Mu then takes
|
||||
// MapMu). LastAt is stored as nanoseconds since the Unix epoch so the eviction
|
||||
// scan and LockedOut read it race-free even on 32-bit platforms — a plain
|
||||
// time.Time read/write pair there could tear the 8-byte timestamp and reset or
|
||||
// extend the lockout window.
|
||||
// LastMintAt is the mint-cooldown stamp (see twoFAMintCooldown in the user
|
||||
// package); it is only ever touched under Mu.
|
||||
type AttemptState struct {
|
||||
Mu sync.Mutex
|
||||
Count atomic.Int32
|
||||
LastAt atomic.Int64
|
||||
LastMintAt time.Time
|
||||
}
|
||||
|
||||
// LastActive returns the state's last-activity timestamp (nanoseconds since
|
||||
// the Unix epoch, UTC). Reads are atomic so the map eviction scan can call it
|
||||
// while holding only MapMu.
|
||||
func (st *AttemptState) LastActive() time.Time {
|
||||
return time.Unix(0, st.LastAt.Load()).UTC()
|
||||
}
|
||||
|
||||
// SetLastActive records a last-activity timestamp. Writes happen under Mu
|
||||
// (Check) while the eviction scan reads under MapMu only — the atomic store
|
||||
// makes both race-free.
|
||||
func (st *AttemptState) SetLastActive(t time.Time) {
|
||||
st.LastAt.Store(t.UnixNano())
|
||||
}
|
||||
|
||||
// LockedOut reports whether the state is inside its lockout window: the attempt
|
||||
// counter has reached the cap and the window has not yet elapsed. Such a record
|
||||
// is the rate limit's source of truth for its user and must never be evicted
|
||||
// while in-window — evicting it would silently reset the counter and grant a
|
||||
// fresh guessing budget.
|
||||
func (st *AttemptState) LockedOut(now time.Time) bool {
|
||||
return st.Count.Load() >= MaxAttempts && now.Sub(st.LastActive()) <= AttemptWindow
|
||||
}
|
||||
|
||||
var (
|
||||
MapMu sync.Mutex
|
||||
Map = make(map[string]*AttemptState)
|
||||
)
|
||||
|
||||
// StateFor returns the per-user attempt state, creating it if needed. The map
|
||||
// is bounded: stale (window-expired) entries are evicted opportunistically and,
|
||||
// when at capacity, the least-recently-active non-locked-out entry is dropped.
|
||||
// A record still inside its lockout window is NEVER evicted — evicting it would
|
||||
// reset the victim's attempt counter and bypass the rate limit under a hostile
|
||||
// flood of new keys. When the map is full of in-window locked-out records (a
|
||||
// pathological flood), a transient, untracked state is returned instead of
|
||||
// growing the map past the cap.
|
||||
func StateFor(userID string) *AttemptState {
|
||||
MapMu.Lock()
|
||||
defer MapMu.Unlock()
|
||||
now := clock.Now()
|
||||
|
||||
if len(Map) >= MaxTrackedAttempts {
|
||||
var oldestID string
|
||||
var oldestAt time.Time
|
||||
for id, st := range Map {
|
||||
if now.Sub(st.LastActive()) > AttemptWindow {
|
||||
// Idle/expired — its counter has already lapsed; safe to evict.
|
||||
delete(Map, id)
|
||||
continue
|
||||
}
|
||||
if st.LockedOut(now) {
|
||||
// Inside its lockout window — the rate limit's source of truth
|
||||
// for this user. Never evict (finding-e fix).
|
||||
continue
|
||||
}
|
||||
if at := st.LastActive(); oldestID == "" || at.Before(oldestAt) {
|
||||
oldestID, oldestAt = id, at
|
||||
}
|
||||
}
|
||||
if len(Map) >= MaxTrackedAttempts && oldestID != "" {
|
||||
delete(Map, oldestID)
|
||||
}
|
||||
if len(Map) >= MaxTrackedAttempts {
|
||||
// Every entry is a locked-out in-window record. Do not evict one
|
||||
// (that would reset its rate limit) and do not grow past the cap:
|
||||
// return a transient, untracked state so THIS request still
|
||||
// proceeds under a fresh budget.
|
||||
st := &AttemptState{}
|
||||
st.SetLastActive(now)
|
||||
return st
|
||||
}
|
||||
}
|
||||
|
||||
st := Map[userID]
|
||||
if st == nil {
|
||||
st = &AttemptState{}
|
||||
st.SetLastActive(now)
|
||||
Map[userID] = st
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ResetAttempts resets a user's attempt counter in place (count only) WITHOUT
|
||||
// deleting the entry, preserving LastMintAt so the mint cooldown survives a
|
||||
// fresh-code delivery. Called on successful verify only — a fresh code mint
|
||||
// MUST NOT reset the counter, or a password-only attacker could loop
|
||||
// mint → burn 5 guesses → mint forever (B11b). LastAt is deliberately not
|
||||
// touched here: it is re-stamped by Check on real activity, and writing it
|
||||
// under MapMu would race with Check's Mu-guarded write. Lock ordering is
|
||||
// Mu→MapMu at call sites, never the reverse (StateFor takes MapMu only and
|
||||
// never takes Mu).
|
||||
func ResetAttempts(userID string) {
|
||||
MapMu.Lock()
|
||||
defer MapMu.Unlock()
|
||||
if st := Map[userID]; st != nil {
|
||||
st.Count.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAttempts removes a user's attempt-map entry entirely, unlike
|
||||
// ResetAttempts which only zeroes the count in place. The admin 2FA removal
|
||||
// flow uses it so any lingering lockout/counter/mint-cooldown state is dropped
|
||||
// wholesale and a re-setup starts from a clean slate.
|
||||
func DeleteAttempts(userID string) {
|
||||
MapMu.Lock()
|
||||
defer MapMu.Unlock()
|
||||
delete(Map, userID)
|
||||
}
|
||||
|
||||
// PepperProvider supplies the server-side HMAC pepper (TWO_FACTOR_PEPPER). The
|
||||
// build-dependent behavior — dev/test fallback to the legacy unsalted digest
|
||||
// with a one-time warning vs production fail-closed — is registered by the
|
||||
// handlers/user build-tagged files via SetPepperProvider.
|
||||
var PepperProvider = func() string { return "" }
|
||||
|
||||
// SetPepperProvider registers the build-specific pepper reader.
|
||||
func SetPepperProvider(f func() string) { PepperProvider = f }
|
||||
|
||||
// Hash returns the hex digest of a verification code as stored in the DB. With
|
||||
// TWO_FACTOR_PEPPER set the digest is HMAC-SHA256 keyed by the pepper, so a
|
||||
// leaked digest cannot be brute-forced offline (the key stays server-side).
|
||||
// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest:
|
||||
// dev/test builds also log a one-time warning, while production builds can
|
||||
// never persist such a digest because issuance fails closed without the pepper
|
||||
// — the fallback survives only for the legacy-row migration window and the
|
||||
// dev/test loose-fake flow. The plaintext code is never stored.
|
||||
func Hash(code string) string {
|
||||
if p := PepperProvider(); p != "" {
|
||||
mac := hmac.New(sha256.New, []byte(p))
|
||||
mac.Write([]byte(code))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// LegacyHash returns the pre-pepper plain SHA-256 digest, used to verify rows
|
||||
// written before TWO_FACTOR_PEPPER was provisioned during the migration window
|
||||
// (see VerifyHash).
|
||||
func LegacyHash(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// VerifyHash reports whether reqCode matches a stored pending-code digest,
|
||||
// always in constant time (subtle.ConstantTimeCompare). The first comparison
|
||||
// uses the current pepper'd digest; when that fails the stored hash may be a
|
||||
// legacy pre-pepper plain SHA-256 (rows written before TWO_FACTOR_PEPPER was
|
||||
// provisioned), so the legacy digest is tried too. When a legacy row matches,
|
||||
// legacy is true and the caller should re-hash with the pepper on the next
|
||||
// successful verify, retiring the plain digest.
|
||||
func VerifyHash(reqCode, storedHash string) (match, legacy bool) {
|
||||
if subtle.ConstantTimeCompare([]byte(Hash(reqCode)), []byte(storedHash)) == 1 {
|
||||
return true, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(LegacyHash(reqCode)), []byte(storedHash)) == 1 {
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Result classifies Check's outcome so callers can map it to the correct HTTP
|
||||
// status (or error, in VerifyForUser's case).
|
||||
type Result int
|
||||
|
||||
const (
|
||||
OK Result = iota
|
||||
Incorrect
|
||||
LockedOut
|
||||
MissingOrExpired
|
||||
)
|
||||
|
||||
// Check verifies the submitted code against the user's stored pending code
|
||||
// under the per-user brute-force lockout. The caller must hold st.Mu (from
|
||||
// StateFor) so concurrent attempts from the same user cannot race the limit
|
||||
// check. A correct code resets the attempt counter and returns OK. An incorrect
|
||||
// code increments the counter and, on the 5th consecutive failure, invalidates
|
||||
// the pending code (lockout). A missing or expired pending code returns
|
||||
// MissingOrExpired. The returned error is non-nil only for DB failures
|
||||
// (callers return 500); a lockout's pending-code invalidation failure is logged
|
||||
// here and still reported as a lockout.
|
||||
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string) (Result, error) {
|
||||
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
|
||||
st.Count.Store(0)
|
||||
st.SetLastActive(now)
|
||||
}
|
||||
if st.Count.Load() >= MaxAttempts {
|
||||
return LockedOut, nil
|
||||
}
|
||||
|
||||
var pendingHash sql.NullString
|
||||
var pendingExpires sql.NullTime
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&pendingHash, &pendingExpires)
|
||||
if err != nil {
|
||||
return LockedOut, err
|
||||
}
|
||||
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
|
||||
return MissingOrExpired, nil
|
||||
}
|
||||
// Constant-time compare (subtle) so a wrong code's match position cannot be
|
||||
// inferred from response timing. Both digests are fixed-length hex. Legacy
|
||||
// pre-pepper rows (plain SHA-256, hashed before TWO_FACTOR_PEPPER existed)
|
||||
// still verify during the transition window.
|
||||
match, legacy := VerifyHash(reqCode, pendingHash.String)
|
||||
if !match {
|
||||
st.Count.Add(1)
|
||||
st.SetLastActive(clock.Now())
|
||||
if st.Count.Load() >= MaxAttempts {
|
||||
// Lockout reached: destroy the pending code so a stolen digest
|
||||
// cannot be replayed against a fresh guessing loop.
|
||||
if _, err := db.Conn.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET two_factor_pending_code_hash = NULL,
|
||||
two_factor_pending_code_expires = NULL
|
||||
WHERE id = $1
|
||||
`, userID); err != nil {
|
||||
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
|
||||
}
|
||||
return LockedOut, nil
|
||||
}
|
||||
return Incorrect, nil
|
||||
}
|
||||
|
||||
// Success: a legacy (pre-pepper) hash that verified is re-hashed with the
|
||||
// pepper so the plain digest is retired on the next successful verify.
|
||||
if legacy {
|
||||
if _, err := db.Conn.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET two_factor_pending_code_hash = $2
|
||||
WHERE id = $1
|
||||
`, userID, Hash(reqCode)); err != nil {
|
||||
log.Printf("failed to upgrade legacy 2FA pending code hash for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
// Success: clear the attempt counter (and any mint cooldown) before the
|
||||
// caller performs its action.
|
||||
st.Count.Store(0)
|
||||
st.SetLastActive(clock.Now())
|
||||
st.LastMintAt = time.Time{}
|
||||
ResetAttempts(userID)
|
||||
return OK, nil
|
||||
}
|
||||
|
||||
// Classifying errors returned by VerifyForUser.
|
||||
var (
|
||||
// ErrIncorrect reports a code that does not match the user's pending code.
|
||||
ErrIncorrect = errors.New("2FA code is incorrect")
|
||||
// ErrLockedOut reports that the user has exhausted the failed-attempt
|
||||
// budget; further attempts must wait for the attempt window to elapse.
|
||||
ErrLockedOut = errors.New("2FA code locked out: too many failed attempts")
|
||||
// ErrMissingOrExpired reports that no valid pending code exists for the
|
||||
// user; a fresh code must be requested first.
|
||||
ErrMissingOrExpired = errors.New("2FA code is missing or has expired")
|
||||
)
|
||||
|
||||
// VerifyForUser verifies a 2FA code for a user outside the HTTP handler layer,
|
||||
// under the same per-user brute-force lockout as the interactive endpoints.
|
||||
// It returns nil on a correct code, or one of ErrIncorrect / ErrLockedOut /
|
||||
// ErrMissingOrExpired (or a DB error, wrapped). This is the entry point for
|
||||
// the payments card-access gate (B6/B10): a saved-card charge must present a
|
||||
// real, freshly-verified challenge.
|
||||
func VerifyForUser(ctx context.Context, userID, code string) error {
|
||||
st := StateFor(userID)
|
||||
st.Mu.Lock()
|
||||
defer st.Mu.Unlock()
|
||||
|
||||
result, err := Check(ctx, userID, st, code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("2FA verify: %w", err)
|
||||
}
|
||||
switch result {
|
||||
case OK:
|
||||
return nil
|
||||
case Incorrect:
|
||||
return ErrIncorrect
|
||||
case LockedOut:
|
||||
return ErrLockedOut
|
||||
case MissingOrExpired:
|
||||
return ErrMissingOrExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//go:build test
|
||||
|
||||
package twofa
|
||||
|
||||
// Tests for the shared 2FA verification core (the package the payments
|
||||
// card-access gate — B6/B10 — imports for real-challenge verification).
|
||||
// The pepper provider is never registered here (handlers/user's build-tagged
|
||||
// files register it), so Hash falls back to the legacy plain SHA-256 digest —
|
||||
// which is exactly what the seeded pending-code hashes use.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func seedPending(t *testing.T, ctx context.Context, tx db.Querier, userID, code string) {
|
||||
t.Helper()
|
||||
_, err := tx.Exec(ctx, `UPDATE users
|
||||
SET two_factor_method = 'email',
|
||||
two_factor_pending_code_hash = $2,
|
||||
two_factor_pending_code_expires = $3
|
||||
WHERE id = $1`, userID, Hash(code), clock.Now().Add(10*time.Minute))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestVerifyForUser_CorrectAndWrongCode(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
seedPending(t, ctx, tx, userID, "123456")
|
||||
|
||||
require.NoError(t, VerifyForUser(ctx, userID, "123456"), "correct code must verify")
|
||||
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999"), ErrIncorrect)
|
||||
}
|
||||
|
||||
func TestVerifyForUser_LockoutAndMissing(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
seedPending(t, ctx, tx, userID, "123456")
|
||||
|
||||
// Wrong code #1 → ErrIncorrect; four more reach the 5-attempt cap.
|
||||
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999"), ErrIncorrect)
|
||||
for i := 0; i < 4; i++ {
|
||||
_ = VerifyForUser(ctx, userID, "999999")
|
||||
}
|
||||
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999"), ErrLockedOut)
|
||||
|
||||
// A fresh user with no pending code → ErrMissingOrExpired.
|
||||
userID2, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456"), ErrMissingOrExpired)
|
||||
}
|
||||
|
||||
// TestVerifyForUser_AttemptStateMapPersists exercises the shared per-user
|
||||
// attempt map directly (the state the payments gate shares with the interactive
|
||||
// endpoints): the map is bounded and a locked-out record is never evicted.
|
||||
func TestVerifyForUser_AttemptStateMapPersists(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
MapMu.Lock()
|
||||
Map = make(map[string]*AttemptState)
|
||||
MaxTrackedAttempts = 10_000
|
||||
MapMu.Unlock()
|
||||
})
|
||||
|
||||
MapMu.Lock()
|
||||
Map = make(map[string]*AttemptState)
|
||||
MaxTrackedAttempts = 2
|
||||
MapMu.Unlock()
|
||||
|
||||
// Fill the map with locked-out records; a new key must NOT evict one.
|
||||
now := clock.Now()
|
||||
for _, id := range []string{"victim_a", "victim_b"} {
|
||||
st := &AttemptState{}
|
||||
st.SetLastActive(now)
|
||||
st.Count.Store(MaxAttempts)
|
||||
Map[id] = st
|
||||
}
|
||||
_ = StateFor("new_user") // transient, untracked (map full of lockouts)
|
||||
MapMu.Lock()
|
||||
defer MapMu.Unlock()
|
||||
require.Len(t, Map, 2, "locked-out records must survive the cap pressure")
|
||||
}
|
||||
Reference in New Issue
Block a user