package auth import ( "context" "testing" "time" ) // newService wires a Service over a fake store + miniredis for unit tests. // It returns the service, the redis client (to read codes directly), the fake // store, and the miniredis handle (to fast-forward TTLs). func newService(t *testing.T, cfg ServiceConfig) (*Service, *fakeStore, *captureMailer) { t.Helper() rdb, _ := newMiniRedis(t) rl := NewRateLimiter(rdb, nil) tm := newTokenManager(t, rdb, time.Now) store := newFakeStore() mailer := newCaptureMailer() svc := NewService(store, rdb, rl, tm, mailer, cfg, nil) return svc, store, mailer } // codeInRedis reads the active verification code straight from Redis. func codeInRedis(t *testing.T, svc *Service, email string) string { t.Helper() c, err := svc.rdb.Get(context.Background(), codeKey(email)).Result() if err != nil { t.Fatalf("no code stored for %s: %v", email, err) } return c } func TestService_RegisterFullFlow(t *testing.T) { svc, store, _ := newService(t, ServiceConfig{}) ctx := context.Background() const email = "alice@example.com" if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) pair, apiErr := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}) if apiErr != nil { t.Fatalf("Register: %v", apiErr) } if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 { t.Fatalf("bad token pair: %+v", pair) } // User exists. u, err := store.GetUserByEmail(ctx, email) if err != nil { t.Fatalf("user not created: %v", err) } // Trial subscription exists, PRO, ~7 days. tr, ok := store.trials[u.ID] if !ok { t.Fatal("trial subscription not created") } if tr.plan != "pro" || tr.source != "trial" { t.Fatalf("trial = %+v, want pro/trial", tr) } days := time.Until(tr.expiresAt).Hours() / 24 if days < 6.9 || days > 7.1 { t.Fatalf("trial length = %.2f days, want ~7", days) } // Issued access token authenticates. claims, perr := svc.tokens.ParseAccess(pair.AccessToken) if perr != nil { t.Fatalf("ParseAccess: %v", perr) } if claims.UID != u.ID || claims.Subject != u.UUID { t.Fatalf("claims mismatch: %+v", claims) } } func TestService_DuplicateEmailConflict(t *testing.T) { // Higher send limit so the two registration attempts can each request a code. svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 10}) ctx := context.Background() const email = "dup@example.com" // First registration. _, _ = svc.SendCode(ctx, email, "") if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}); e != nil { t.Fatalf("first register: %v", e) } // Anti-enumeration: SendCode for a now-registered email must NOT store a // verification code (it mails a login reminder instead), so the API response // can't be used to discover that the email is registered. if _, e := svc.SendCode(ctx, email, ""); e != nil { t.Fatalf("second SendCode: %v", e) } if c := svc.rdb.Get(ctx, codeKey(email)).Val(); c != "" { t.Fatalf("registered email must not receive a code, got %q", c) } // Even if a code is forced (e.g. a race), Register returns the generic code // error rather than "email exists" — no enumeration leak. if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil { t.Fatalf("force code: %v", err) } _, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", apiErr) } } func TestService_CodeWrong(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) ctx := context.Background() const email = "wrong@example.com" _, _ = svc.SendCode(ctx, email, "") _, apiErr := svc.Register(ctx, email, "000000", "password1", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid, got %v", apiErr) } } func TestService_CodeExpired(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{CodeTTL: time.Minute}) ctx := context.Background() const email = "expired@example.com" _, _ = svc.SendCode(ctx, email, "") code := codeInRedis(t, svc, email) // Expire the code key. svc.rdb.Del(ctx, codeKey(email)) _, apiErr := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid after expiry, got %v", apiErr) } } func TestService_CodeReuseRejected(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) ctx := context.Background() const email = "reuse@example.com" _, _ = svc.SendCode(ctx, email, "") code := codeInRedis(t, svc, email) if _, e := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}); e != nil { t.Fatalf("first register: %v", e) } // Re-using the consumed code must fail. _, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid on reuse, got %v", apiErr) } } func TestService_CodeBruteForceBurned(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{CodeMaxAttempts: 3}) ctx := context.Background() const email = "brute@example.com" _, _ = svc.SendCode(ctx, email, "") good := codeInRedis(t, svc, email) // 3 wrong attempts burn the code. for i := 0; i < 3; i++ { if _, e := svc.Register(ctx, email, "999999", "password1", "", DeviceMeta{}); e == nil { t.Fatal("wrong code should fail") } } // Even the correct code no longer works. if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("burned code should reject correct value, got %v", e) } } func TestService_SendCodeRateLimited(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 1}) ctx := context.Background() const email = "rl@example.com" if _, e := svc.SendCode(ctx, email, "9.9.9.9"); e != nil { t.Fatalf("first send: %v", e) } ra, apiErr := svc.SendCode(ctx, email, "9.9.9.9") if apiErr == nil || apiErr.Code != ErrRateLimited.Code { t.Fatalf("want rate_limited, got %v", apiErr) } if ra <= 0 { t.Fatalf("expected positive retry-after, got %v", ra) } } func TestService_SendCodeDisposableBlocked(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) _, apiErr := svc.SendCode(context.Background(), "x@mailinator.com", "") if apiErr == nil || apiErr.Code != ErrEmailDisposable.Code { t.Fatalf("want email_disposable, got %v", apiErr) } } func TestService_LoginAndLockout(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{LoginFailMax: 3, LoginLockWindow: 15 * time.Minute}) ctx := context.Background() const email = "login@example.com" const pw = "rightpassword" _, _ = svc.SendCode(ctx, email, "") if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil { t.Fatalf("register: %v", e) } // Correct login works. pair, _, apiErr := svc.Login(ctx, email, pw, "", DeviceMeta{}) if apiErr != nil || pair == nil { t.Fatalf("login should succeed: %v", apiErr) } // 3 wrong attempts. for i := 0; i < 3; i++ { _, _, e := svc.Login(ctx, email, "wrong", "", DeviceMeta{}) if e == nil || e.Code != ErrInvalidCredentials.Code { t.Fatalf("attempt %d want invalid_credentials, got %v", i, e) } } // Now locked, even with the correct password. _, ra, e := svc.Login(ctx, email, pw, "", DeviceMeta{}) if e == nil || e.Code != ErrAccountLocked.Code { t.Fatalf("want account_locked, got %v", e) } if ra <= 0 { t.Fatalf("expected positive retry-after on lock, got %v", ra) } } func TestService_LoginUnknownUser(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) _, _, apiErr := svc.Login(context.Background(), "ghost@example.com", "whatever", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrInvalidCredentials.Code { t.Fatalf("want invalid_credentials for unknown user, got %v", apiErr) } } func TestService_BannedUserRejected(t *testing.T) { svc, store, _ := newService(t, ServiceConfig{}) ctx := context.Background() const email = "banned@example.com" const pw = "password1" _, _ = svc.SendCode(ctx, email, "") if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil { t.Fatalf("register: %v", e) } store.setStatus(email, "banned") _, _, apiErr := svc.Login(ctx, email, pw, "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrAccountBanned.Code { t.Fatalf("want account_banned, got %v", apiErr) } } func TestService_RefreshRotation(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) ctx := context.Background() const email = "refresh@example.com" _, _ = svc.SendCode(ctx, email, "") pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}) if e != nil { t.Fatalf("register: %v", e) } rotated, apiErr := svc.Refresh(ctx, pair.RefreshToken) if apiErr != nil { t.Fatalf("refresh: %v", apiErr) } // Old refresh token now invalid. if _, e := svc.Refresh(ctx, pair.RefreshToken); e == nil || e.Code != ErrInvalidToken.Code { t.Fatalf("want invalid_token for rotated-out refresh, got %v", e) } // New one works. if _, e := svc.Refresh(ctx, rotated.RefreshToken); e != nil { t.Fatalf("new refresh should work: %v", e) } }