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") } }