116 lines
3.5 KiB
Go
116 lines
3.5 KiB
Go
package customerauth
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPurposeTokenRoundTrip(t *testing.T) {
|
|
s := NewSigner([]byte("test-secret"))
|
|
tok := s.IssuePurpose(purposePasswordReset, "user@example.com", time.Hour)
|
|
sub, err := s.ParsePurpose(purposePasswordReset, tok)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if sub != "user@example.com" {
|
|
t.Fatalf("got subject %q, want user@example.com", sub)
|
|
}
|
|
}
|
|
|
|
func TestPurposeTokenWrongPurposeRejected(t *testing.T) {
|
|
s := NewSigner([]byte("test-secret"))
|
|
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
|
|
// A verify token must not be accepted as a reset token.
|
|
if _, err := s.ParsePurpose(purposePasswordReset, tok); err != ErrInvalidToken {
|
|
t.Fatalf("got %v, want ErrInvalidToken", err)
|
|
}
|
|
}
|
|
|
|
func TestPurposeTokenExpiredAndTampered(t *testing.T) {
|
|
s := NewSigner([]byte("test-secret"))
|
|
expired := s.IssuePurpose(purposeVerifyEmail, "user@example.com", -time.Second)
|
|
if _, err := s.ParsePurpose(purposeVerifyEmail, expired); err != ErrExpiredToken {
|
|
t.Fatalf("expired: got %v, want ErrExpiredToken", err)
|
|
}
|
|
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
|
|
if _, err := s.ParsePurpose(purposeVerifyEmail, tok+"x"); err != ErrInvalidToken {
|
|
t.Fatalf("tampered: got %v, want ErrInvalidToken", err)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterLocksOutAndResets(t *testing.T) {
|
|
ctx := context.Background()
|
|
l := NewLoginLimiter(3, time.Minute)
|
|
const key = "user@example.com"
|
|
for i := range 3 {
|
|
if ok, _ := l.Allowed(ctx, key); !ok {
|
|
t.Fatalf("locked out early after %d failures", i)
|
|
}
|
|
l.RecordFailure(ctx, key)
|
|
}
|
|
ok, retry := l.Allowed(ctx, key)
|
|
if ok {
|
|
t.Fatal("expected lockout after 3 failures")
|
|
}
|
|
if retry <= 0 {
|
|
t.Fatalf("expected positive retry-after, got %v", retry)
|
|
}
|
|
// A successful auth clears the history.
|
|
l.Reset(ctx, key)
|
|
if ok, _ := l.Allowed(ctx, key); !ok {
|
|
t.Fatal("expected reset to clear lockout")
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterNilIsNoop(t *testing.T) {
|
|
ctx := context.Background()
|
|
var l *LoginLimiter
|
|
if ok, _ := l.Allowed(ctx, "x"); !ok {
|
|
t.Fatal("nil limiter should allow")
|
|
}
|
|
l.RecordFailure(ctx, "x") // must not panic
|
|
l.Reset(ctx, "x") // must not panic
|
|
}
|
|
|
|
func TestStoreMarkVerifiedAndUpdateHash(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "credentials.json")
|
|
store, err := LoadCredentialStore(path)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if err := store.Register(ctx, "u@example.com", 1, "hash1", "ts"); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
|
|
// Unknown email is a no-op, not an error.
|
|
if found, err := store.MarkVerified(ctx, "missing@example.com", "now"); err != nil || found {
|
|
t.Fatalf("MarkVerified(missing) = (%v, %v), want (false, nil)", found, err)
|
|
}
|
|
|
|
if found, err := store.MarkVerified(ctx, "U@EXAMPLE.COM", "2026-01-01T00:00:00Z"); err != nil || !found {
|
|
t.Fatalf("MarkVerified = (%v, %v), want (true, nil)", found, err)
|
|
}
|
|
if found, err := store.UpdateHash(ctx, "u@example.com", "hash2"); err != nil || !found {
|
|
t.Fatalf("UpdateHash = (%v, %v), want (true, nil)", found, err)
|
|
}
|
|
|
|
// Both changes survive a reload from disk.
|
|
reloaded, err := LoadCredentialStore(path)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
rec, ok := reloaded.Get(ctx, "u@example.com")
|
|
if !ok {
|
|
t.Fatal("record missing after reload")
|
|
}
|
|
if rec.Hash != "hash2" {
|
|
t.Fatalf("hash = %q, want hash2", rec.Hash)
|
|
}
|
|
if rec.VerifiedAt == "" {
|
|
t.Fatal("verifiedAt not persisted")
|
|
}
|
|
}
|