axe/internal/repository/password_reset_test.go

149 lines
5.5 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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")
}
}