149 lines
5.5 KiB
Go
149 lines
5.5 KiB
Go
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")
|
||
}
|
||
}
|