package repository import ( "errors" "testing" "time" ) // Тесты сброса пароля (миграция 0007): токен одноразовый, прежние гасятся // при выпуске нового, просроченный не работает, пароль реально меняется. // TestPasswordReset — полный цикл: выпуск, замена токена, применение. func TestPasswordReset(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) p := createProfile(t, ctx, q, "client") before, err := repo.GetByID(ctx, p.ID) if err != nil { t.Fatalf("get profile: %v", err) } // Выпуск: токен — длинная случайная строка, срок в будущем (ResetTTL). token, expires, err := repo.CreateResetToken(ctx, p.ID) if err != nil { t.Fatalf("create reset token: %v", err) } if len(token) < 32 { t.Errorf("token = %q, want long random string", token) } if !expires.After(time.Now().Add(ResetTTL - time.Minute)) { t.Errorf("expires_at = %v, want ~%v from now", expires, ResetTTL) } // Второй выпуск гасит первый: действует ровно один свежий токен. token2, _, err := repo.CreateResetToken(ctx, p.ID) if err != nil { t.Fatalf("second token: %v", err) } if _, err := repo.UseResetToken(ctx, token, "$2a$12$old-hash"); !errors.Is(err, ErrResetToken) { t.Errorf("superseded token: err = %v, want ErrResetToken", err) } // Применение: возвращает владельца и меняет хеш пароля. const newHash = "$2a$12$new-hash-for-test" userID, err := repo.UseResetToken(ctx, token2, newHash) if err != nil { t.Fatalf("use reset token: %v", err) } if userID != p.ID { t.Errorf("user id = %s, want %s", userID, p.ID) } after, err := repo.GetByID(ctx, p.ID) if err != nil { t.Fatalf("get profile after reset: %v", err) } if after.PasswordHash != newHash { t.Errorf("password hash = %q, want %q", after.PasswordHash, newHash) } if before.PasswordHash == after.PasswordHash { t.Error("password hash did not change") } // Токен одноразовый: второе применение — ErrResetToken. if _, err := repo.UseResetToken(ctx, token2, "$2a$12$third"); !errors.Is(err, ErrResetToken) { t.Errorf("reuse: err = %v, want ErrResetToken", err) } // Неизвестный токен — та же ошибка, без подсказок. if _, err := repo.UseResetToken(ctx, "deadbeef", newHash); !errors.Is(err, ErrResetToken) { t.Errorf("unknown token: err = %v, want ErrResetToken", err) } // Несуществующему пользователю токен не выпускается → ErrInvalid // (наружу это 422, хендлер заранее отдаёт 404 по GetByID). if _, _, err := repo.CreateResetToken(ctx, "00000000-0000-0000-0000-000000000000"); !errors.Is(err, ErrInvalid) { t.Errorf("token for missing user: err = %v, want ErrInvalid", err) } } // TestSetPasswordDirect — прямая смена пароля админом: пароль реально // меняется, токены, выпущенные до смены, гаснут, неизвестный id — 404. func TestSetPasswordDirect(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) p := createProfile(t, ctx, q, "worker") token, _, err := repo.CreateResetToken(ctx, p.ID) if err != nil { t.Fatalf("create reset token: %v", err) } const newHash = "$2a$12$admin-set-password" userID, err := repo.SetPassword(ctx, p.ID, newHash) if err != nil { t.Fatalf("set password: %v", err) } if userID != p.ID { t.Errorf("user id = %s, want %s", userID, p.ID) } got, err := repo.GetByID(ctx, p.ID) if err != nil { t.Fatalf("get profile: %v", err) } if got.PasswordHash != newHash { t.Errorf("password hash = %q, want %q", got.PasswordHash, newHash) } // Свежая ссылка, выданная до смены, больше не работает. if _, err := repo.UseResetToken(ctx, token, "$2a$12$stale"); !errors.Is(err, ErrResetToken) { t.Errorf("token issued before reset: err = %v, want ErrResetToken", err) } // Несуществующий пользователь — ErrNotFound (наружу 404). if _, err := repo.SetPassword(ctx, "00000000-0000-0000-0000-000000000000", newHash); !errors.Is(err, ErrNotFound) { t.Errorf("missing user: err = %v, want ErrNotFound", err) } } // TestPasswordResetExpired — просроченный токен не применяется и пароль // при этом не трогается (реально ждать сутки не будем — устареваем сами). func TestPasswordResetExpired(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) p := createProfile(t, ctx, q, "worker") token, _, err := repo.CreateResetToken(ctx, p.ID) if err != nil { t.Fatalf("create reset token: %v", err) } if _, err := q.Exec(ctx, `UPDATE password_reset_tokens SET created_at = now() - interval '25 hours', expires_at = now() - interval '1 minute' WHERE user_id = $1 AND used_at IS NULL`, p.ID); err != nil { t.Fatalf("expire token: %v", err) } if _, err := repo.UseResetToken(ctx, token, "$2a$12$expired"); !errors.Is(err, ErrResetToken) { t.Errorf("expired token: err = %v, want ErrResetToken", err) } got, err := repo.GetByID(ctx, p.ID) if err != nil { t.Fatalf("get profile: %v", err) } if got.PasswordHash == "$2a$12$expired" { t.Error("password changed with an expired token") } }