package auth import ( "context" "strings" "sync" "testing" "time" ) func TestPoolLimitsConcurrency(t *testing.T) { pool := NewPoolSize(1) errCh := make(chan error, 3) var wg sync.WaitGroup wg.Add(3) for i := 0; i < 3; i++ { go func() { defer wg.Done() _, err := pool.Hash(context.Background(), PasswordLogin, "concurrency-test-password") errCh <- err }() } wg.Wait() close(errCh) for err := range errCh { if err != nil { t.Fatal(err) } } if pool.MaxActive() > 1 { t.Fatalf("max active=%d want <=1", pool.MaxActive()) } if pool.MaxActive() < 1 { t.Fatal("expected at least one active hash") } } func TestPoolHashVerifyRoundTrip(t *testing.T) { pool := NewPoolSize(2) ctx := context.Background() phc, err := pool.Hash(ctx, PasswordAdmin, "round-trip-password-1") if err != nil { t.Fatal(err) } if !strings.HasPrefix(phc, "$argon2id$") { t.Fatalf("phc=%q", phc) } ok, err := pool.Verify(ctx, PasswordAdmin, "round-trip-password-1", phc) if err != nil || !ok { t.Fatalf("ok=%v err=%v", ok, err) } ok, err = pool.Verify(ctx, PasswordAdmin, "wrong-password-xxxxx", phc) if err != nil || ok { t.Fatalf("mismatch ok=%v err=%v", ok, err) } } func TestSessionAndAPITokens(t *testing.T) { s := NewSessionTokens() tok, hash, err := s.Issue(context.Background()) if err != nil { t.Fatal(err) } if !strings.HasPrefix(tok, "nst_") { t.Fatalf("tok=%q", tok) } if !s.LooksLikeSessionToken(tok) { t.Fatal("LooksLikeSessionToken") } if !EqualHash(hash, s.HashToken(tok)) { t.Fatal("hash mismatch") } a := NewAPITokens() atok, ahash, err := a.Issue(context.Background()) if err != nil { t.Fatal(err) } if !strings.HasPrefix(atok, "nxm_") { t.Fatalf("atok=%q", atok) } if !a.LooksLikeAPIToken(atok) || !EqualHash(ahash, a.HashToken(atok)) { t.Fatal("api token hash") } if EqualHash(hash, ahash) { t.Fatal("session and api hashes should differ") } } func TestLoginLocksWindowAndExpiry(t *testing.T) { locks := NewLoginLocks() now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) locks.SetClock(func() time.Time { return now }) key := LockKey{Kind: LockLoginEndpointIP, EndpointID: "e1", IP: "1.2.3.4"} for i := 0; i < 9; i++ { locked, _ := locks.Fail(key) if locked { t.Fatalf("locked early at %d", i+1) } now = now.Add(time.Second) } locked, retry := locks.Fail(key) if !locked || retry <= 0 { t.Fatalf("want lock, locked=%v retry=%v", locked, retry) } locked, _ = locks.Check(key) if !locked { t.Fatal("check should be locked") } now = now.Add(5*time.Minute + time.Second) locked, _ = locks.Check(key) if locked { t.Fatal("should unlock after window") } locked, _ = locks.Fail(key) if locked { t.Fatal("after expiry should not still be locked on first fail") } } func TestLoginLocksClearEndpoint(t *testing.T) { locks := NewLoginLocks() k1 := LockKey{Kind: LockLoginEndpointIP, EndpointID: "e1", IP: "1.1.1.1"} k2 := LockKey{Kind: LockLoginEndpoint, EndpointID: "e1"} k3 := LockKey{Kind: LockLoginEndpointIP, EndpointID: "e2", IP: "1.1.1.1"} for i := 0; i < 10; i++ { locks.Fail(k1) locks.Fail(k2) } locks.Fail(k3) locks.ClearEndpoint("e1") if locked, _ := locks.Check(k1); locked { t.Fatal("e1 ip lock should clear") } if locked, _ := locks.Check(k2); locked { t.Fatal("e1 total lock should clear") } } func TestLoginLocksClearEndpointKeepsTalk(t *testing.T) { locks := NewLoginLocks() talk := LockKey{Kind: LockTalkPair, EndpointID: "e1", PeerID: "e2"} target := LockKey{Kind: LockTalkTarget, EndpointID: "e1"} login := LockKey{Kind: LockLoginEndpoint, EndpointID: "e1"} for i := 0; i < 10; i++ { locks.Fail(talk) locks.Fail(login) } for i := 0; i < 50; i++ { locks.Fail(target) } locks.ClearEndpoint("e1") if locked, _ := locks.Check(login); locked { t.Fatal("login lock should clear") } if locked, _ := locks.Check(talk); !locked { t.Fatal("talk pair lock must survive ClearEndpoint") } if locked, _ := locks.Check(target); !locked { t.Fatal("talk target lock must survive ClearEndpoint") } } func TestLoginLocksClearAllForEndpoint(t *testing.T) { locks := NewLoginLocks() asSender := LockKey{Kind: LockTalkPair, EndpointID: "gone", PeerID: "peer"} asPeer := LockKey{Kind: LockTalkPair, EndpointID: "other", PeerID: "gone"} target := LockKey{Kind: LockTalkTarget, EndpointID: "gone"} login := LockKey{Kind: LockLoginEndpointIP, EndpointID: "gone", IP: "1.1.1.1"} keep := LockKey{Kind: LockTalkPair, EndpointID: "keep", PeerID: "peer"} for i := 0; i < 10; i++ { locks.Fail(asSender) locks.Fail(asPeer) locks.Fail(login) locks.Fail(keep) } for i := 0; i < 50; i++ { locks.Fail(target) } locks.ClearAllForEndpoint("gone") for _, k := range []LockKey{asSender, asPeer, target, login} { if locked, _ := locks.Check(k); locked { t.Fatalf("expected %s cleared", lockMapKey(k)) } } if locked, _ := locks.Check(keep); !locked { t.Fatal("unrelated pair should remain") } }