From 51680ffc9b995735061478a125a159e9834fcc39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:29:53 +0000 Subject: [PATCH] feat: add organization-scoped user creation (POST /api/v1/users) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1 of #15 — allows authenticated org admins to create users inside their own organization without going through registration. Changes: - Add UserService.Create method with password validation, bcrypt hashing, optional role assignment, audit logging, and webhook dispatch - Add CreateUser handler with full swagger annotations - Wire POST /api/v1/users route with users:write permission - Export ValidatePassword for reuse across services - Add AuditActionUserCreated constant - Add authorization tests (forbidden, granted, superuser bypass) - Add 14 service-layer unit tests covering happy paths, password policy, duplicate emails, cross-org isolation, and role validation Security: org_id from JWT only, is_superuser always false, cross-org role injection prevented, RBAC enforced via users:write permission. https://claude.ai/code/session_01MVLBRFpkjSWd57A285vbnP --- internal/api/handlers/swagger_types.go | 8 + internal/api/handlers/users.go | 69 +++++ internal/api/router.go | 3 +- internal/api/router_authorization_test.go | 65 +++-- internal/domain/audit.go | 1 + internal/service/auth.go | 8 +- internal/service/auth_test.go | 4 +- internal/service/user.go | 71 ++++- internal/service/user_test.go | 330 ++++++++++++++++++++++ 9 files changed, 528 insertions(+), 31 deletions(-) create mode 100644 internal/service/user_test.go diff --git a/internal/api/handlers/swagger_types.go b/internal/api/handlers/swagger_types.go index f49a657..fa4b1fc 100644 --- a/internal/api/handlers/swagger_types.go +++ b/internal/api/handlers/swagger_types.go @@ -143,6 +143,14 @@ type UpdateMeRequest struct { Email *string `json:"email" example:"jane@acme.com"` } +// CreateUserRequest is the request body for POST /api/v1/users. +type CreateUserRequest struct { + Email string `json:"email" example:"employee@acme.com"` + Password string `json:"password" example:"S3cur3P@ss!"` + FullName *string `json:"full_name" example:"John Doe"` + RoleIDs []string `json:"role_ids" example:"550e8400-e29b-41d4-a716-446655440002"` +} + // UpdateOrgRequest is the request body for PUT /api/v1/organizations/me. type UpdateOrgRequest struct { Name string `json:"name" example:"Acme Corporation"` diff --git a/internal/api/handlers/users.go b/internal/api/handlers/users.go index 7952283..29912dd 100644 --- a/internal/api/handlers/users.go +++ b/internal/api/handlers/users.go @@ -99,6 +99,75 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, userResponse(user)) } +// CreateUser godoc +// @Summary Create a user in the organization +// @Description Creates a new user inside the caller's organization. The organization is derived from the JWT — no org_id is accepted in the request body. New users always default to non-superuser. Optionally assign initial roles at creation time. Requires the `users:write` permission. +// @Tags Users +// @Accept json +// @Produce json +// @Param body body CreateUserRequest true "User creation payload" +// @Success 201 {object} UserView +// @Failure 400 {object} SwaggerErrorResponse "Validation error (weak password, empty email, invalid role ID)" +// @Failure 401 {object} SwaggerErrorResponse +// @Failure 403 {object} SwaggerErrorResponse "Missing users:write permission" +// @Failure 409 {object} SwaggerErrorResponse "Email already exists in organization" +// @Failure 500 {object} SwaggerErrorResponse +// @Security BearerAuth +// @Router /api/v1/users [post] +func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { + actorID, ok := middleware.GetUserID(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + orgID, ok := middleware.GetOrgID(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req struct { + Email string `json:"email"` + Password string `json:"password"` + FullName *string `json:"full_name"` + RoleIDs []string `json:"role_ids"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Email == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "email and password are required") + return + } + + roleIDs := make([]uuid.UUID, 0, len(req.RoleIDs)) + for _, idStr := range req.RoleIDs { + id, err := uuid.Parse(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid role_id: "+idStr) + return + } + roleIDs = append(roleIDs, id) + } + + ip := ptrStr(middleware.ClientIP(r)) + ua := ptrStr(r.UserAgent()) + + user, err := h.userSvc.Create(r.Context(), orgID, actorID, service.CreateUserInput{ + Email: req.Email, + Password: req.Password, + FullName: req.FullName, + RoleIDs: roleIDs, + }, ip, ua) + if err != nil { + handleServiceError(w, err) + return + } + + writeJSON(w, http.StatusCreated, userResponse(user)) +} + // ListUsers godoc // @Summary List users in organization // @Description Returns a paginated list of all users in the authenticated user's organization. Requires the `users:read` permission. diff --git a/internal/api/router.go b/internal/api/router.go index 8468dde..cd794be 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -33,7 +33,7 @@ func NewRouter( webhookSvc := service.NewWebhookService(store, logger) emailSvc := service.NewEmailService(cfg.Email, logger, cfg.IsDevelopment()) authSvc := service.NewAuthService(store, tokenMaker, redisCache, auditSvc, webhookSvc, emailSvc, cfg.Auth) - userSvc := service.NewUserService(store, auditSvc) + userSvc := service.NewUserService(store, auditSvc, webhookSvc) orgSvc := service.NewOrgService(store) rbacSvc := service.NewRBACService(store, auditSvc) apiKeySvc := service.NewAPIKeyService(store, auditSvc) @@ -102,6 +102,7 @@ func NewRouter( r.Route("/users", func(r chi.Router) { r.Get("/me", userH.GetMe) r.Put("/me", userH.UpdateMe) + r.With(middleware.RequirePermission(rbacSvc, domain.PermissionUsersWrite)).Post("/", userH.CreateUser) r.With(middleware.RequirePermission(rbacSvc, domain.PermissionUsersRead)).Get("/", userH.ListUsers) r.With(middleware.RequirePermission(rbacSvc, domain.PermissionUsersRead)).Get("/{id}", userH.GetUser) r.With(middleware.RequirePermission(rbacSvc, domain.PermissionUsersDelete)).Delete("/{id}", userH.DeactivateUser) diff --git a/internal/api/router_authorization_test.go b/internal/api/router_authorization_test.go index 8b8a5e6..fd0563c 100644 --- a/internal/api/router_authorization_test.go +++ b/internal/api/router_authorization_test.go @@ -38,6 +38,7 @@ func TestRouterRequiresPermissionsOnPrivilegedRoutes(t *testing.T) { path string body string }{ + {name: "create user", method: http.MethodPost, path: "/api/v1/users", body: `{"email":"test@example.com","password":"S3cur3P@ss!"}`}, {name: "list users", method: http.MethodGet, path: "/api/v1/users"}, {name: "get user", method: http.MethodGet, path: "/api/v1/users/" + uuid.NewString()}, {name: "deactivate user", method: http.MethodDelete, path: "/api/v1/users/" + uuid.NewString()}, @@ -121,6 +122,16 @@ func TestRouterAllowsGrantedPermissions(t *testing.T) { path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/users" }, wantStatus: http.StatusOK, }, + { + name: "users write", + permission: domain.PermissionUsersWrite, + method: http.MethodPost, + path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { + return "/api/v1/users" + }, + body: `{"email":"create-` + uuid.NewString() + `@example.com","password":"S3cur3P@ss!"}`, + wantStatus: http.StatusCreated, + }, { name: "users delete", permission: domain.PermissionUsersDelete, @@ -263,15 +274,24 @@ func TestRouterAllowsSuperuserBypass(t *testing.T) { handler := newTestRouter(store) tests := []struct { - name string - method string - path func(t *testing.T, ctx context.Context, store *db.Store, orgID uuid.UUID) string - body string + name string + method string + path func(t *testing.T, ctx context.Context, store *db.Store, orgID uuid.UUID) string + body string + wantStatus int }{ { - name: "users family", - method: http.MethodGet, + name: "users family", + method: http.MethodGet, + path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/users" }, + wantStatus: http.StatusOK, + }, + { + name: "create user", + method: http.MethodPost, path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/users" }, + body: `{"email":"superuser-create-` + uuid.NewString() + `@example.com","password":"S3cur3P@ss!"}`, + wantStatus: http.StatusCreated, }, { name: "organizations family", @@ -279,26 +299,31 @@ func TestRouterAllowsSuperuserBypass(t *testing.T) { path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/organizations/me" }, + wantStatus: http.StatusOK, }, { - name: "roles family", - method: http.MethodGet, - path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/roles" }, + name: "roles family", + method: http.MethodGet, + path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/roles" }, + wantStatus: http.StatusOK, }, { - name: "api keys family", - method: http.MethodGet, - path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/api-keys" }, + name: "api keys family", + method: http.MethodGet, + path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/api-keys" }, + wantStatus: http.StatusOK, }, { - name: "audit family", - method: http.MethodGet, - path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/audit" }, + name: "audit family", + method: http.MethodGet, + path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/audit" }, + wantStatus: http.StatusOK, }, { - name: "webhooks family", - method: http.MethodGet, - path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/webhooks" }, + name: "webhooks family", + method: http.MethodGet, + path: func(_ *testing.T, _ context.Context, _ *db.Store, _ uuid.UUID) string { return "/api/v1/webhooks" }, + wantStatus: http.StatusOK, }, } @@ -312,8 +337,8 @@ func TestRouterAllowsSuperuserBypass(t *testing.T) { path := tc.path(t, ctx, store, org.ID) rec := performAuthedRequest(handler, tc.method, path, tc.body, issueAccessToken(t, superuser.ID, org.ID)) - if rec.Code != http.StatusOK { - t.Fatalf("%s %s status = %d, want %d, body=%s", tc.method, path, rec.Code, http.StatusOK, rec.Body.String()) + if rec.Code != tc.wantStatus { + t.Fatalf("%s %s status = %d, want %d, body=%s", tc.method, path, rec.Code, tc.wantStatus, rec.Body.String()) } }) } diff --git a/internal/domain/audit.go b/internal/domain/audit.go index 9626552..2724b16 100644 --- a/internal/domain/audit.go +++ b/internal/domain/audit.go @@ -8,6 +8,7 @@ import ( // Well-known audit action constants. const ( + AuditActionUserCreated = "user.created" AuditActionUserRegistered = "user.registered" AuditActionUserLogin = "user.login" AuditActionUserLoginFailed = "user.login_failed" diff --git a/internal/service/auth.go b/internal/service/auth.go index f8f38e6..d5cd35e 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -70,7 +70,7 @@ type RegisterOutput struct { // Register creates a new organization and its first (superuser) user. func (s *AuthService) Register(ctx context.Context, in RegisterInput, ipAddress, userAgent *string) (*RegisterOutput, error) { - if err := validatePassword(in.Password); err != nil { + if err := ValidatePassword(in.Password); err != nil { return nil, err } @@ -284,7 +284,7 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, orgSlug, email s // ConfirmPasswordReset validates the token and updates the password. func (s *AuthService) ConfirmPasswordReset(ctx context.Context, rawToken, newPassword string) error { - if err := validatePassword(newPassword); err != nil { + if err := ValidatePassword(newPassword); err != nil { return err } @@ -318,7 +318,7 @@ func (s *AuthService) ConfirmPasswordReset(ctx context.Context, rawToken, newPas // ChangePassword updates a user's password after verifying the current one, // then blacklists the current access token so it is rejected on all instances. func (s *AuthService) ChangePassword(ctx context.Context, orgID, userID uuid.UUID, currentPassword, newPassword string, accessTokenID uuid.UUID, accessTokenExpiresAt time.Time) error { - if err := validatePassword(newPassword); err != nil { + if err := ValidatePassword(newPassword); err != nil { return err } @@ -399,7 +399,7 @@ func hashString(s string) string { return hex.EncodeToString(sum[:]) } -func validatePassword(p string) error { +func ValidatePassword(p string) error { if len(p) < 8 { return fmt.Errorf("%w: minimum 8 characters", domain.ErrWeakPassword) } diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 9d46566..9f7c55e 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -12,7 +12,7 @@ import ( "github.com/osama1998h/uniauth/pkg/token" ) -// validatePassword and slugify are package-private functions tested here +// ValidatePassword and slugify are package-private functions tested here // because they encapsulate security-critical logic. func TestValidatePassword(t *testing.T) { @@ -88,7 +88,7 @@ func TestValidatePassword(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := validatePassword(tc.input) + err := ValidatePassword(tc.input) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil") diff --git a/internal/service/user.go b/internal/service/user.go index e4b26a9..511a002 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -2,9 +2,12 @@ package service import ( "context" + "errors" "fmt" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "golang.org/x/crypto/bcrypt" "github.com/osama1998h/uniauth/internal/domain" db "github.com/osama1998h/uniauth/internal/repository/postgres" @@ -12,13 +15,73 @@ import ( // UserService handles user management logic. type UserService struct { - store *db.Store - auditSvc *AuditService + store *db.Store + auditSvc *AuditService + webhookSvc *WebhookService } // NewUserService creates a UserService. -func NewUserService(store *db.Store, auditSvc *AuditService) *UserService { - return &UserService{store: store, auditSvc: auditSvc} +func NewUserService(store *db.Store, auditSvc *AuditService, webhookSvc *WebhookService) *UserService { + return &UserService{store: store, auditSvc: auditSvc, webhookSvc: webhookSvc} +} + +// CreateUserInput holds the data required to create a user in an organization. +type CreateUserInput struct { + Email string + Password string + FullName *string + RoleIDs []uuid.UUID +} + +// Create creates a new user inside the given organization. +// The new user always defaults to non-superuser. +func (s *UserService) Create(ctx context.Context, orgID, actorID uuid.UUID, in CreateUserInput, ipAddress, userAgent *string) (*domain.User, error) { + if in.Email == "" { + return nil, fmt.Errorf("%w: email is required", domain.ErrInvalidInput) + } + + if err := ValidatePassword(in.Password); err != nil { + return nil, err + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user, err := s.store.CreateUser(ctx, orgID, in.Email, string(hashed), in.FullName, false) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, fmt.Errorf("%w: email already exists in this organization", domain.ErrAlreadyExists) + } + return nil, fmt.Errorf("create user: %w", err) + } + + for _, roleID := range in.RoleIDs { + if _, err := s.store.GetRoleByID(ctx, orgID, roleID); err != nil { + return nil, fmt.Errorf("%w: role %s not found in organization", domain.ErrInvalidInput, roleID) + } + if err := s.store.AssignRoleToUser(ctx, orgID, user.ID, roleID); err != nil { + return nil, fmt.Errorf("assign role %s: %w", roleID, err) + } + } + + s.auditSvc.Log(&domain.AuditLog{ + OrgID: &orgID, + UserID: &actorID, + Action: domain.AuditActionUserCreated, + ResourceType: strPtr("user"), + ResourceID: strPtr(user.ID.String()), + IPAddress: ipAddress, + UserAgent: userAgent, + }) + s.webhookSvc.Dispatch(orgID, domain.AuditActionUserCreated, map[string]any{ + "user_id": user.ID, + "email": user.Email, + }) + + return user, nil } // GetByID returns a user by ID within the caller's organization. diff --git a/internal/service/user_test.go b/internal/service/user_test.go new file mode 100644 index 0000000..37d7f93 --- /dev/null +++ b/internal/service/user_test.go @@ -0,0 +1,330 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/osama1998h/uniauth/internal/domain" + db "github.com/osama1998h/uniauth/internal/repository/postgres" + "github.com/osama1998h/uniauth/internal/testutil" +) + +func newTestUserService(t *testing.T) (*UserService, *db.Store) { + t.Helper() + + store := testutil.RequireTestStore(t) + logger := testutil.DiscardLogger() + auditSvc := NewAuditService(store, logger) + webhookSvc := NewWebhookService(store, logger) + svc := NewUserService(store, auditSvc, webhookSvc) + + return svc, store +} + +func TestUserService_Create(t *testing.T) { + if testing.Short() { + t.Skip("skipping database-dependent test in short mode") + } + + svc, store := newTestUserService(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + org := testutil.CreateOrganization(t, store, "user-create-org") + actor := testutil.CreateUser(t, store, org.ID, "user-create-actor") + + tests := []struct { + name string + input CreateUserInput + orgID uuid.UUID + setup func(t *testing.T) + check func(t *testing.T, user *domain.User) + wantErr error + }{ + { + name: "happy path — no roles", + input: CreateUserInput{ + Email: "happy-no-roles@example.com", + Password: "S3cur3P@ss!", + }, + orgID: org.ID, + check: func(t *testing.T, user *domain.User) { + if user.IsSuperuser { + t.Error("expected is_superuser=false") + } + if !user.IsActive { + t.Error("expected is_active=true") + } + if user.OrgID != org.ID { + t.Errorf("org_id = %s, want %s", user.OrgID, org.ID) + } + }, + }, + { + name: "happy path — with roles", + input: func() CreateUserInput { + role1 := testutil.CreateRole(t, store, org.ID, "create-role1") + role2 := testutil.CreateRole(t, store, org.ID, "create-role2") + return CreateUserInput{ + Email: "happy-with-roles@example.com", + Password: "S3cur3P@ss!", + RoleIDs: []uuid.UUID{role1.ID, role2.ID}, + } + }(), + orgID: org.ID, + check: func(t *testing.T, user *domain.User) { + roles, err := store.ListRolesByUser(ctx, user.ID) + if err != nil { + t.Fatalf("ListRolesByUser: %v", err) + } + if len(roles) != 2 { + t.Errorf("expected 2 roles, got %d", len(roles)) + } + }, + }, + { + name: "password is hashed with bcrypt", + input: CreateUserInput{ + Email: "hashed-pw@example.com", + Password: "S3cur3P@ss!", + }, + orgID: org.ID, + check: func(t *testing.T, user *domain.User) { + dbUser, err := store.GetUserByID(ctx, org.ID, user.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(dbUser.HashedPassword), []byte("S3cur3P@ss!")); err != nil { + t.Error("password was not hashed with bcrypt") + } + }, + }, + { + name: "created user is never superuser", + input: CreateUserInput{ + Email: "never-superuser@example.com", + Password: "S3cur3P@ss!", + }, + orgID: org.ID, + check: func(t *testing.T, user *domain.User) { + if user.IsSuperuser { + t.Error("expected is_superuser=false, even when created by admin") + } + }, + }, + { + name: "full name is set", + input: CreateUserInput{ + Email: "with-name@example.com", + Password: "S3cur3P@ss!", + FullName: strPtr("John Doe"), + }, + orgID: org.ID, + check: func(t *testing.T, user *domain.User) { + if user.FullName == nil || *user.FullName != "John Doe" { + t.Errorf("full_name = %v, want %q", user.FullName, "John Doe") + } + }, + }, + { + name: "weak password — too short", + input: CreateUserInput{ + Email: "weak-short@example.com", + Password: "Abc1!e", + }, + orgID: org.ID, + wantErr: domain.ErrWeakPassword, + }, + { + name: "weak password — no uppercase", + input: CreateUserInput{ + Email: "weak-noup@example.com", + Password: "secure@123", + }, + orgID: org.ID, + wantErr: domain.ErrWeakPassword, + }, + { + name: "weak password — no digit", + input: CreateUserInput{ + Email: "weak-nodig@example.com", + Password: "Secure@abc", + }, + orgID: org.ID, + wantErr: domain.ErrWeakPassword, + }, + { + name: "weak password — no special char", + input: CreateUserInput{ + Email: "weak-nospec@example.com", + Password: "Secure1234", + }, + orgID: org.ID, + wantErr: domain.ErrWeakPassword, + }, + { + name: "empty email", + input: CreateUserInput{ + Email: "", + Password: "S3cur3P@ss!", + }, + orgID: org.ID, + wantErr: domain.ErrInvalidInput, + }, + { + name: "duplicate email in same org", + input: CreateUserInput{ + Email: "duplicate@example.com", + Password: "S3cur3P@ss!", + }, + orgID: org.ID, + setup: func(t *testing.T) { + // Pre-create a user with the same email in the same org + _, err := svc.Create(ctx, org.ID, actor.ID, CreateUserInput{ + Email: "duplicate@example.com", + Password: "S3cur3P@ss!", + }, nil, nil) + if err != nil { + t.Fatalf("setup duplicate: %v", err) + } + }, + wantErr: domain.ErrAlreadyExists, + }, + { + name: "same email in different org succeeds", + input: CreateUserInput{ + Email: "cross-org@example.com", + Password: "S3cur3P@ss!", + }, + orgID: func() uuid.UUID { + org2 := testutil.CreateOrganization(t, store, "user-create-org2") + // Pre-create a user in org1 with this email + _, err := svc.Create(ctx, org.ID, actor.ID, CreateUserInput{ + Email: "cross-org@example.com", + Password: "S3cur3P@ss!", + }, nil, nil) + if err != nil { + t.Fatalf("setup cross-org: %v", err) + } + return org2.ID + }(), + check: func(t *testing.T, user *domain.User) { + if user.Email != "cross-org@example.com" { + t.Errorf("email = %q, want %q", user.Email, "cross-org@example.com") + } + }, + }, + { + name: "role from different org rejected", + input: func() CreateUserInput { + otherOrg := testutil.CreateOrganization(t, store, "other-org-role") + foreignRole := testutil.CreateRole(t, store, otherOrg.ID, "foreign-role") + return CreateUserInput{ + Email: "foreign-role@example.com", + Password: "S3cur3P@ss!", + RoleIDs: []uuid.UUID{foreignRole.ID}, + } + }(), + orgID: org.ID, + wantErr: domain.ErrInvalidInput, + }, + { + name: "non-existent role ID rejected", + input: CreateUserInput{ + Email: "bad-role@example.com", + Password: "S3cur3P@ss!", + RoleIDs: []uuid.UUID{uuid.New()}, + }, + orgID: org.ID, + wantErr: domain.ErrInvalidInput, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(t) + } + + user, err := svc.Create(ctx, tc.orgID, actor.ID, tc.input, nil, nil) + + if tc.wantErr != nil { + if err == nil { + t.Fatalf("expected error wrapping %v, got nil", tc.wantErr) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("error = %v, want errors.Is(%v)", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.check != nil { + tc.check(t, user) + } + }) + } +} + +func TestUserService_Create_AuditLog(t *testing.T) { + if testing.Short() { + t.Skip("skipping database-dependent test in short mode") + } + + store := testutil.RequireTestStore(t) + logger := slog.Default() + auditSvc := NewAuditService(store, logger) + webhookSvc := NewWebhookService(store, logger) + svc := NewUserService(store, auditSvc, webhookSvc) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + org := testutil.CreateOrganization(t, store, "audit-org") + actor := testutil.CreateUser(t, store, org.ID, "audit-actor") + + user, err := svc.Create(ctx, org.ID, actor.ID, CreateUserInput{ + Email: "audit-test@example.com", + Password: "S3cur3P@ss!", + }, nil, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Give async audit log goroutine time to complete + time.Sleep(500 * time.Millisecond) + + action := domain.AuditActionUserCreated + logs, err := store.ListAuditLogs(ctx, org.ID, domain.AuditFilter{ + Action: &action, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListAuditLogs: %v", err) + } + + var found bool + for _, log := range logs { + if log.ResourceID != nil && *log.ResourceID == user.ID.String() { + found = true + if log.UserID == nil || *log.UserID != actor.ID { + t.Errorf("audit log actor = %v, want %s", log.UserID, actor.ID) + } + if log.ResourceType == nil || *log.ResourceType != "user" { + t.Errorf("audit log resource_type = %v, want %q", log.ResourceType, "user") + } + break + } + } + if !found { + t.Error("expected user.created audit log entry, found none") + } +}