axe/internal/repository/password_reset_test.go
2026-10-18 22:07:25 +03:00

149 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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