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