From 904f446447e24da4d5cf5348005a4f50b55ae4a9 Mon Sep 17 00:00:00 2001 From: Alexey Date: Sat, 20 Dec 2025 17:38:15 +0200 Subject: [PATCH] basicly implement acl crud ops with roles and resources --- api/acl_admin/common_models.go | 11 + api/acl_admin/errors.go | 28 +++ api/acl_admin/handle.go | 280 +++++++++++++++++++------- api/acl_admin/resources.go | 321 ++++++++++++++++++++++++++++++ api/acl_admin/resources_models.go | 94 +++++++++ api/acl_admin/roles.go | 314 +++++++++++++++++++++++++++++ api/acl_admin/roles_models.go | 94 +++++++++ api/auth/handle.go | 5 +- cmd/serve.go | 2 +- internal/acl/errors.go | 21 ++ internal/acl/models.go | 4 +- internal/acl/resources.go | 140 +++++++++++++ internal/acl/roles.go | 136 +++++++++++++ internal/acl/service.go | 61 ++---- internal/acl_test/crud_test.go | 260 ++++++++++++------------ internal/server/error.go | 20 ++ internal/user/model.go | 2 +- internal/user/user_test.go | 138 ++++++------- 18 files changed, 1607 insertions(+), 324 deletions(-) create mode 100644 api/acl_admin/common_models.go create mode 100644 api/acl_admin/errors.go create mode 100644 api/acl_admin/resources.go create mode 100644 api/acl_admin/resources_models.go create mode 100644 api/acl_admin/roles.go create mode 100644 api/acl_admin/roles_models.go create mode 100644 internal/acl/errors.go create mode 100644 internal/acl/resources.go create mode 100644 internal/acl/roles.go create mode 100644 internal/server/error.go diff --git a/api/acl_admin/common_models.go b/api/acl_admin/common_models.go new file mode 100644 index 0000000..0211c84 --- /dev/null +++ b/api/acl_admin/common_models.go @@ -0,0 +1,11 @@ +package api_acladmin + +type errorInvalidRequestBody struct { + Error string `json:"error" example:"INVALID_REQUEST_BODY"` + Details string `json:"details" example:"Request body is not valid JSON"` +} + +type errorInternalServerError struct { + Error string `json:"error"` + Details string `json:"details"` +} diff --git a/api/acl_admin/errors.go b/api/acl_admin/errors.go new file mode 100644 index 0000000..dee7889 --- /dev/null +++ b/api/acl_admin/errors.go @@ -0,0 +1,28 @@ +package api_acladmin + +const ( + ErrorInvalidRequestBody = "INVALID_REQUEST_BODY" + ErrorInternalServerError = "INTERNAL_SERVER_ERROR" + + // Roles + ErrorFailedToCreateRole = "FAILED_TO_CREATE_ROLE" + ErrorFailedToGetRole = "FAILED_TO_GET_ROLE" + ErrorFailedToUpdateRole = "FAILED_TO_UPDATE_ROLE" + ErrorFailedToDeleteRole = "FAILED_TO_DELETE_ROLE" + + ErrorInvalidRoleID = "INVALID_ROLE_ID" + ErrorRoleNotFound = "ROLE_NOT_FOUND" + + // Resources + ErrorFailedToCreateResource = "FAILED_TO_CREATE_RESOURCE" + ErrorFailedToGetResource = "FAILED_TO_GET_RESOURCE" + ErrorFailedToUpdateResource = "FAILED_TO_UPDATE_RESOURCE" + ErrorFailedToDeleteResource = "FAILED_TO_DELETE_RESOURCE" + + ErrorInvalidResourceID = "INVALID_RESOURCE_ID" + ErrorResourceNotFound = "RESOURCE_NOT_FOUND" +) + +const ( + ErrorACLServiceNotInitialized = "ACL service is not initialized" +) diff --git a/api/acl_admin/handle.go b/api/acl_admin/handle.go index b5cc14f..851f766 100644 --- a/api/acl_admin/handle.go +++ b/api/acl_admin/handle.go @@ -1,13 +1,11 @@ package api_acladmin import ( - "encoding/json" - "net/http" - "git.oblat.lv/alex/triggerssmith/internal/acl" "git.oblat.lv/alex/triggerssmith/internal/auth" "git.oblat.lv/alex/triggerssmith/internal/config" - "git.oblat.lv/alex/triggerssmith/internal/server" + + //"git.oblat.lv/alex/triggerssmith/internal/server" "github.com/go-chi/chi/v5" ) @@ -32,91 +30,221 @@ func MustRoute(config *config.Config, aclService *acl.Service, authService *auth a: aclService, auth: authService, } + // GET /roles — список ролей + // POST /roles — создать роль + // GET /roles/{roleId} — получить роль + // PATCH /roles/{roleId} — обновить роль (если нужно) + // DELETE /roles/{roleId} — удалить роль + + // GET /resources — список ресурсов + // POST /resources — создать ресурс + // GET /resources/{resId} — получить ресурс + // PATCH /resources/{resId} — обновить ресурс + // DELETE /resources/{resId} — удалить ресурс + + // GET /users/{userId}/roles — роли пользователя + // POST /users/{userId}/roles — назначить роль пользователю + // DELETE /users/{userId}/roles/{roleId} — снять роль + + // GET /roles/{roleId}/resources — ресурсы роли + // POST /roles/{roleId}/resources — назначить ресурс роли + // DELETE /roles/{roleId}/resources/{resId} — убрать ресурс return func(r chi.Router) { - r.Get("/roles", h.getRoles) - r.Post("/create-role", h.createRole) - r.Post("/assign-role", h.assignRoleToUser) - r.Get("/user-roles", h.getUserRoles) - r.Post("/remove-role", h.removeRoleFromUser) + // Roles + r.Get("/roles", h.getRoles) // list all roles + r.Post("/roles", h.createRole) // create a new role + r.Get("/roles/{roleId}", h.getRole) // get a role by ID + r.Patch("/roles/{roleId}", h.updateRole) // update a role by ID + r.Delete("/roles/{roleId}", h.deleteRole) // delete a role by ID - r.Get("/resources", h.getResources) - r.Post("/create-resource", h.createResource) - r.Post("/assign-resource", h.assignResourceToRole) - r.Get("/role-resources", h.getRoleResources) - r.Post("/remove-resource", h.removeResourceFromRole) + // // Resources + r.Get("/resources", h.getResources) // list all resources + r.Post("/resources", h.createResource) // create a new resource + r.Get("/resources/{resourceId}", h.getResource) // get a resource by ID + r.Patch("/resources/{resourceId}", h.updateResource) // update a resource by ID + r.Delete("/resources/{resourceId}", h.deleteResource) // delete a resource by ID - r.Get("/permissions", h.getResources) // legacy support - r.Post("/create-permissions", h.createResource) // legacy support - r.Post("/assign-permissions", h.assignResourceToRole) // legacy support - r.Get("/role-permissions", h.getRoleResources) // legacy support - r.Post("/remove-permissions", h.removeResourceFromRole) // legacy support + // Users + // r.Get("/users/{userId}/roles", h.getUserRoles) // get all roles for a user + // r.Post("/users/{userId}/roles", h.assignRoleToUser) // assign a role to a user + // r.Delete("/users/{userId}/roles/{roleId}", h.removeRoleFromUser) // remove a role from a user + + // r.Get("/roles", h.getRoles) + // r.Post("/create-role", h.createRole) + // r.Post("/assign-role", h.assignRoleToUser) + // r.Get("/user-roles", h.getUserRoles) + // r.Post("/remove-role", h.removeRoleFromUser) + + // r.Get("/resources", h.getResources) + // r.Post("/create-resource", h.createResource) + // r.Post("/assign-resource", h.assignResourceToRole) + // r.Get("/role-resources", h.getRoleResources) + // r.Post("/remove-resource", h.removeResourceFromRole) + + // r.Get("/permissions", h.getResources) // legacy support + // r.Post("/create-permissions", h.createResource) // legacy support + // r.Post("/assign-permissions", h.assignResourceToRole) // legacy support + // r.Get("/role-permissions", h.getRoleResources) // legacy support + // r.Post("/remove-permissions", h.removeResourceFromRole) // legacy support } } -type rolesResponse []struct { - ID uint `json:"id"` - Name string `json:"name"` -} +// type assignRoleRequest struct { +// UserID int `json:"userId"` +// RoleID int `json:"roleId"` +// } -func (h *aclAdminHandler) getRoles(w http.ResponseWriter, r *http.Request) { - roles, err := h.a.GetRoles() - if err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(func() rolesResponse { - // Transform acl.Role to rolesResponse - resp := make(rolesResponse, 0, len(roles)) - for _, role := range roles { - resp = append(resp, struct { - ID uint `json:"id"` - Name string `json:"name"` - }{ - ID: role.ID, - Name: role.Name, - }) - } - return resp - }()) - if err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} +// func (h *aclAdminHandler) assignRoleToUser(w http.ResponseWriter, r *http.Request) { +// var req assignRoleRequest +// if err := json.NewDecoder(r.Body).Decode(&req); err != nil { +// http.Error(w, "Invalid request body", http.StatusBadRequest) +// return +// } +// if req.UserID < 0 || req.RoleID < 0 { +// http.Error(w, "Invalid user or role ID", http.StatusBadRequest) +// return +// } +// if err := h.a.AssignRoleToUser(uint(req.RoleID), uint(req.UserID)); err != nil { +// http.Error(w, "Failed to assign role to user", http.StatusConflict) +// return +// } +// w.WriteHeader(http.StatusCreated) +// } -func (h *aclAdminHandler) createRole(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// type getUserRolesResponse getRolesResponse -func (h *aclAdminHandler) assignRoleToUser(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// func (h *aclAdminHandler) getUserRoles(w http.ResponseWriter, r *http.Request) { +// uidStr := r.URL.Query().Get("userId") +// if uidStr == "" { +// http.Error(w, "Missing userId parameter", http.StatusBadRequest) +// return +// } +// userID, err := strconv.Atoi(uidStr) +// if err != nil || userID < 0 { +// http.Error(w, "Invalid userId parameter", http.StatusBadRequest) +// return +// } +// roles, err := h.a.GetUserRoles(uint(userID)) +// if err != nil { +// http.Error(w, "Internal server error", http.StatusInternalServerError) +// return +// } +// w.Header().Set("Content-Type", "application/json") +// err = json.NewEncoder(w).Encode(func() getUserRolesResponse { +// // Transform acl.Role to getUserRolesResponse +// resp := make(getUserRolesResponse, 0, len(roles)) +// for _, role := range roles { +// resp = append(resp, struct { +// ID uint `json:"id"` +// Name string `json:"name"` +// }{ +// ID: role.ID, +// Name: role.Name, +// }) +// } +// return resp +// }()) +// if err != nil { +// http.Error(w, "Failed to encode response", http.StatusInternalServerError) +// return +// } +// } -func (h *aclAdminHandler) getUserRoles(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// type removeRoleRequest struct { +// UserID int `json:"userId"` +// RoleID int `json:"roleId"` +// } -func (h *aclAdminHandler) removeRoleFromUser(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// func (h *aclAdminHandler) removeRoleFromUser(w http.ResponseWriter, r *http.Request) { +// var req removeRoleRequest +// if err := json.NewDecoder(r.Body).Decode(&req); err != nil { +// http.Error(w, "Invalid request body", http.StatusBadRequest) +// return +// } +// if req.UserID < 0 || req.RoleID < 0 { +// http.Error(w, "Invalid user or role ID", http.StatusBadRequest) +// return +// } +// if err := h.a.RemoveRoleFromUser(uint(req.RoleID), uint(req.UserID)); err != nil { +// http.Error(w, "Failed to remove role from user", http.StatusConflict) +// return +// } +// w.WriteHeader(http.StatusNoContent) +// } -func (h *aclAdminHandler) getResources(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// type getResourcesResponse getRolesResponse -func (h *aclAdminHandler) createResource(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// func (h *aclAdminHandler) getResources(w http.ResponseWriter, r *http.Request) { +// resources, err := h.a.GetResources() +// if err != nil { +// http.Error(w, "Internal server error", http.StatusInternalServerError) +// return +// } +// w.Header().Set("Content-Type", "application/json") +// err = json.NewEncoder(w).Encode(func() getResourcesResponse { +// // Transform acl.Resource to getResourcesResponse +// resp := make(getResourcesResponse, 0, len(resources)) +// for _, res := range resources { +// resp = append(resp, struct { +// ID uint `json:"id"` +// Name string `json:"name"` +// }{ +// ID: res.ID, +// Name: res.Key, +// }) +// } +// return resp +// }()) +// if err != nil { +// http.Error(w, "Failed to encode response", http.StatusInternalServerError) +// return +// } +// } -func (h *aclAdminHandler) assignResourceToRole(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// type createResourceRequest struct { +// Name string `json:"name"` +// } -func (h *aclAdminHandler) getRoleResources(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// type createResourceResponse struct { +// ID uint `json:"id"` +// Name string `json:"name"` +// } -func (h *aclAdminHandler) removeResourceFromRole(w http.ResponseWriter, r *http.Request) { - server.NotImplemented(w) -} +// func (h *aclAdminHandler) createResource(w http.ResponseWriter, r *http.Request) { +// var req createResourceRequest +// if err := json.NewDecoder(r.Body).Decode(&req); err != nil { +// http.Error(w, "Invalid request body", http.StatusBadRequest) +// return +// } +// if req.Name == "" { +// http.Error(w, "Name is required", http.StatusBadRequest) +// return +// } +// id, err := h.a.CreateResource(req.Name) +// if err != nil { +// http.Error(w, "Failed to create resource", http.StatusConflict) +// return +// } +// w.WriteHeader(http.StatusCreated) +// w.Header().Set("Content-Type", "application/json") +// err = json.NewEncoder(w).Encode(createResourceResponse{ +// ID: id, +// Name: req.Name, +// }) +// if err != nil { +// http.Error(w, "Failed to encode response", http.StatusInternalServerError) +// return +// } +// } + +// func (h *aclAdminHandler) assignResourceToRole(w http.ResponseWriter, r *http.Request) { +// server.NotImplemented(w) +// } + +// func (h *aclAdminHandler) getRoleResources(w http.ResponseWriter, r *http.Request) { +// server.NotImplemented(w) +// } + +// func (h *aclAdminHandler) removeResourceFromRole(w http.ResponseWriter, r *http.Request) { +// server.NotImplemented(w) +// } diff --git a/api/acl_admin/resources.go b/api/acl_admin/resources.go new file mode 100644 index 0000000..5c88b96 --- /dev/null +++ b/api/acl_admin/resources.go @@ -0,0 +1,321 @@ +package api_acladmin + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + + "git.oblat.lv/alex/triggerssmith/internal/acl" + "github.com/go-chi/chi/v5" +) + +// @Summary Get all resources +// @Tags resources +// @Produce json +// @Success 200 {object} getResourcesResponse +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/resources [get] +func (h *aclAdminHandler) getResources(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resources, err := h.a.GetResources() + if err != nil { + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to get resources", + }) + return + } + } + + _ = json.NewEncoder(w).Encode(func() getResourcesResponse { + resp := make(getResourcesResponse, 0, len(resources)) + for _, res := range resources { + resp = append(resp, struct { + ID uint `json:"id" example:"1"` + Key string `json:"key" example:"html.view"` + }{ + ID: res.ID, + Key: res.Key, + }) + } + return resp + }()) +} + +// @Summary Get resource by ID +// @Tags resources +// @Produce json +// @Param resourceId path int true "Resource ID" example(1) +// @Success 200 {object} getResourceResponse +// @Failure 400 {object} getResourceErrorInvalidResourceID +// @Failure 404 {object} getResourceErrorResourceNotFound +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/resources/{resourceId} [get] +func (h *aclAdminHandler) getResource(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resourceIDStr := chi.URLParam(r, "resourceId") + resourceID, err := strconv.Atoi(resourceIDStr) + if err != nil || resourceID < 0 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(getResourceErrorInvalidResourceID{ + Error: ErrorInvalidResourceID, + Details: "Resource ID must be positive integer", + }) + return + } + + resource, err := h.a.GetResourceByID(uint(resourceID)) + if err != nil { + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrResourceNotFound: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(getResourceErrorResourceNotFound{ + Error: ErrorResourceNotFound, + Details: "No resource with ID " + resourceIDStr, + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to get resource with ID " + resourceIDStr, + }) + return + } + } + + _ = json.NewEncoder(w).Encode(getResourceResponse{ + ID: resource.ID, + Key: resource.Key, + }) +} + +// @Summary Create resource +// @Tags resources +// @Accept json +// @Produce json +// @Param request body createResourceRequest true "Resource" +// @Success 201 {object} createResourceResponse +// @Failure 400 {object} errorInvalidRequestBody +// @Failure 400 {object} createResourceErrorInvalidResourceKey +// @Failure 409 {object} createResourceErrorResourceAlreadyExists +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/resources [post] +func (h *aclAdminHandler) createResource(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var req createResourceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorInvalidRequestBody{ + Error: ErrorInvalidRequestBody, + Details: "Request body is not valid JSON", + }) + return + } + + resourceID, err := h.a.CreateResource(req.Key) + if err != nil { + slog.Error("Failed to create resource", "error", err.Error()) + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrInvalidResourceKey: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(createResourceErrorInvalidResourceKey{ + Error: ErrorFailedToCreateResource, + Details: "Resource key must be non-empty", + }) + return + case acl.ErrResourceAlreadyExists: + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(createResourceErrorResourceAlreadyExists{ + Error: ErrorFailedToCreateResource, + Details: "Resource with key '" + req.Key + "' already exists", + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to create resource with key '" + req.Key + "'", + }) + return + } + } + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(createResourceResponse{ + ID: resourceID, + Key: req.Key, + }) +} + +// @Summary Update resource +// @Tags resources +// @Accept json +// @Produce json +// @Param resourceId path int true "Resource ID" example(1) +// @Param request body updateResourceRequest true "Resource" +// @Success 200 {object} updateResourceResponse +// @Failure 400 {object} errorInvalidRequestBody +// @Failure 400 {object} updateResourceErrorInvalidResourceID +// @Failure 400 {object} updateResourceErrorInvalidResourceKey +// @Failure 404 {object} updateResourceErrorResourceNotFound +// @Failure 409 {object} updateResourceErrorResourceKeyAlreadyExists +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/resources/{resourceId} [patch] +func (h *aclAdminHandler) updateResource(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var req updateResourceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorInvalidRequestBody{ + Error: ErrorInvalidRequestBody, + Details: "Request body is not valid JSON", + }) + return + } + + resourceIDStr := chi.URLParam(r, "resourceId") + resourceID, err := strconv.Atoi(resourceIDStr) + if err != nil || resourceID < 0 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateResourceErrorInvalidResourceID{ + Error: ErrorInvalidResourceID, + Details: "Resource ID must be positive integer", + }) + return + } + + err = h.a.UpdateResource(uint(resourceID), req.Key) + if err != nil { + slog.Error("Failed to update resource", "error", err.Error()) + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrInvalidResourceKey: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateResourceErrorInvalidResourceKey{ + Error: ErrorFailedToUpdateResource, + Details: "Invalid resource key", + }) + return + case acl.ErrResourceNotFound: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(updateResourceErrorResourceNotFound{ + Error: ErrorFailedToUpdateResource, + Details: "No resource with ID " + resourceIDStr, + }) + return + case acl.ErrSameResourceKey: + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(updateResourceErrorResourceKeyAlreadyExists{ + Error: ErrorFailedToUpdateResource, + Details: "Resource with key '" + req.Key + "' already exists", + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to update resource with key '" + req.Key + "'", + }) + return + } + } + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(updateResourceResponse{ + ID: uint(resourceID), + Key: req.Key, + }) +} + +// @Summary Delete resource +// @Tags resources +// @Produce json +// @Param resourceId path int true "Resource ID" example(1) +// @Success 200 +// @Failure 400 {object} deleteResourceErrorInvalidResourceID +// @Failure 404 {object} deleteResourceErrorResourceNotFound +// @Failure 409 {object} deleteResourceErrorResourceInUse +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/resources/{resourceId} [delete] +func (h *aclAdminHandler) deleteResource(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resourceIDStr := chi.URLParam(r, "resourceId") + resourceID, err := strconv.Atoi(resourceIDStr) + if err != nil || resourceID < 0 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(deleteResourceErrorInvalidResourceID{ + Error: ErrorInvalidResourceID, + Details: "Resource ID must be positive integer", + }) + return + } + + err = h.a.DeleteResource(uint(resourceID)) + if err != nil { + slog.Error("Failed to delete resource", "error", err.Error()) + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrResourceNotFound: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(deleteResourceErrorResourceNotFound{ + Error: ErrorFailedToDeleteResource, + Details: "No resource with ID " + resourceIDStr, + }) + return + case acl.ErrResourceInUse: + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(deleteResourceErrorResourceInUse{ + Error: ErrorFailedToDeleteResource, + Details: "Resource with ID " + resourceIDStr + " is used and cannot be deleted", + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to delete resource with ID '" + resourceIDStr + "'", + }) + return + } + } + + w.WriteHeader(http.StatusOK) +} diff --git a/api/acl_admin/resources_models.go b/api/acl_admin/resources_models.go new file mode 100644 index 0000000..8ae2e41 --- /dev/null +++ b/api/acl_admin/resources_models.go @@ -0,0 +1,94 @@ +package api_acladmin + +/*******************************************************************/ +// used in getResources() +type getResourcesResponse []struct { + ID uint `json:"id" example:"1"` + Key string `json:"key" example:"html.view"` +} + +/*******************************************************************/ +// used in getResource() +type getResourceResponse struct { + ID uint `json:"id" example:"1"` + Key string `json:"key" example:"html.view"` +} + +type getResourceErrorInvalidResourceID struct { + Error string `json:"error" example:"INVALID_RESOURCE_ID"` + Details string `json:"details" example:"Resource ID must be positive integer"` +} + +type getResourceErrorResourceNotFound struct { + Error string `json:"error" example:"RESOURCE_NOT_FOUND"` + Details string `json:"details" example:"No resource with ID 123"` +} + +/*******************************************************************/ +// used in createResource() +type createResourceRequest struct { + Key string `json:"key" example:"html.view"` +} + +type createResourceResponse struct { + ID uint `json:"id" example:"1"` + Key string `json:"key" example:"html.view"` +} + +type createResourceErrorResourceAlreadyExists struct { + Error string `json:"error" example:"FAILED_TO_CREATE_RESOURCE"` + Details string `json:"details" example:"Resource with key 'html.view' already exists"` +} + +type createResourceErrorInvalidResourceKey struct { + Error string `json:"error" example:"FAILED_TO_CREATE_RESOURCE"` + Details string `json:"details" example:"Invalid resource key"` +} + +/*******************************************************************/ +// used in updateResource() +type updateResourceRequest struct { + Key string `json:"key" example:"html.view"` +} + +type updateResourceResponse struct { + ID uint `json:"id" example:"1"` + Key string `json:"key" example:"html.view"` +} + +type updateResourceErrorResourceNotFound struct { + Error string `json:"error" example:"RESOURCE_NOT_FOUND"` + Details string `json:"details" example:"No resource with ID 123"` +} + +type updateResourceErrorInvalidResourceID struct { + Error string `json:"error" example:"INVALID_RESOURCE_ID"` + Details string `json:"details" example:"Resource ID must be positive integer"` +} + +type updateResourceErrorInvalidResourceKey struct { + Error string `json:"error" example:"FAILED_TO_UPDATE_RESOURCE"` + Details string `json:"details" example:"Invalid resource key"` +} + +type updateResourceErrorResourceKeyAlreadyExists struct { + Error string `json:"error" example:"FAILED_TO_UPDATE_RESOURCE"` + Details string `json:"details" example:"Resource with key 'html.view' already exists"` +} + +/*******************************************************************/ +// used in deleteResource() +type deleteResourceErrorResourceNotFound struct { + Error string `json:"error" example:"RESOURCE_NOT_FOUND"` + Details string `json:"details" example:"No resource with ID 123"` +} + +type deleteResourceErrorInvalidResourceID struct { + Error string `json:"error" example:"INVALID_RESOURCE_ID"` + Details string `json:"details" example:"Resource ID must be positive integer"` +} + +type deleteResourceErrorResourceInUse struct { + Error string `json:"error" example:"FAILED_TO_DELETE_RESOURCE"` + Details string `json:"details" example:"Resource with ID 123 is used and cannot be deleted"` +} diff --git a/api/acl_admin/roles.go b/api/acl_admin/roles.go new file mode 100644 index 0000000..b35e883 --- /dev/null +++ b/api/acl_admin/roles.go @@ -0,0 +1,314 @@ +package api_acladmin + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + + "git.oblat.lv/alex/triggerssmith/internal/acl" + "github.com/go-chi/chi/v5" +) + +// @Summary Get all roles +// @Tags roles +// @Produce json +// @Success 200 {object} getRolesResponse +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/roles [get] +func (h *aclAdminHandler) getRoles(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + roles, err := h.a.GetRoles() + if err != nil { + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to get roles", + }) + return + } + } + _ = json.NewEncoder(w).Encode(func() getRolesResponse { + // Transform acl.Role to getRolesResponse + resp := make(getRolesResponse, 0, len(roles)) + for _, role := range roles { + resp = append(resp, struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"admin"` + }{ + ID: role.ID, + Name: role.Name, + }) + } + return resp + }()) +} + +// @Summary Get role by ID +// @Tags roles +// @Produce json +// @Param roleId path int true "Role ID" example(1) +// @Success 200 {object} getRoleResponse +// @Failure 400 {object} getRoleErrorInvalidRoleID +// @Failure 404 {object} getRoleErrorRoleNotFound +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/roles/{roleId} [get] +func (h *aclAdminHandler) getRole(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + roleIDStr := chi.URLParam(r, "roleId") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil || roleID < 0 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(getRoleErrorInvalidRoleID{ + Error: ErrorInvalidRoleID, + Details: "Role ID must be positive integer", + }) + return + } + role, err := h.a.GetRoleByID(uint(roleID)) + if err != nil { + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrRoleNotFound: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(getRoleErrorRoleNotFound{ + Error: ErrorRoleNotFound, + Details: "No role with ID " + roleIDStr, + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to get role with ID " + roleIDStr, + }) + return + } + } + _ = json.NewEncoder(w).Encode(getRoleResponse{ + ID: role.ID, + Name: role.Name, + }) +} + +// @Summary Create role +// @Tags roles +// @Accept json +// @Produce json +// @Param request body createRoleRequest true "Role" +// @Success 201 {object} createRoleResponse +// @Failure 400 {object} errorInvalidRequestBody +// @Failure 401 {object} createRoleErrorInvalidRoleName +// @Failure 409 {object} createRoleErrorRoleAlreadyExists +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/roles [post] +func (h *aclAdminHandler) createRole(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var req createRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorInvalidRequestBody{ + Error: ErrorInvalidRequestBody, + Details: "Request body is not valid JSON", + }) + return + } + roleID, err := h.a.CreateRole(req.Name) + if err != nil { + slog.Error("Failed to create role", "error", err.Error()) + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrRoleAlreadyExists: + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(createRoleErrorRoleAlreadyExists{ + Error: ErrorFailedToCreateRole, + Details: "Role with name '" + req.Name + "' already exists", + }) + return + case acl.ErrInvalidRoleName: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(createRoleErrorInvalidRoleName{ + Error: ErrorFailedToCreateRole, + Details: "Role name must be non-empty string", + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to create role with name '" + req.Name + "'", + }) + return + } + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(createRoleResponse{ + ID: roleID, + Name: req.Name, + }) +} + +// @Summary Update role +// @Tags roles +// @Accept json +// @Produce json +// @Param roleId path int true "Role ID" example(1) +// @Param request body updateRoleRequest true "Role" +// @Success 200 {object} updateRoleResponse +// @Failure 400 {object} errorInvalidRequestBody +// @Failure 400 {object} updateRoleErrorInvalidRoleID +// @Failure 400 {object} updateRoleErrorInvalidRoleName +// @Failure 404 {object} updateRoleErrorRoleNotFound +// @Failure 409 {object} updateRoleErrorRoleNameAlreadyExists +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/roles/{roleId} [patch] +func (h *aclAdminHandler) updateRole(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var req updateRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorInvalidRequestBody{ + Error: ErrorInvalidRequestBody, + Details: "Request body is not valid JSON", + }) + return + } + roleIDStr := chi.URLParam(r, "roleId") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil || roleID < 0 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateRoleErrorInvalidRoleID{ + Error: ErrorInvalidRoleID, + Details: "Role ID must be positive integer", + }) + return + } + err = h.a.UpdateRole(uint(roleID), req.Name) + // TODO: make error handling more specific in acl service + if err != nil { + slog.Error("Failed to update role", "error", err.Error()) + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrInvalidRoleName: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateRoleErrorInvalidRoleName{ + Error: ErrorFailedToUpdateRole, + Details: "Invalid role name", + }) + return + case acl.ErrRoleNotFound: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(updateRoleErrorRoleNotFound{ + Error: ErrorFailedToUpdateRole, + Details: "No role with ID " + roleIDStr, + }) + return + case acl.ErrSameRoleName: + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(updateRoleErrorRoleNameAlreadyExists{ + Error: ErrorFailedToUpdateRole, + Details: "Role with name '" + req.Name + "' already exists", + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to update role with name '" + req.Name + "'", + }) + return + } + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(updateRoleResponse{ + ID: uint(roleID), + Name: req.Name, + }) +} + +// @Summary Delete role +// @Tags roles +// @Produce json +// @Param roleId path int true "Role ID" example(1) +// @Success 200 +// @Failure 400 {object} deleteRoleErrorInvalidRoleID +// @Failure 404 {object} deleteRoleErrorRoleNotFound +// @Failure 409 {object} deleteRoleErrorRoleInUse +// @Failure 500 {object} errorInternalServerError +// @Router /api/acl/roles/{roleId} [delete] +func (h *aclAdminHandler) deleteRole(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + roleIDStr := chi.URLParam(r, "roleId") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil || roleID < 0 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(deleteRoleErrorInvalidRoleID{ + Error: ErrorInvalidRoleID, + Details: "Role ID must be positive integer", + }) + return + } + err = h.a.DeleteRole(uint(roleID)) + // TODO: make error handling more specific in acl service + if err != nil { + slog.Error("Failed to delete role", "error", err.Error()) + switch err { + case acl.ErrNotInitialized: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: ErrorACLServiceNotInitialized, + }) + return + case acl.ErrRoleNotFound: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(deleteRoleErrorRoleNotFound{ + Error: ErrorFailedToDeleteRole, + Details: "No role with ID " + roleIDStr, + }) + return + case acl.ErrRoleInUse: + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(deleteRoleErrorRoleInUse{ + Error: ErrorFailedToDeleteRole, + Details: "Role with ID " + roleIDStr + " is assigned to users and cannot be deleted", + }) + return + default: + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorInternalServerError{ + Error: ErrorInternalServerError, + Details: "Failed to delete role with ID '" + roleIDStr + "'", + }) + return + } + } + w.WriteHeader(http.StatusOK) +} diff --git a/api/acl_admin/roles_models.go b/api/acl_admin/roles_models.go new file mode 100644 index 0000000..e538fd7 --- /dev/null +++ b/api/acl_admin/roles_models.go @@ -0,0 +1,94 @@ +package api_acladmin + +/*******************************************************************/ +// used in getRoles() +type getRolesResponse []struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"admin"` +} + +/*******************************************************************/ +// used in getRole() +type getRoleResponse struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"admin"` +} + +type getRoleErrorInvalidRoleID struct { + Error string `json:"error" example:"INVALID_ROLE_ID"` + Details string `json:"details" example:"Role ID must be positive integer"` +} + +type getRoleErrorRoleNotFound struct { + Error string `json:"error" example:"ROLE_NOT_FOUND"` + Details string `json:"details" example:"No role with ID 123"` +} + +/*******************************************************************/ +// used in createRole() +type createRoleRequest struct { + Name string `json:"name" example:"admin"` +} + +type createRoleResponse struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"admin"` +} + +type createRoleErrorRoleAlreadyExists struct { + Error string `json:"error" example:"FAILED_TO_CREATE_ROLE"` + Details string `json:"details" example:"Role with name 'admin' already exists"` +} + +type createRoleErrorInvalidRoleName struct { + Error string `json:"error" example:"FAILED_TO_CREATE_ROLE"` + Details string `json:"details" example:"Invalid role name"` +} + +/*******************************************************************/ +// used in updateRole() +type updateRoleRequest struct { + Name string `json:"name" example:"admin"` +} + +type updateRoleResponse struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"admin"` +} + +type updateRoleErrorRoleNotFound struct { + Error string `json:"error" example:"ROLE_NOT_FOUND"` + Details string `json:"details" example:"No role with ID 123"` +} + +type updateRoleErrorInvalidRoleID struct { + Error string `json:"error" example:"INVALID_ROLE_ID"` + Details string `json:"details" example:"Role ID must be positive integer"` +} + +type updateRoleErrorInvalidRoleName struct { + Error string `json:"error" example:"FAILED_TO_UPDATE_ROLE"` + Details string `json:"details" example:"Invalid role name"` +} + +type updateRoleErrorRoleNameAlreadyExists struct { + Error string `json:"error" example:"FAILED_TO_UPDATE_ROLE"` + Details string `json:"details" example:"Role with name 'admin' already exists"` +} + +/*******************************************************************/ +// used in deleteRole() +type deleteRoleErrorRoleNotFound struct { + Error string `json:"error" example:"ROLE_NOT_FOUND"` + Details string `json:"details" example:"No role with ID 123"` +} + +type deleteRoleErrorInvalidRoleID struct { + Error string `json:"error" example:"INVALID_ROLE_ID"` + Details string `json:"details" example:"Role ID must be positive integer"` +} + +type deleteRoleErrorRoleInUse struct { + Error string `json:"error" example:"FAILED_TO_DELETE_ROLE"` + Details string `json:"details" example:"Role with ID 123 is assigned to users and cannot be deleted"` +} diff --git a/api/auth/handle.go b/api/auth/handle.go index 5f94c9c..05c6a09 100644 --- a/api/auth/handle.go +++ b/api/auth/handle.go @@ -65,7 +65,7 @@ type registerRequest struct { } type registerResponse struct { - UserID int64 `json:"id"` + UserID uint `json:"id"` Username string `json:"username"` } @@ -92,6 +92,7 @@ func (h *authHandler) handleRegister(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } + w.WriteHeader(http.StatusCreated) } type loginRequest struct { @@ -152,7 +153,7 @@ func (h *authHandler) handleLogout(w http.ResponseWriter, r *http.Request) { } type meResponse struct { - UserID int64 `json:"id"` + UserID uint `json:"id"` Username string `json:"username"` Email string `json:"email"` } diff --git a/cmd/serve.go b/cmd/serve.go index 9da37c2..fa6eac8 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -202,7 +202,7 @@ var serveCmd = &cobra.Command{ } // also acl !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - userData, err := gorm.Open(sqlite.Open(filepath.Join(cfg.Data.DataPath, "user_data.sqlite3")), &gorm.Config{}) + userData, err := gorm.Open(sqlite.Open(filepath.Join(cfg.Data.DataPath, "user_data.sqlite3")+"?_foreign_keys=on"), &gorm.Config{}) if err != nil { slog.Error("Failed to open user database", slog.String("error", err.Error())) return diff --git a/internal/acl/errors.go b/internal/acl/errors.go new file mode 100644 index 0000000..21b2074 --- /dev/null +++ b/internal/acl/errors.go @@ -0,0 +1,21 @@ +package acl + +// TODO: add more specific errors + +import "fmt" + +var ( + ErrNotInitialized = fmt.Errorf("acl service is not initialized") + + ErrRoleNotFound = fmt.Errorf("role not found") + ErrRoleAlreadyExists = fmt.Errorf("role already exists") + ErrInvalidRoleName = fmt.Errorf("role name is invalid") + ErrSameRoleName = fmt.Errorf("role name is the same as another role") + ErrRoleInUse = fmt.Errorf("role is in use") + + ErrResourceNotFound = fmt.Errorf("resource not found") + ErrResourceAlreadyExists = fmt.Errorf("resource already exists") + ErrInvalidResourceKey = fmt.Errorf("invalid resource key") + ErrResourceInUse = fmt.Errorf("resource is in use") + ErrSameResourceKey = fmt.Errorf("resource key is the same as another resource") +) diff --git a/internal/acl/models.go b/internal/acl/models.go index b357c07..d2033a4 100644 --- a/internal/acl/models.go +++ b/internal/acl/models.go @@ -1,8 +1,8 @@ package acl type UserRole struct { - UserID uint `gorm:"primaryKey" json:"userId"` - RoleID uint `gorm:"primaryKey" json:"roleId"` + UserID uint `gorm:"index;not null;uniqueIndex:ux_user_role"` + RoleID uint `gorm:"index;not null;uniqueIndex:ux_user_role"` Role Role `gorm:"constraint:OnDelete:CASCADE;foreignKey:RoleID;references:ID" json:"role"` //User user.User `gorm:"constraint:OnDelete:CASCADE;foreignKey:UserID;references:ID"` diff --git a/internal/acl/resources.go b/internal/acl/resources.go new file mode 100644 index 0000000..4cc7b60 --- /dev/null +++ b/internal/acl/resources.go @@ -0,0 +1,140 @@ +package acl + +import ( + "errors" + "fmt" + "strings" + + "gorm.io/gorm" +) + +// GetResources returns all resources. +// May return [ErrNotInitialized] or db error. +func (s *Service) GetResources() ([]Resource, error) { + if !s.isInitialized() { + return nil, ErrNotInitialized + } + + var resources []Resource + if err := s.db.Order("id").Find(&resources).Error; err != nil { + return nil, fmt.Errorf("db error: %w", err) + } + + return resources, nil +} + +// CreateResource creates a new resource with the given key or returns existing one. +// Returns ID of created resource. +// May return [ErrNotInitialized], [ErrInvalidResourceKey], [ErrResourceAlreadyExists] or db error. +func (s *Service) CreateResource(key string) (uint, error) { + if !s.isInitialized() { + return 0, ErrNotInitialized + } + + key = strings.TrimSpace(key) + if key == "" { + return 0, ErrInvalidResourceKey + } + + var res Resource + if err := s.db.Where("key = ?", key).First(&res).Error; err == nil { + // already exists + return res.ID, ErrResourceAlreadyExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + // other db error + return 0, fmt.Errorf("db error: %w", err) + } + + res = Resource{Key: key} + if err := s.db.Create(&res).Error; err != nil { + return 0, fmt.Errorf("db error: %w", err) + } + + return res.ID, nil +} + +// GetResourceByID returns the resource with the given ID. +// May return [ErrNotInitialized], [ErrResourceNotFound] or db error. +func (s *Service) GetResourceByID(resourceID uint) (*Resource, error) { + if !s.isInitialized() { + return nil, ErrNotInitialized + } + + var res Resource + if err := s.db.First(&res, resourceID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("db error: %w", err) + } + + return &res, nil +} + +// UpdateResource updates the key of a resource. +// May return [ErrNotInitialized], [ErrInvalidResourceKey], [ErrResourceNotFound], [ErrSameResourceKey] or db error. +func (s *Service) UpdateResource(resourceID uint, newKey string) error { + if !s.isInitialized() { + return ErrNotInitialized + } + + newKey = strings.TrimSpace(newKey) + if newKey == "" { + return ErrInvalidResourceKey + } + + var res Resource + if err := s.db.First(&res, resourceID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrResourceNotFound + } + return fmt.Errorf("db error: %w", err) + } + + // same key? + if res.Key == newKey { + return ErrSameResourceKey + } + + // check if key used by another resource + var count int64 + if err := s.db.Model(&Resource{}). + Where("key = ? AND id != ?", newKey, resourceID). + Count(&count).Error; err != nil { + return fmt.Errorf("db error: %w", err) + } + + if count > 0 { + return ErrSameResourceKey + } + + res.Key = newKey + if err := s.db.Save(&res).Error; err != nil { + return fmt.Errorf("failed to update resource: %w", err) + } + + return nil +} + +// DeleteResource deletes a resource. +// May return [ErrNotInitialized], [ErrResourceNotFound], [ErrResourceInUse] or db error. +func (s *Service) DeleteResource(resourceID uint) error { + if !s.isInitialized() { + return ErrNotInitialized + } + + result := s.db.Delete(&Resource{}, resourceID) + + if err := result.Error; err != nil { + if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + return ErrResourceInUse + } + return fmt.Errorf("db error: %w", err) + } + + if result.RowsAffected == 0 { + return ErrResourceNotFound + } + + return nil +} diff --git a/internal/acl/roles.go b/internal/acl/roles.go new file mode 100644 index 0000000..8404d38 --- /dev/null +++ b/internal/acl/roles.go @@ -0,0 +1,136 @@ +package acl + +import ( + "errors" + "fmt" + "strings" + + "gorm.io/gorm" +) + +// GetRoles returns all roles. +// May return [ErrNotInitialized] or db error. +func (s *Service) GetRoles() ([]Role, error) { + if !s.isInitialized() { + return nil, ErrNotInitialized + } + + var roles []Role + if err := s.db.Preload("Resources").Order("id").Find(&roles).Error; err != nil { + return nil, fmt.Errorf("db error: %w", err) + } + + return roles, nil +} + +// CreateRole creates a new role with the given name or returns existing one. +// Returns the ID of the created role. +// May return [ErrNotInitialized], [ErrInvalidRoleName], [ErrRoleAlreadyExists] or db error. +func (s *Service) CreateRole(name string) (uint, error) { + if !s.isInitialized() { + return 0, ErrNotInitialized + } + + name = strings.TrimSpace(name) + if name == "" { + return 0, ErrInvalidRoleName + } + + var role Role + if err := s.db.Where("name = ?", name).First(&role).Error; err == nil { + // already exists + return role.ID, ErrRoleAlreadyExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + // other database error + return 0, fmt.Errorf("db error: %w", err) + } + + role = Role{Name: name} + if err := s.db.Create(&role).Error; err != nil { + return 0, fmt.Errorf("db error: %w", err) + } + + return role.ID, nil +} + +// GetRoleByID returns the role with the given ID or an error. +// May return [ErrNotInitialized], [ErrRoleNotFound] or db error. +func (s *Service) GetRoleByID(roleID uint) (*Role, error) { + if !s.isInitialized() { + return nil, ErrNotInitialized + } + var role Role + err := s.db.Preload("Resources").First(&role, roleID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrRoleNotFound + } + return nil, fmt.Errorf("db error: %w", err) + } + + return &role, nil +} + +// UpdateRole updates the name of a role. +// May return [ErrNotInitialized], [ErrInvalidRoleName], [ErrRoleNotFound], [ErrSameRoleName], or db error. +func (s *Service) UpdateRole(roleID uint, newName string) error { + if !s.isInitialized() { + return ErrNotInitialized + } + + newName = strings.TrimSpace(newName) + if newName == "" { + return ErrInvalidRoleName + } + + var role Role + err := s.db.First(&role, roleID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrRoleNotFound + } + return fmt.Errorf("db error: %w", err) + } + + // check for name conflicts + if role.Name == newName { + return ErrSameRoleName + } + var count int64 + err = s.db.Model(&Role{}).Where("name = ? AND id != ?", newName, roleID).Count(&count).Error + if err != nil { + return fmt.Errorf("db error: %w", err) + } + if count > 0 { + return ErrSameRoleName + } + + role.Name = newName + if err := s.db.Save(&role).Error; err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + + return nil +} + +// DeleteRole deletes a role. +// May return [ErrNotInitialized], [ErrRoleNotFound], [ErrRoleInUse] or db error. +func (s *Service) DeleteRole(roleID uint) error { + if !s.isInitialized() { + return ErrNotInitialized + } + + result := s.db.Delete(&Role{}, roleID) + if err := result.Error; err != nil { + if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + return ErrRoleInUse + } + return fmt.Errorf("db error: %w", err) + } + + if result.RowsAffected == 0 { + return ErrRoleNotFound + } + + return nil +} diff --git a/internal/acl/service.go b/internal/acl/service.go index d0d633f..17dfba7 100644 --- a/internal/acl/service.go +++ b/internal/acl/service.go @@ -1,6 +1,7 @@ package acl import ( + "errors" "fmt" "gorm.io/gorm" @@ -40,30 +41,14 @@ func (s *Service) Init() error { return nil } -// Admin crud functions +// Admin crud functions // -// CreateRole creates a new role with the given name -func (s *Service) CreateRole(name string) error { - if !s.isInitialized() { - return fmt.Errorf("acl service is not initialized") - } - role := Role{Name: name} - return s.db.FirstOrCreate(&role, &Role{Name: name}).Error -} - -// CreateResource creates a new resource with the given key -func (s *Service) CreateResource(key string) error { - if !s.isInitialized() { - return fmt.Errorf("acl service is not initialized") - } - res := Resource{Key: key} - return s.db.FirstOrCreate(&res, &Resource{Key: key}).Error -} +// Resources // AssignResourceToRole assigns a resource to a role func (s *Service) AssignResourceToRole(roleID, resourceID uint) error { if !s.isInitialized() { - return fmt.Errorf("acl service is not initialized") + return ErrNotInitialized } rr := RoleResource{ RoleID: roleID, @@ -75,19 +60,25 @@ func (s *Service) AssignResourceToRole(roleID, resourceID uint) error { // AssignRoleToUser assigns a role to a user func (s *Service) AssignRoleToUser(roleID, userID uint) error { if !s.isInitialized() { - return fmt.Errorf("acl service is not initialized") + return ErrNotInitialized } ur := UserRole{ UserID: userID, RoleID: roleID, } - return s.db.FirstOrCreate(&ur, UserRole{UserID: userID, RoleID: roleID}).Error + if err := s.db.Create(&ur).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("role already assigned to user") + } + return err + } + return nil } // RemoveResourceFromRole removes a resource from a role func (s *Service) RemoveResourceFromRole(roleID, resourceID uint) error { if !s.isInitialized() { - return fmt.Errorf("acl service is not initialized") + return ErrNotInitialized } return s.db.Where("role_id = ? AND resource_id = ?", roleID, resourceID).Delete(&RoleResource{}).Error } @@ -95,35 +86,15 @@ func (s *Service) RemoveResourceFromRole(roleID, resourceID uint) error { // RemoveRoleFromUser removes a role from a user func (s *Service) RemoveRoleFromUser(roleID, userID uint) error { if !s.isInitialized() { - return fmt.Errorf("acl service is not initialized") + return ErrNotInitialized } return s.db.Where("role_id = ? AND user_id = ?", roleID, userID).Delete(&UserRole{}).Error } -// GetRoles returns all roles -func (s *Service) GetRoles() ([]Role, error) { - if !s.isInitialized() { - return nil, fmt.Errorf("acl service is not initialized") - } - var roles []Role - err := s.db.Preload("Resources").Order("id").Find(&roles).Error - return roles, err -} - -// GetPermissions returns all permissions -func (s *Service) GetPermissions() ([]Resource, error) { - if !s.isInitialized() { - return nil, fmt.Errorf("acl service is not initialized") - } - var resources []Resource - err := s.db.Order("id").Find(&resources).Error - return resources, err -} - // GetRoleResources returns all resources for a given role func (s *Service) GetRoleResources(roleID uint) ([]Resource, error) { if !s.isInitialized() { - return nil, fmt.Errorf("acl service is not initialized") + return nil, ErrNotInitialized } var resources []Resource err := s.db.Joins("JOIN role_resources rr ON rr.resource_id = resources.id"). @@ -134,7 +105,7 @@ func (s *Service) GetRoleResources(roleID uint) ([]Resource, error) { // GetUserRoles returns all roles for a given user func (s *Service) GetUserRoles(userID uint) ([]Role, error) { if !s.isInitialized() { - return nil, fmt.Errorf("acl service is not initialized") + return nil, ErrNotInitialized } var roles []Role err := s.db.Joins("JOIN user_roles ur ON ur.role_id = roles.id"). diff --git a/internal/acl_test/crud_test.go b/internal/acl_test/crud_test.go index 274633c..c268f40 100644 --- a/internal/acl_test/crud_test.go +++ b/internal/acl_test/crud_test.go @@ -1,156 +1,158 @@ package acl_test -import ( - "os" - "path/filepath" - "testing" +// DEPRECATED TEST FILE - "git.oblat.lv/alex/triggerssmith/internal/acl" - "git.oblat.lv/alex/triggerssmith/internal/user" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) +// import ( +// "os" +// "path/filepath" +// "testing" -func openTestDB(t *testing.T) *gorm.DB { - t.Helper() +// "git.oblat.lv/alex/triggerssmith/internal/acl" +// "git.oblat.lv/alex/triggerssmith/internal/user" +// "gorm.io/driver/sqlite" +// "gorm.io/gorm" +// ) - // Путь к файлу базы - dbPath := filepath.Join("testdata", "test.db") +// func openTestDB(t *testing.T) *gorm.DB { +// t.Helper() - // Удаляем старую базу, если есть - os.Remove(dbPath) +// // Путь к файлу базы +// dbPath := filepath.Join("testdata", "test.db") - db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) - if err != nil { - t.Fatalf("failed to open test db: %v", err) - } +// // Удаляем старую базу, если есть +// os.Remove(dbPath) - // Миграция таблицы User для связи с ACL - if err := db.AutoMigrate(&user.User{}); err != nil { - t.Fatalf("failed to migrate User: %v", err) - } +// db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) +// if err != nil { +// t.Fatalf("failed to open test db: %v", err) +// } - return db -} +// // Миграция таблицы User для связи с ACL +// if err := db.AutoMigrate(&user.User{}); err != nil { +// t.Fatalf("failed to migrate User: %v", err) +// } -func TestACLService_CRUD(t *testing.T) { - db := openTestDB(t) +// return db +// } - // Создаём сервис ACL - svc, err := acl.NewService(db) - if err != nil { - t.Fatalf("failed to create ACL service: %v", err) - } +// func TestACLService_CRUD(t *testing.T) { +// db := openTestDB(t) - if err := svc.Init(); err != nil { - t.Fatalf("failed to init ACL service: %v", err) - } +// // Создаём сервис ACL +// svc, err := acl.NewService(db) +// if err != nil { +// t.Fatalf("failed to create ACL service: %v", err) +// } - // Создаём роли - if err := svc.CreateRole("admin"); err != nil { - t.Fatalf("CreateRole failed: %v", err) - } - if err := svc.CreateRole("guest"); err != nil { - t.Fatalf("CreateRole failed: %v", err) - } +// if err := svc.Init(); err != nil { +// t.Fatalf("failed to init ACL service: %v", err) +// } - roles, err := svc.GetRoles() - if err != nil { - t.Fatalf("GetRoles failed: %v", err) - } - if len(roles) != 2 { - t.Fatalf("expected 2 roles, got %d", len(roles)) - } +// // Создаём роли +// if err := svc.CreateRole("admin"); err != nil { +// t.Fatalf("CreateRole failed: %v", err) +// } +// if err := svc.CreateRole("guest"); err != nil { +// t.Fatalf("CreateRole failed: %v", err) +// } - // Создаём ресурсы - if err := svc.CreateResource("*"); err != nil { - t.Fatalf("CreateResource failed: %v", err) - } - if err := svc.CreateResource("html.view.*"); err != nil { - t.Fatalf("CreateResource failed: %v", err) - } +// roles, err := svc.GetRoles() +// if err != nil { +// t.Fatalf("GetRoles failed: %v", err) +// } +// if len(roles) != 2 { +// t.Fatalf("expected 2 roles, got %d", len(roles)) +// } - resources, err := svc.GetPermissions() - if err != nil { - t.Fatalf("GetPermissions failed: %v", err) - } - if len(resources) != 2 { - t.Fatalf("expected 2 resources, got %d", len(resources)) - } +// // Создаём ресурсы +// if err := svc.CreateResource("*"); err != nil { +// t.Fatalf("CreateResource failed: %v", err) +// } +// if err := svc.CreateResource("html.view.*"); err != nil { +// t.Fatalf("CreateResource failed: %v", err) +// } - // 1. Создаём сервис user - store, err := user.NewGormUserStore(db) - if err != nil { - t.Fatalf("failed to create user store: %v", err) - } - userSvc, err := user.NewService(store) - if err != nil { - t.Fatalf("failed to create user service: %v", err) - } +// resources, err := svc.GetPermissions() +// if err != nil { +// t.Fatalf("GetPermissions failed: %v", err) +// } +// if len(resources) != 2 { +// t.Fatalf("expected 2 resources, got %d", len(resources)) +// } - // 2. Инициализируем - if err := userSvc.Init(); err != nil { - t.Fatalf("failed to init user service: %v", err) - } +// // 1. Создаём сервис user +// store, err := user.NewGormUserStore(db) +// if err != nil { +// t.Fatalf("failed to create user store: %v", err) +// } +// userSvc, err := user.NewService(store) +// if err != nil { +// t.Fatalf("failed to create user service: %v", err) +// } - user := &user.User{ - Username: "testuser", - Email: "testuser@example.com", - Password: "secret", - } +// // 2. Инициализируем +// if err := userSvc.Init(); err != nil { +// t.Fatalf("failed to init user service: %v", err) +// } - u := user +// user := &user.User{ +// Username: "testuser", +// Email: "testuser@example.com", +// Password: "secret", +// } - // 3. Создаём пользователя через сервис - err = userSvc.Create(user) - if err != nil { - t.Fatalf("failed to create user: %v", err) - } +// u := user - // Привязываем роль к пользователю - adminRoleID := roles[0].ID - if err := svc.AssignRoleToUser(adminRoleID, uint(u.ID)); err != nil { - t.Fatalf("AssignRoleToUser failed: %v", err) - } +// // 3. Создаём пользователя через сервис +// err = userSvc.Create(user) +// if err != nil { +// t.Fatalf("failed to create user: %v", err) +// } - userRoles, err := svc.GetUserRoles(uint(u.ID)) - if err != nil { - t.Fatalf("GetUserRoles failed: %v", err) - } - if len(userRoles) != 1 || userRoles[0].ID != adminRoleID { - t.Fatalf("expected user to have admin role") - } +// // Привязываем роль к пользователю +// adminRoleID := roles[0].ID +// if err := svc.AssignRoleToUser(adminRoleID, uint(u.ID)); err != nil { +// t.Fatalf("AssignRoleToUser failed: %v", err) +// } - // Привязываем ресурсы к роли - for _, res := range resources { - if err := svc.AssignResourceToRole(adminRoleID, res.ID); err != nil { - t.Fatalf("AssignResourceToRole failed: %v", err) - } - } +// userRoles, err := svc.GetUserRoles(uint(u.ID)) +// if err != nil { +// t.Fatalf("GetUserRoles failed: %v", err) +// } +// if len(userRoles) != 1 || userRoles[0].ID != adminRoleID { +// t.Fatalf("expected user to have admin role") +// } - roleResources, err := svc.GetRoleResources(adminRoleID) - if err != nil { - t.Fatalf("GetRoleResources failed: %v", err) - } - if len(roleResources) != 2 { - t.Fatalf("expected role to have 2 resources") - } +// // Привязываем ресурсы к роли +// for _, res := range resources { +// if err := svc.AssignResourceToRole(adminRoleID, res.ID); err != nil { +// t.Fatalf("AssignResourceToRole failed: %v", err) +// } +// } - // Удаляем ресурс из роли - if err := svc.RemoveResourceFromRole(adminRoleID, resources[0].ID); err != nil { - t.Fatalf("RemoveResourceFromRole failed: %v", err) - } - roleResources, _ = svc.GetRoleResources(adminRoleID) - if len(roleResources) != 1 { - t.Fatalf("expected 1 resource after removal") - } +// roleResources, err := svc.GetRoleResources(adminRoleID) +// if err != nil { +// t.Fatalf("GetRoleResources failed: %v", err) +// } +// if len(roleResources) != 2 { +// t.Fatalf("expected role to have 2 resources") +// } - // Удаляем роль у пользователя - if err := svc.RemoveRoleFromUser(adminRoleID, uint(u.ID)); err != nil { - t.Fatalf("RemoveRoleFromUser failed: %v", err) - } - userRoles, _ = svc.GetUserRoles(uint(u.ID)) - if len(userRoles) != 0 { - t.Fatalf("expected user to have 0 roles after removal") - } -} +// // Удаляем ресурс из роли +// if err := svc.RemoveResourceFromRole(adminRoleID, resources[0].ID); err != nil { +// t.Fatalf("RemoveResourceFromRole failed: %v", err) +// } +// roleResources, _ = svc.GetRoleResources(adminRoleID) +// if len(roleResources) != 1 { +// t.Fatalf("expected 1 resource after removal") +// } + +// // Удаляем роль у пользователя +// if err := svc.RemoveRoleFromUser(adminRoleID, uint(u.ID)); err != nil { +// t.Fatalf("RemoveRoleFromUser failed: %v", err) +// } +// userRoles, _ = svc.GetUserRoles(uint(u.ID)) +// if len(userRoles) != 0 { +// t.Fatalf("expected user to have 0 roles after removal") +// } +// } diff --git a/internal/server/error.go b/internal/server/error.go new file mode 100644 index 0000000..83876c9 --- /dev/null +++ b/internal/server/error.go @@ -0,0 +1,20 @@ +package server + +import ( + "encoding/json" + "net/http" +) + +type ErrorResponse struct { + Error string `json:"error"` + Details string `json:"details,omitempty"` +} + +func WriteError(w http.ResponseWriter, error, details string, statusCode int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(ErrorResponse{ + Error: error, + Details: details, + }) +} diff --git a/internal/user/model.go b/internal/user/model.go index 4037d79..f7fcb71 100644 --- a/internal/user/model.go +++ b/internal/user/model.go @@ -6,7 +6,7 @@ import ( ) type User struct { - ID int64 `gorm:"primaryKey"` + ID uint `gorm:"primaryKey"` Username string `gorm:"uniqueIndex;not null"` Email string `gorm:"uniqueIndex;not null"` Password string `gorm:"not null"` diff --git a/internal/user/user_test.go b/internal/user/user_test.go index fc46e21..242557e 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -1,84 +1,86 @@ package user -import ( - "os" - "path/filepath" - "testing" +// DEPRECATED TEST FILE - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) +// import ( +// "os" +// "path/filepath" +// "testing" -func setupTestDB(t *testing.T) *gorm.DB { - t.Helper() +// "gorm.io/driver/sqlite" +// "gorm.io/gorm" +// ) - dbPath := filepath.Join("testdata", "users.db") +// func setupTestDB(t *testing.T) *gorm.DB { +// t.Helper() - _ = os.Remove(dbPath) +// dbPath := filepath.Join("testdata", "users.db") - db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) - if err != nil { - t.Fatalf("failed to open db: %v", err) - } +// _ = os.Remove(dbPath) - if err := db.AutoMigrate(&User{}); err != nil { - t.Fatalf("failed to migrate: %v", err) - } +// db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) +// if err != nil { +// t.Fatalf("failed to open db: %v", err) +// } - return db -} +// if err := db.AutoMigrate(&User{}); err != nil { +// t.Fatalf("failed to migrate: %v", err) +// } -func TestUsersCRUD(t *testing.T) { - db := setupTestDB(t) +// return db +// } - store, err := NewGormUserStore(db) - if err != nil { - t.Fatalf("failed to create store: %v", err) - } +// func TestUsersCRUD(t *testing.T) { +// db := setupTestDB(t) - service, err := NewService(store) - if err != nil { - t.Fatalf("failed to create service: %v", err) - } +// store, err := NewGormUserStore(db) +// if err != nil { +// t.Fatalf("failed to create store: %v", err) +// } - user := &User{ - Username: "testuser", - Email: "test@example.com", - Password: "password123", - } +// service, err := NewService(store) +// if err != nil { +// t.Fatalf("failed to create service: %v", err) +// } - if err := service.Create(user); err != nil { - t.Fatalf("failed to create user: %v", err) - } - // retrieved, err := service.GetByID(user.ID) - // if err != nil { - // t.Fatalf("failed to get user by ID: %v", err) - // } - // if retrieved.Username != user.Username { - // t.Fatalf("expected username %s, got %s", user.Username, retrieved.Username) - // } +// user := &User{ +// Username: "testuser", +// Email: "test@example.com", +// Password: "password123", +// } - // retrievedByUsername, err := service.GetByUsername(user.Username) - // if err != nil { - // t.Fatalf("failed to get user by username: %v", err) - // } - // if retrievedByUsername.Email != user.Email { - // t.Fatalf("expected email %s, got %s", user.Email, retrievedByUsername.Email) - // } +// if err := service.Create(user); err != nil { +// t.Fatalf("failed to create user: %v", err) +// } +// // retrieved, err := service.GetByID(user.ID) +// // if err != nil { +// // t.Fatalf("failed to get user by ID: %v", err) +// // } +// // if retrieved.Username != user.Username { +// // t.Fatalf("expected username %s, got %s", user.Username, retrieved.Username) +// // } - // user.Email = "newemail@example.com" - // if err := service.Update(user); err != nil { - // t.Fatalf("failed to update user: %v", err) - // } - // retrieved, err = service.GetByID(user.ID) - // if err != nil { - // t.Fatalf("failed to get user by ID: %v", err) - // } - // if retrieved.Email != user.Email { - // t.Fatalf("expected email %s, got %s", user.Email, retrieved.Email) - // } - err = service.Delete(user.ID) - if err != nil { - t.Fatalf("failed to delete user: %v", err) - } -} +// // retrievedByUsername, err := service.GetByUsername(user.Username) +// // if err != nil { +// // t.Fatalf("failed to get user by username: %v", err) +// // } +// // if retrievedByUsername.Email != user.Email { +// // t.Fatalf("expected email %s, got %s", user.Email, retrievedByUsername.Email) +// // } + +// // user.Email = "newemail@example.com" +// // if err := service.Update(user); err != nil { +// // t.Fatalf("failed to update user: %v", err) +// // } +// // retrieved, err = service.GetByID(user.ID) +// // if err != nil { +// // t.Fatalf("failed to get user by ID: %v", err) +// // } +// // if retrieved.Email != user.Email { +// // t.Fatalf("expected email %s, got %s", user.Email, retrieved.Email) +// // } +// err = service.Delete(user.ID) +// if err != nil { +// t.Fatalf("failed to delete user: %v", err) +// } +// }