148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/chess/internal/server/mail"
|
|
"github.com/lixenwraith/chess/internal/server/storage"
|
|
)
|
|
|
|
var linkPattern = regexp.MustCompile(`https://example\.test/chess/web-client/\?reset=([A-Za-z0-9_-]{43})`)
|
|
|
|
func TestPasswordResetFlow(t *testing.T) {
|
|
svc := newPersistentTestService(t)
|
|
if svc.MailHealth() != "disabled" {
|
|
t.Fatal("mail reported before configuration")
|
|
}
|
|
if err := svc.RequestPasswordReset("alice"); !errors.Is(err, ErrMailDisabled) {
|
|
t.Fatalf("recovery without mail: %v", err)
|
|
}
|
|
dir := filepath.Join(t.TempDir(), "mail")
|
|
if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "/chess/"); err == nil {
|
|
t.Fatal("relative public URL accepted")
|
|
}
|
|
if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "https://example.test/chess/web-client/"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if svc.MailHealth() != "ok" {
|
|
t.Fatal("mail not reported ok")
|
|
}
|
|
|
|
user, sessionID, err := svc.RegisterUser("alice", "Alice@Example.test", "Password1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Unknown accounts and accounts without email get the same answer and no mail.
|
|
for _, id := range []string{"nobody", "nobody@example.test", "bob"} {
|
|
if err := svc.RequestPasswordReset(id); err != nil {
|
|
t.Errorf("recover %q: %v", id, err)
|
|
}
|
|
}
|
|
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
|
t.Fatalf("mail sent for unknown accounts: %d files", len(entries))
|
|
}
|
|
|
|
// By email, case-insensitively; then by username, which replaces the link.
|
|
if err := svc.RequestPasswordReset("ALICE@example.test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := lastMailToken(t, dir)
|
|
if err := svc.RequestPasswordReset("alice"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token := lastMailToken(t, dir)
|
|
if token == first {
|
|
t.Fatal("second request reused the token")
|
|
}
|
|
if _, _, err := svc.ResetPassword(first, "Password2"); !errors.Is(err, ErrResetInvalid) {
|
|
t.Errorf("replaced token: %v", err)
|
|
}
|
|
for _, bad := range []string{"", "x", strings.Repeat("A", 43), token + "A"} {
|
|
if _, _, err := svc.ResetPassword(bad, "Password2"); !errors.Is(err, ErrResetInvalid) {
|
|
t.Errorf("token %q: %v", bad, err)
|
|
}
|
|
}
|
|
|
|
reset, newSession, err := svc.ResetPassword(token, "Password2")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reset.UserID != user.UserID || reset.Username != "alice" || newSession == "" || newSession == sessionID {
|
|
t.Fatalf("reset returned %+v, session %q", reset, newSession)
|
|
}
|
|
if _, _, err := svc.ResetPassword(token, "Password3"); !errors.Is(err, ErrResetInvalid) {
|
|
t.Errorf("token reused: %v", err)
|
|
}
|
|
// The old session is gone, the new one and the new password work.
|
|
if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, sessionID)); err == nil {
|
|
t.Error("old session still valid")
|
|
}
|
|
if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, newSession)); err != nil {
|
|
t.Errorf("new session invalid: %v", err)
|
|
}
|
|
if _, _, err := svc.AuthenticateUser("alice", "Password1"); !errors.Is(err, ErrInvalidCredentials) {
|
|
t.Errorf("old password: %v", err)
|
|
}
|
|
if _, _, err := svc.AuthenticateUser("alice", "Password2"); err != nil {
|
|
t.Errorf("new password: %v", err)
|
|
}
|
|
|
|
// An expired link is refused and the cleanup removes it.
|
|
expired, hash, err := NewResetToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
past := time.Now().UTC().Add(-time.Minute)
|
|
if err := svc.store.CreatePasswordReset(storage.PasswordResetRecord{
|
|
UserID: user.UserID, TokenHash: hash, CreatedAt: past.Add(-time.Hour), ExpiresAt: past,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := svc.ResetPassword(expired, "Password4"); !errors.Is(err, ErrResetInvalid) {
|
|
t.Errorf("expired token: %v", err)
|
|
}
|
|
if deleted, err := svc.store.DeleteExpiredPasswordResets(); err != nil || deleted != 1 {
|
|
t.Errorf("expired resets deleted = %d, %v", deleted, err)
|
|
}
|
|
}
|
|
|
|
func lastMailToken(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil || len(entries) == 0 {
|
|
t.Fatalf("no mail in %s: %v", dir, err)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, entries[len(entries)-1].Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(data)
|
|
if !strings.Contains(text, "To: alice@example.test\r\n") || !strings.Contains(text, "Subject: Reset your chess password\r\n") {
|
|
t.Fatalf("mail = %q", text)
|
|
}
|
|
m := linkPattern.FindStringSubmatch(text)
|
|
if m == nil {
|
|
t.Fatalf("no reset link in %q", text)
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
func mustToken(t *testing.T, svc *Service, userID, sessionID string) string {
|
|
t.Helper()
|
|
token, err := svc.GenerateUserToken(userID, sessionID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return token
|
|
}
|