accounts and email
This commit is contained in:
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +12,20 @@ import (
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
)
|
||||
|
||||
type testEmailSender struct {
|
||||
to []string
|
||||
subject string
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||
s.to = append([]string(nil), to...)
|
||||
s.subject = subject
|
||||
s.body = body
|
||||
return s.err
|
||||
}
|
||||
|
||||
func setupAuthTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
|
||||
@@ -158,11 +174,131 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
|
||||
t.Fatal("expected demoting last admin to fail")
|
||||
}
|
||||
if _, err := service.UpdateUserAccess(ctx, principal, []UserAccessUpdate{{ID: user.ID, Role: RoleAdmin, Disabled: true}}); err == nil {
|
||||
t.Fatal("expected disabling last admin to fail")
|
||||
}
|
||||
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
|
||||
t.Fatal("expected deleting last admin to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUserCannotAuthenticateWithPasswordSessionOrAPIKey(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoginPassword() before disable error = %v", err)
|
||||
}
|
||||
apiKey, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIKey() error = %v", err)
|
||||
}
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
if _, err := service.UpdateUserAccess(ctx, adminPrincipal, []UserAccessUpdate{{ID: user.ID, Role: RoleViewer, Disabled: true}}); err != nil {
|
||||
t.Fatalf("UpdateUserAccess() error = %v", err)
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||
t.Fatal("disabled user password login succeeded")
|
||||
}
|
||||
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
|
||||
t.Fatal("disabled user session remained valid")
|
||||
}
|
||||
if _, err := service.ValidateBearerToken(ctx, apiKey.Token); err == nil {
|
||||
t.Fatal("disabled user api token remained valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetEmailReplacesPriorLinkAndRevokesSessions(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("LoginPassword() before reset error = %v", err)
|
||||
}
|
||||
sender := &testEmailSender{}
|
||||
service.SetEmailSender(sender)
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
|
||||
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
|
||||
t.Fatalf("SendPasswordReset() first error = %v", err)
|
||||
}
|
||||
firstToken := passwordResetTokenFromBody(t, sender.body)
|
||||
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
|
||||
t.Fatalf("SendPasswordReset() second error = %v", err)
|
||||
}
|
||||
secondToken := passwordResetTokenFromBody(t, sender.body)
|
||||
if firstToken == secondToken {
|
||||
t.Fatal("expected each password reset email to contain a fresh token")
|
||||
}
|
||||
if err := service.ResetPassword(ctx, firstToken, "new correct horse battery staple"); err == nil {
|
||||
t.Fatal("first password reset link remained valid after requesting a new one")
|
||||
}
|
||||
if err := service.ResetPassword(ctx, secondToken, "new correct horse battery staple"); err != nil {
|
||||
t.Fatalf("ResetPassword() error = %v", err)
|
||||
}
|
||||
if err := service.ResetPassword(ctx, secondToken, "another correct horse battery staple"); err == nil {
|
||||
t.Fatal("password reset link was reusable")
|
||||
}
|
||||
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
|
||||
t.Fatal("password reset did not revoke existing sessions")
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||
t.Fatal("old password still works after reset")
|
||||
}
|
||||
if _, _, err := service.LoginPassword(ctx, user.Email, "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
||||
t.Fatalf("new password login error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetEmailFailureInvalidatesLink(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
sender := &testEmailSender{err: errors.New("smtp unavailable")}
|
||||
service.SetEmailSender(sender)
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
|
||||
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err == nil {
|
||||
t.Fatal("expected password reset email delivery failure")
|
||||
}
|
||||
token := passwordResetTokenFromBody(t, sender.body)
|
||||
if err := service.ResetPassword(ctx, token, "new correct horse battery staple"); err == nil {
|
||||
t.Fatal("password reset link remained valid after email delivery failed")
|
||||
}
|
||||
}
|
||||
|
||||
func passwordResetTokenFromBody(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if !strings.HasPrefix(line, "http") {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(line)
|
||||
if err != nil {
|
||||
t.Fatalf("parse password reset URL: %v", err)
|
||||
}
|
||||
if token := parsed.Query().Get("token"); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
t.Fatalf("password reset body does not contain a link: %q", body)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user