346 lines
13 KiB
Go
346 lines
13 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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()
|
|
|
|
db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db")
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
service, err := NewService(NewRepository(db), "http://localhost:8080")
|
|
if err != nil {
|
|
t.Fatalf("new auth service: %v", err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
func setupInitialAdmin(t *testing.T, service *Service, signupsEnabled bool) User {
|
|
t.Helper()
|
|
|
|
user, err := service.CompleteInitialSetup(context.Background(), "admin@example.com", "Admin", "correct horse battery staple", signupsEnabled)
|
|
if err != nil {
|
|
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
|
}
|
|
return user
|
|
}
|
|
|
|
func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
|
service := setupAuthTestService(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true)
|
|
if err != nil {
|
|
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
|
}
|
|
if user.Email != "dev@example.com" {
|
|
t.Fatalf("email = %q, want normalized", user.Email)
|
|
}
|
|
|
|
principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test")
|
|
if err != nil {
|
|
t.Fatalf("LoginPassword() error = %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Fatal("expected session token")
|
|
}
|
|
if principal.Role != RoleAdmin {
|
|
t.Fatalf("role = %s, want initial setup user to be admin", principal.Role)
|
|
}
|
|
|
|
validated, err := service.ValidateSessionToken(ctx, token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateSessionToken() error = %v", err)
|
|
}
|
|
if validated.UserID != principal.UserID {
|
|
t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
|
|
service := setupAuthTestService(t)
|
|
ctx := context.Background()
|
|
|
|
user := setupInitialAdmin(t, service, true)
|
|
expires := time.Now().UTC().Add(time.Hour)
|
|
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
|
|
if err != nil {
|
|
t.Fatalf("CreateAPIKey() error = %v", err)
|
|
}
|
|
if !strings.HasPrefix(created.Token, "cq_pat_") {
|
|
t.Fatalf("token prefix = %q, want cq_pat_", created.Token)
|
|
}
|
|
if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) {
|
|
t.Fatal("record contains raw token")
|
|
}
|
|
|
|
principal, err := service.ValidateBearerToken(ctx, created.Token)
|
|
if err != nil {
|
|
t.Fatalf("ValidateBearerToken() error = %v", err)
|
|
}
|
|
if principal.APIKeyID != created.Record.ID {
|
|
t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID)
|
|
}
|
|
if !Allows(principal, ScopeDocsRead) {
|
|
t.Fatal("expected docs:read to be allowed")
|
|
}
|
|
if Allows(principal, ScopeAdmin) {
|
|
t.Fatal("did not expect admin scope to be allowed")
|
|
}
|
|
}
|
|
|
|
func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
|
|
service := setupAuthTestService(t)
|
|
ctx := context.Background()
|
|
|
|
user := setupInitialAdmin(t, service, true)
|
|
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
|
|
if err != nil {
|
|
t.Fatalf("StartDeviceFlow() error = %v", err)
|
|
}
|
|
|
|
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil {
|
|
t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err)
|
|
}
|
|
|
|
_, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode)
|
|
if err != nil {
|
|
t.Fatalf("ApproveDeviceFlow() error = %v", err)
|
|
}
|
|
created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode)
|
|
if err != nil {
|
|
t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err)
|
|
}
|
|
if created.Token == "" {
|
|
t.Fatal("expected device flow to mint api token")
|
|
}
|
|
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil {
|
|
t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err)
|
|
}
|
|
}
|
|
|
|
func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
|
|
service := setupAuthTestService(t)
|
|
ctx := context.Background()
|
|
|
|
user := setupInitialAdmin(t, service, true)
|
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
|
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
|
|
t.Fatalf("ChangePassword() error = %v", err)
|
|
}
|
|
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
|
t.Fatal("old password still works")
|
|
}
|
|
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
|
t.Fatalf("new password login error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
|
service := setupAuthTestService(t)
|
|
ctx := context.Background()
|
|
|
|
user := setupInitialAdmin(t, service, true)
|
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
|
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()
|
|
|
|
setupInitialAdmin(t, service, true)
|
|
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
|
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
|
}
|
|
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil {
|
|
t.Fatal("expected duplicate password registration to fail")
|
|
}
|
|
if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil {
|
|
t.Fatal("expected public passkey registration for existing account to fail")
|
|
}
|
|
}
|
|
|
|
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
|
service := setupAuthTestService(t)
|
|
ctx := context.Background()
|
|
admin := setupInitialAdmin(t, service, false)
|
|
|
|
if admin.Role != RoleAdmin {
|
|
t.Fatalf("initial role = %s, want admin", admin.Role)
|
|
}
|
|
if _, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer"); err == nil || err.Error() != "signups are disabled" {
|
|
t.Fatalf("RegisterPasswordUser() error = %v, want signups are disabled", err)
|
|
}
|
|
|
|
principal := principalFromUser(admin, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
|
settings, err := service.UpdateSignupsEnabled(ctx, principal, true)
|
|
if err != nil {
|
|
t.Fatalf("UpdateSignupsEnabled() error = %v", err)
|
|
}
|
|
if !settings.SignupsEnabled {
|
|
t.Fatal("expected signups to be enabled")
|
|
}
|
|
viewer, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "admin")
|
|
if err != nil {
|
|
t.Fatalf("RegisterPasswordUser() after enabling signups error = %v", err)
|
|
}
|
|
if viewer.Role != RoleViewer {
|
|
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
|
|
}
|
|
}
|