feat: custom content type icons, built-in types, user lookup

This commit is contained in:
Florian Sylvain committed 2026-10-19 18:22:55 +02:00
1 parent e6e088c963
commit 73a75d5cb9
31 files changed
+1611 -36

No files matched your search

+6
View File
@@ -70,6 +70,9 @@ type UseCases struct {
GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
CreateEntryUseCase *useCases.CreateEntryUseCase
GetEntryUseCase *useCases.GetEntryUseCase
ListEntriesUseCase *useCases.ListEntriesUseCase
@@ -366,6 +369,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo),
@@ -0,0 +1,38 @@
package builtintype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domain "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListBuiltinTypesUseCase
GetUseCase *useCases.GetBuiltinTypeUseCase
}
func NewHandler(list *useCases.ListBuiltinTypesUseCase, get *useCases.GetBuiltinTypeUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, http.StatusOK, h.ListUseCase.List())
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if errors.Is(err, domain.ErrNotFound) {
http.Error(w, apperr.BuiltinTypeNotFoundErrorMessage, http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
response.WriteJSON(w, http.StatusOK, found)
}
+37 -8
View File
@@ -26,15 +26,35 @@ func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListCo
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type iconInput struct {
value *string
invalid bool
}
func (i *iconInput) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
i.invalid = true
return nil
}
i.value = &text
return nil
}
type PostContentType struct {
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type PutContentType struct {
Key string `json:"key"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
@@ -42,23 +62,22 @@ type contentTypeResponse struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Icon *string `json:"icon"`
Fields []domain.Field `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toResponse(contentType domain.ContentType) contentTypeResponse {
fields := make([]domain.Field, 0, len(contentType.Fields))
for _, field := range contentType.Fields {
if field.Options == nil {
field.Options = []string{}
}
fields = append(fields, field)
fields := contentType.Fields
if fields == nil {
fields = []domain.Field{}
}
return contentTypeResponse{
ID: contentType.ID,
Key: contentType.Key,
Name: contentType.Name,
Icon: contentType.Icon,
Fields: fields,
CreatedAt: contentType.CreatedAt,
UpdatedAt: contentType.UpdatedAt,
@@ -77,6 +96,8 @@ func writeContentTypeError(w http.ResponseWriter, err error) {
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidIcon):
http.Error(w, apperr.InvalidIconErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidKey):
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidField):
@@ -118,7 +139,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Fields: request.Fields})
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
@@ -136,7 +161,11 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Fields: request.Fields})
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
+66
View File
@@ -0,0 +1,66 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"strings"
)
type LookupHandler struct {
LookupUseCase *useCases.LookupUserNamesUseCase
}
type userNameResponse struct {
ID uint32 `json:"id"`
DisplayName string `json:"display_name"`
}
func NewLookupHandler(lookup *useCases.LookupUserNamesUseCase) *LookupHandler {
return &LookupHandler{LookupUseCase: lookup}
}
func parseLookupIDs(raw string) ([]uint32, bool) {
if raw == "" {
return nil, false
}
if strings.Count(raw, ",")+1 > domainUser.MaxLookupIDs {
return nil, false
}
parts := strings.Split(raw, ",")
ids := make([]uint32, 0, len(parts))
for _, part := range parts {
id, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, false
}
ids = append(ids, uint32(id))
}
return ids, true
}
func (h *LookupHandler) Lookup(w http.ResponseWriter, r *http.Request) {
ids, ok := parseLookupIDs(r.URL.Query().Get("ids"))
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
names, err := h.LookupUseCase.Lookup(ids)
if errors.Is(err, domainUser.ErrInvalidLookup) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]userNameResponse, 0, len(names))
for _, name := range names {
items = append(items, userNameResponse{ID: name.ID, DisplayName: name.DisplayName})
}
response.WriteJSON(w, http.StatusOK, items)
}
+2
View File
@@ -91,6 +91,8 @@ const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
const InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found"
+22
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/author"
"RenewCMS/api/handlers/builtintype"
"RenewCMS/api/handlers/contenttype"
"RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend"
@@ -133,6 +134,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
r.Use(jwtauth.Verifier(container.Token))
@@ -143,6 +145,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers)
r.Get("/lookup", lookupHandler.Lookup)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
@@ -276,6 +279,24 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
return r
}
func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/", builtinTypeHandler.List)
r.Get("/{key}", builtinTypeHandler.Get)
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
@@ -427,6 +448,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
+6
View File
@@ -2,6 +2,12 @@
A content type is a named schema (a key, a name and 1 to 50 fields) stored as one row with the fields as a JSON text column. Entries of every type share one generic table whose `data` column holds a JSON object. No DDL runs at runtime, so creating or changing a type never alters the database schema.
## Icon, built-in types and user lookup
- A content type has an optional `icon`: `null` or a string matching `^[a-z][a-z0-9-]{0,39}$`, validated in the domain (`ErrInvalidIcon`, `400 invalid_icon`) and never checked against a list of known icons. `""` is stored as `null`. `PUT` replaces the icon like the name and the fields, so an absent icon clears it. The column is nullable, so rows stored before it existed read as `null`.
- `GET /v1/builtin-type` and `GET /v1/builtin-type/{key}` expose a static, read-only catalogue (`internal/domain/builtintype`) of the built-in types `article`, `author`, `site`, `media` and `user` as `key`, `name` and `fields` shaped like content type fields. Owners and admins only. It uses no repository; an unknown key is `404 builtin_type_not_found`. A test checks that every catalogue field key exists in the JSON of the matching entity.
- `GET /v1/user/lookup?ids=1,2,3` returns `[{id, display_name}]` for any verified user, ordered by id, with the username when the display name is empty. 1 to 100 distinct uint32 ids; anything else is `400`. Unknown ids are omitted.
## Layering
The domain packages `internal/domain/contenttype` and `internal/domain/entry` own the rules: key, name and field validation, entry validation against a schema, the sort whitelist, the page limits and the value equality used by uniqueness. The use cases orchestrate them through the two repositories. The repositories only store and load rows. Entry counting for the "type still has entries" rule lives on the entry repository.
+2 -2
View File
@@ -35,7 +35,7 @@ Feature: Owners and admins define custom content types, and users manage entries
AuditContext, RequireSignedIn, jwtauth.Authenticator, RequireCurrentToken and
RequireVerifiedUser, and POST /v1/content-type, PUT /v1/content-type/{key} and
DELETE /v1/content-type/{key} additionally sit behind RequireOwnerOrAdmin
And a content type response has exactly the keys id, key, name, fields, created_at, updated_at
And a content type response has exactly the keys id, key, name, icon, fields, created_at, updated_at
And a field response has exactly the keys key, label, type, required, unique, default, min, max,
options, with null for an unset default, min and max and [] for no options
And an entry response has exactly the keys id, content_type, data, created_by, updated_by,
@@ -58,7 +58,7 @@ Feature: Owners and admins define custom content types, and users manage entries
When the client sends POST /v1/content-type with body
{"key": "recipe", "name": "Recipe", "fields": [<the sample fields>]}
Then the response status is 201
And the body has exactly the keys id, key, name, fields, created_at, updated_at
And the body has exactly the keys id, key, name, icon, fields, created_at, updated_at
And key is "recipe" and the fields come back in the order sent
And each field has exactly the keys key, label, type, required, unique, default, min, max, options
+1 -1
View File
@@ -21,7 +21,7 @@ Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
6. Helper `sampleRecipe()` builds the "recipe" request body from the feature's table.
## Content type create and validation
7. Admin 2 `POST /v1/content-type` with the recipe -> `201`; key set is exactly `id`, `key`, `name`,
7. Admin 2 `POST /v1/content-type` with the recipe -> `201`; key set is exactly `id`, `key`, `name`, `icon`,
`fields`, `created_at`, `updated_at`; each field has exactly `key`, `label`, `type`, `required`,
`unique`, `default`, `min`, `max`, `options`; order preserved; `default`/`min`/`max` are JSON `null`
and `options` is `[]` when unset.
@@ -0,0 +1,235 @@
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names
As an owner, admin or verified user working in the SPA
I want an icon per content type, a read-only view of the built-in types and the display names of entry authors
So that the sidebar, the Schema screen and the entry screens can be drawn without leaking account data
Background:
Given the existing content type contract of custom_content_types.feature is unchanged except
that a content type response now has exactly the keys id, key, name, icon, fields, created_at,
updated_at
And a content type icon is null or a string matching ^[a-z][a-z0-9-]{0,39}$ and it is never
checked against a list of known icons
And internal/domain/contenttype defines the sentinel ErrInvalidIcon and the apperr string is
invalid_icon
And the icon is stored in a nullable column added by AutoMigrate, so existing rows read as null,
and the fields column stays a JSON text column
And an empty icon "" is stored as null
And a PUT replaces the icon like it replaces the name and the fields, so an absent icon clears it
And internal/domain/builtintype defines the static catalogue of the built-in types article,
author, site, media and user, in this order, each with a name and a list of fields shaped like
a content type field (key, label, type, required, unique, default, min, max, options)
And the catalogue uses no repository and no database, and the use cases ListBuiltinTypes and
GetBuiltinType depend only on that package
And the routes GET /v1/builtin-type and GET /v1/builtin-type/{key} are mounted in a router with
jwtauth.Verifier, apiKeyAuth, AuditContext, RequireSignedIn, jwtauth.Authenticator,
RequireCurrentToken, RequireVerifiedUser and RequireOwnerOrAdmin
And a built-in type response has exactly the keys key, name, fields
And the new apperr string is builtin_type_not_found
And the route GET /v1/user/lookup is mounted on the existing user router behind the verified
user chain with no role requirement
And a lookup item has exactly the keys id and display_name
And users: owner id 1 "marta" with display name "Marta Alves", admin id 2 "dev" with no
display name, verified id 3 "sam" with display name "Sam Roy", unverified id 4 "quinn"
And only the roles owner and admin exist, so every verified user passes RequireOwnerOrAdmin
And errors are plain text from http.Error
Scenario: Creating a content type with an icon
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with the recipe body and "icon": "chef-hat"
Then the response status is 201
And the body has exactly the keys id, key, name, icon, fields, created_at, updated_at
And icon is "chef-hat"
And GET /v1/content-type/recipe and GET /v1/content-type both show icon "chef-hat"
Scenario Outline: A content type without an icon has a null icon
Given a valid jwt cookie for admin 2
When the client creates "recipe" with <icon>
Then the response status is 201 and icon is null in the response, in GET /v1/content-type/recipe and in the list
Examples:
| icon |
| no icon key |
| "icon": null |
| "icon": "" |
Scenario: A content type stored before this slice still works
Given the content type "recipe" was stored with no icon value
When a verified user sends GET /v1/content-type and GET /v1/content-type/recipe
Then the response status is 200 and icon is null and all other keys are unchanged
Scenario Outline: An invalid icon is refused and stores nothing
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with a valid body and "icon": <icon>
Then the response status is 400
And the response body is invalid_icon
And no content type exists in the repository
Examples:
| icon |
| "Chef-Hat" |
| "1star" |
| "-star" |
| "chef hat" |
| "chef_hat" |
| "<41 characters>" |
| 5 |
Scenario: Boundary icons are accepted
Given a valid jwt cookie for admin 2
When the client creates types with the icons "a" and a 40 character icon "a" followed by 39 "b"
Then each response status is 201
Scenario Outline: Updating the icon of a content type
Given the content type "recipe" exists with the icon "chef-hat" and has an entry
When admin 2 sends PUT /v1/content-type/recipe with the same fields and <icon>
Then the response status is <status>
And <outcome>
Examples:
| icon | status | outcome |
| "icon": "utensils" | 200 | icon is "utensils" |
| no icon key | 200 | icon is null |
| "icon": null | 200 | icon is null |
| "icon": "Bad Icon" | 400 | the body is invalid_icon and the icon is still "chef-hat" |
Scenario: The icon does not affect entries
Given the content type "recipe" has an entry and admin 2 changes only the icon
Then the response status is 200 and the entry data and its updated_at are unchanged
Scenario: Listing the built-in types
Given a valid jwt cookie for owner 1
When the client sends GET /v1/builtin-type
Then the response status is 200
And the body is a JSON array of 5 items with the keys article, author, site, media, user in this order
And each item has exactly the keys key, name, fields
And the names are "Articles", "Authors", "Sites", "Media" and "Users"
And every field has exactly the keys key, label, type, required, unique, default, min, max, options
And every field type is one of text, richtext, number, boolean, date, select
Scenario Outline: The built-in field lists match the real API
Given a valid jwt cookie for admin 2
When the client sends GET /v1/builtin-type/<key>
Then the response status is 200
And the field keys, in order, are <fields>
And no field key is one of id, created_at, updated_at, password, password_reset_code, verification_code, token_version
Examples:
| key | fields |
| article | title, body, slug, excerpt, seo_title, seo_description, is_online, canonical_url, noindex |
| author | name, slug, bio, website |
| site | name, key, base_url, default_robots, locale, is_default, site_name, title_template, default_description |
| media | filename, size, width, height, content_type |
| user | username, display_name, email, role |
Scenario Outline: Selected built-in field rules
Given a valid jwt cookie for admin 2
When the client sends GET /v1/builtin-type/<key>
Then the field <field> has type <type>, required <required>, min <min>, max <max> and options <options>
Examples:
| key | field | type | required | min | max | options |
| article | title | text | true | 3 | 200 | [] |
| article | body | richtext | false | null | null | [] |
| article | excerpt | text | false | null | 300 | [] |
| article | is_online | boolean | false | null | null | [] |
| site | default_robots | select | false | null | null | index, noindex |
| site | site_name | text | false | null | 100 | [] |
| site | title_template | text | false | null | 200 | [] |
| site | default_description | text | false | null | 320 | [] |
| user | role | select | true | null | null | owner, admin |
| user | email | text | true | null | null | [] |
Scenario: Reading one built-in type does not collide with a custom type of the same key
Given the custom content type "builtin" exists, and a custom content type "article" exists
And a valid jwt cookie for admin 2
When the client sends GET /v1/content-type/builtin and GET /v1/content-type/article
Then each response is the custom content type
When the client sends GET /v1/builtin-type/article
Then the response is the built-in article type
And GET /v1/content-type never lists a built-in type
Scenario Outline: The built-in routes are read-only and restricted
Given <caller>
When the client sends <method> <path>
Then the response status is <status>
And the response body is <message>
Examples:
| caller | method | path | status | message |
| owner 1 cookie | GET | /v1/builtin-type | 200 | |
| admin 2 cookie | GET | /v1/builtin-type/user | 200 | |
| an active API key of admin 2 | GET | /v1/builtin-type | 200 | |
| a cookie for unverified 4 | GET | /v1/builtin-type | 403 | apperr.VerificationRequiredErrorMessage |
| no credentials | GET | /v1/builtin-type | 401 | the existing Authenticator response |
| a stale token_version cookie | GET | /v1/builtin-type | 401 | the existing Authenticator response |
| admin 2 cookie | GET | /v1/builtin-type/nope | 404 | builtin_type_not_found |
| admin 2 cookie | GET | /v1/builtin-type/Article | 404 | builtin_type_not_found |
| admin 2 cookie | POST | /v1/builtin-type | 405 | empty |
| admin 2 cookie | PUT | /v1/builtin-type/article | 405 | empty |
| admin 2 cookie | DELETE | /v1/builtin-type/article | 405 | empty |
Scenario: Reading the built-in types never touches data
Given fake repositories for content types, entries, articles, authors, sites, media and users
When the client sends GET /v1/builtin-type and GET /v1/builtin-type/site
Then no repository method is called
Scenario: Any verified user resolves display names
Given a valid jwt cookie for verified user 3
When the client sends GET /v1/user/lookup?ids=1,2,3
Then the response status is 200
And the body is a JSON array of 3 items ordered by id
And each item has exactly the keys id and display_name
And the display names are "Marta Alves", "dev" (the username, because the display name is empty) and "Sam Roy"
And the body contains no email, role, username or status
Scenario Outline: Lookup edge cases
Given a valid jwt cookie for verified user 3
When the client sends GET /v1/user/lookup<query>
Then the response status is <status>
And the response body is <message>
Examples:
| query | status | message |
| ?ids=2,2,2 | 200 | an array with one item for id 2 |
| ?ids=1,999 | 200 | an array with only id 1 |
| ?ids=999 | 200 | [] |
| ?ids=4 | 200 | an array with the item for id 4 |
| (no query) | 400 | apperr.BodyErrorMessage |
| ?ids= | 400 | apperr.BodyErrorMessage |
| ?ids=abc | 400 | apperr.BodyErrorMessage |
| ?ids=1,-2 | 400 | apperr.BodyErrorMessage |
| ?ids=1,4294967296 | 400 | apperr.BodyErrorMessage |
| ?ids=1,,2 | 400 | apperr.BodyErrorMessage |
| 101 distinct ids | 400 | apperr.BodyErrorMessage |
| exactly 100 ids | 200 | an array of the existing users |
Scenario Outline: Lookup needs a verified signed-in user or an API key
Given <caller>
When the client sends GET /v1/user/lookup?ids=1
Then the response status is <status>
Examples:
| caller | status |
| a cookie for verified user 3 | 200 |
| an active API key of admin 2 | 200 |
| no credentials | 401 |
| a cookie for unverified 4 | 403 |
| a stale token_version cookie | 401 |
Scenario: Existing user and entry responses are unchanged
Given a valid jwt cookie for verified user 3
When the client sends GET /v1/user/ and GET /v1/content-type/recipe/entry
Then the responses keep their existing shapes, and created_by and updated_by stay numeric ids
Scenario: Use case: the icon rule returns a domain sentinel
Given a fake contenttype.Repository
When CreateContentType and UpdateContentType are called with each invalid icon of the table above
Then errors.Is(err, contenttype.ErrInvalidIcon) is true and the repository received no Create or Update
Scenario: Use case: the catalogue and the lookup
Given the builtintype package and a fake user.Repository
When ListBuiltinTypes and GetBuiltinType("nope") are called
Then the list has 5 types and the unknown key returns builtintype.ErrNotFound
When LookupUserNames is called with ids 1, 2, 999
Then it returns the display name for 1, the username for 2, and nothing for 999
@@ -0,0 +1,91 @@
# QA procedure: custom content types support (icon, built-in read model, user lookup)
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
## Setup shared by every case
1. Call `validator.InitValidator()` once per test.
2. Reuse the existing fakes `mockContentTypeRepo`, `mockEntryRepo`, `mockUserRepo`, `stubHasher` and the API key
fakes from the custom content types tests. Extend `mockUserRepo` with the batch lookup by ids (unknown ids
skipped). Add a counting wrapper (`countingRepos`) that records any call on the article, author, site,
media, user, content type and entry fakes, with a unique type name across the package.
3. Build the `*api.UseCases` container with fakes and mount `api.InitBackendRoutes(container, settings)` on a
test server. If a nil use case breaks a constructor, fall back to a fresh `chi.NewRouter()` mirroring
`NewContentTypeRouter`, a new `NewBuiltinTypeRouter` (same chain plus `RequireOwnerOrAdmin`) and the user
router, with `jwtauth.New("HS256", []byte("test-secret"), nil)`.
4. Inject a fake clock into the use cases.
5. Seed users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" (empty display name), verified
id 3 "sam" ("Sam Roy"), unverified 4 "quinn". Only the roles owner and admin exist, so no verified user is
rejected by `RequireOwnerOrAdmin`. Helpers: `cookieFor(userId, tokenVersion)`, `bearer(raw)`; the API key
through `POST /v1/api-key`.
6. Reuse `sampleRecipe()`; add `withIcon(body, value)` that sets, omits or nulls the icon key.
## Icon
7. Create with `"icon": "chef-hat"` -> 201; key set is exactly `id`, `key`, `name`, `icon`, `fields`, `created_at`,
`updated_at`; the icon is also present in `GET /{key}` and in the list. Update the existing pair's
"exactly the keys" wording and its QA step 7 accordingly.
8. Create with the key absent, `null` and `""` -> 201 and `icon` is JSON `null` in all three reads (assert the
raw JSON `null`, not a missing key and not `""`).
9. Legacy row: seed `mockContentTypeRepo` with a type that has no icon set; GET list and GET one -> 200, `icon`
null, other keys unchanged.
10. Invalid icons (`Chef-Hat`, `1star`, `-star`, `chef hat`, `chef_hat`, 41 characters, JSON number `5`) ->
400 `invalid_icon`, repository empty. 1 and 40 character icons -> 201.
11. PUT on a type that has an entry: new icon -> 200; absent -> 200 and null; `null` -> 200 and null; invalid ->
400 `invalid_icon` and the stored icon is unchanged. The stored entry and its `updated_at` are unchanged in
every case.
12. Use case level: `CreateContentType` / `UpdateContentType` with each invalid icon ->
`errors.Is(err, contenttype.ErrInvalidIcon)`, no `Create`/`Update` call on the fake. Domain table test of the
icon rule on the function directly.
13. Persistence mapper round trip (unit, no GORM): icon nil and non-nil survive the model/domain conversion in
both directions. The repository `Update` select list must include the icon (verified in the manual section
against the real DB).
## Built-in types
14. Owner `GET /v1/builtin-type` -> 200, array of 5, keys `article`, `author`, `site`, `media`, `user` in order,
names `Articles`, `Authors`, `Sites`, `Media`, `Users`; each item has exactly `key`, `name`, `fields`; each
field has exactly the nine field keys; every type is one of the six engine types.
15. Field-key table (feature table): per type, the ordered field keys; assert none of the forbidden keys
(`id`, `created_at`, `updated_at`, `password`, `password_reset_code`, `verification_code`, `token_version`)
appears in any type.
16. Rules table (feature table): spot-check type, required, min, max and options for the listed fields, with
`default`/`min`/`max` as JSON `null` and `options` as `[]` when unset. Rule values come from the request
structs; where a rule is not stated there (author bio max, media filename), leave min/max null.
17. Cross-check against the real contracts so the catalogue can't drift: for each built-in type, the JSON object
keys of the domain struct (via `json.Marshal` of the entity: `article.Article`, `author.Author`, `site.Site`,
`image.Image`, `user.User`) must contain every catalogue field key, using the json tags. Fail the test if a
catalogue key does not exist in the entity JSON.
18. `GET /v1/builtin-type/{key}` for each key -> 200 with the same item as in the list; `nope` and `Article` ->
404 `builtin_type_not_found`.
19. Collision: seed custom types `builtin` and `article`; `GET /v1/content-type/builtin` and `/article` return the
custom types; `GET /v1/content-type` lists only the custom ones; `GET /v1/builtin-type/article` returns the
built-in one.
20. Access table: owner 200, admin 200, API key of admin 200, user 4 403 `apperr.VerificationRequiredErrorMessage`,
none 401, stale 401. POST/PUT/DELETE on `/v1/builtin-type` and `/v1/builtin-type/article` -> 405 for admin.
(A verified non-owner non-admin 403 cannot be tested: no such role exists.)
21. Isolation: with the counting wrapper, calling the list and `GET /site` leaves every repository call count at
zero.
22. Use case level: `ListBuiltinTypes` returns 5 types; `GetBuiltinType("nope")` returns `builtintype.ErrNotFound`.
## User lookup
23. As verified user 3, `GET /v1/user/lookup?ids=1,2,3` -> 200; array ordered by id; each item has exactly `id`
and `display_name`; names `Marta Alves`, `dev`, `Sam Roy`; raw body contains none of `email`, `role`,
`username`, `status`, `password`.
24. Edge table (feature table): duplicates collapse, unknown ids are omitted, all-unknown -> `[]` (assert JSON
`[]` not `null`), unverified id 4 still resolves, missing/empty/non-numeric/negative/overflow/`1,,2`/101 ids
-> 400 `apperr.BodyErrorMessage`, exactly 100 ids -> 200.
25. Access table: verified user 3 200, API key of admin 2 200, none 401, user 4 403, stale token_version 401.
26. Regression: `GET /v1/user/` and entry responses are unchanged (same keys as before, `created_by`/`updated_by`
numeric).
27. Use case level: `LookupUserNames` with a fake `user.Repository` returns display name, falls back to the
username when it is empty, omits unknown ids, and makes a single batch call on the fake (no per-id lookups).
## Manual verification (not exercised by the Go tests)
- Start `task dev:api` on an existing database that already has content types: confirm AutoMigrate adds the
nullable icon column, old types read `icon: null`, and nothing else changes. Repeat on MySQL/MariaDB and
PostgreSQL via the existing driver tasks.
- Change an icon through PUT against the real SQLite file and reload: confirms the repository `Update` select
list includes the icon (the mapper test cannot).
- Compare `GET /v1/builtin-type/<key>` field rules with the real `POST`/`PATCH` validation of each resource once
(for example create a site with a 101 character `site_name`, expect a 400) to confirm that the stated max
values are honest.
- Request `GET /v1/user/lookup` in a browser session as a non-admin and confirm no email or role appears in the
network response.
@@ -0,0 +1,141 @@
package builtintype
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/user"
"errors"
)
var ErrNotFound = errors.New("built-in type not found")
const (
KeyArticle = "article"
KeyAuthor = "author"
KeySite = "site"
KeyMedia = "media"
KeyUser = "user"
)
type Type struct {
Key string `json:"key"`
Name string `json:"name"`
Fields []contenttype.Field `json:"fields"`
}
func Catalogue() []Type {
return []Type{
{Key: KeyArticle, Name: "Articles", Fields: articleFields()},
{Key: KeyAuthor, Name: "Authors", Fields: authorFields()},
{Key: KeySite, Name: "Sites", Fields: siteFields()},
{Key: KeyMedia, Name: "Media", Fields: mediaFields()},
{Key: KeyUser, Name: "Users", Fields: userFields()},
}
}
func Find(key string) (Type, error) {
for _, candidate := range Catalogue() {
if candidate.Key == key {
return candidate, nil
}
}
return Type{}, ErrNotFound
}
func field(key string, label string, fieldType contenttype.FieldType) contenttype.Field {
return contenttype.Field{Key: key, Label: label, Type: fieldType, Options: []string{}}
}
func (b builder) required() builder {
b.Required = true
return b
}
func (b builder) min(bound int) builder {
b.Min = bound
return b
}
func (b builder) max(bound int) builder {
b.Max = bound
return b
}
func (b builder) options(options ...string) builder {
b.Options = options
return b
}
type builder contenttype.Field
func text(key string, label string) builder {
return builder(field(key, label, contenttype.TypeText))
}
func of(key string, label string, fieldType contenttype.FieldType) builder {
return builder(field(key, label, fieldType))
}
func build(builders ...builder) []contenttype.Field {
fields := make([]contenttype.Field, 0, len(builders))
for _, b := range builders {
fields = append(fields, contenttype.Field(b))
}
return fields
}
func articleFields() []contenttype.Field {
return build(
text("title", "Title").required().min(article.MinTitleRunes).max(article.MaxTitleRunes),
of("body", "Body", contenttype.TypeRichText),
text("slug", "Slug"),
text("excerpt", "Excerpt").max(300),
text("seo_title", "SEO title").max(120),
text("seo_description", "SEO description").max(320),
of("is_online", "Online", contenttype.TypeBoolean),
text("canonical_url", "Canonical URL").max(article.MaxCanonicalURLRunes),
of("noindex", "No index", contenttype.TypeBoolean),
)
}
func authorFields() []contenttype.Field {
return build(
text("name", "Name").required(),
text("slug", "Slug"),
text("bio", "Bio"),
text("website", "Website"),
)
}
func siteFields() []contenttype.Field {
return build(
text("name", "Name").required(),
text("key", "Key").required(),
text("base_url", "Base URL").required(),
of("default_robots", "Default robots", contenttype.TypeSelect).options("index", "noindex"),
text("locale", "Locale"),
of("is_default", "Default site", contenttype.TypeBoolean),
text("site_name", "Site name").max(100),
text("title_template", "Title template").max(200),
text("default_description", "Default description").max(320),
)
}
func mediaFields() []contenttype.Field {
return build(
text("filename", "Filename"),
of("size", "Size", contenttype.TypeNumber),
of("width", "Width", contenttype.TypeNumber),
of("height", "Height", contenttype.TypeNumber),
text("content_type", "Content type"),
)
}
func userFields() []contenttype.Field {
return build(
text("username", "Username").required(),
text("display_name", "Display name"),
text("email", "Email").required(),
of("role", "Role", contenttype.TypeSelect).required().options(string(user.RoleOwner), string(user.RoleAdmin)),
)
}
@@ -10,6 +10,7 @@ var (
ErrKeyTaken = errors.New("a content type with that key already exists")
ErrInvalidName = errors.New("invalid content type name")
ErrInvalidKey = errors.New("invalid content type key")
ErrInvalidIcon = errors.New("invalid content type icon")
ErrInvalidField = errors.New("invalid field")
ErrHasEntries = errors.New("the content type still has entries")
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
@@ -58,6 +59,7 @@ type ContentType struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Icon *string `json:"icon"`
Fields []Field `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -7,6 +7,8 @@ import (
"unicode/utf8"
)
var iconPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
var fieldKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
func ValidateKey(key string) error {
@@ -24,6 +26,16 @@ func ValidateName(name string) error {
return nil
}
func ValidateIcon(icon *string) (*string, error) {
if icon == nil || *icon == "" {
return nil, nil
}
if !iconPattern.MatchString(*icon) {
return nil, ErrInvalidIcon
}
return icon, nil
}
func ValidateFields(fields []Field) ([]Field, error) {
if len(fields) < MinFields || len(fields) > MaxFields {
return nil, ErrInvalidField
+11
View File
@@ -2,6 +2,7 @@ package user
import (
"errors"
"strings"
"time"
)
@@ -15,8 +16,11 @@ var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
var ErrInvalidDisplayName = errors.New("invalid display name")
var ErrUsernameTaken = errors.New("username taken")
var ErrInvalidLookup = errors.New("invalid user lookup")
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
const MaxLookupIDs = 100
type Role string
const (
@@ -40,3 +44,10 @@ type User struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (u User) NameOrUsername() string {
if trimmed := strings.TrimSpace(u.DisplayName); trimmed != "" {
return trimmed
}
return u.Username
}
@@ -7,6 +7,7 @@ type Repository interface {
GetByUsername(username string) (User, error)
GetByEmail(email string) (User, error)
GetAll() []User
FindByIDs(ids []uint32) ([]User, error)
Create(user User) (User, error)
Delete(id uint32) error
UpdateVerificationStatus(userId uint32, isVerified bool) (User, error)
@@ -15,6 +15,7 @@ func ContentTypeToDomain(m models.ContentType) (contenttype.ContentType, error)
ID: m.ID,
Key: m.Key,
Name: m.Name,
Icon: m.Icon,
Fields: fields,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
@@ -30,6 +31,7 @@ func ContentTypeToModel(c contenttype.ContentType) (models.ContentType, error) {
ID: c.ID,
Key: c.Key,
Name: c.Name,
Icon: c.Icon,
Fields: string(fields),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
@@ -6,6 +6,7 @@ type ContentType struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Key string `gorm:"column:type_key;size:40;not null;uniqueIndex"`
Name string `gorm:"size:100;not null"`
Icon *string `gorm:"size:40"`
Fields string `gorm:"type:text;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -60,7 +60,7 @@ func (c *ContentTypeRepository) Update(contentType domain.ContentType) (domain.C
if err != nil {
return domain.ContentType{}, err
}
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Fields", "UpdatedAt").Updates(&model)
result := c.db.Model(&entity.ContentType{}).Where("id = ?", model.ID).Select("Name", "Icon", "Fields", "UpdatedAt").Updates(&model)
if err := deleteOrNotFound(result, domain.ErrContentTypeNotFound); err != nil {
return domain.ContentType{}, mapKeyTakenErr(err)
}
@@ -62,6 +62,18 @@ func (u *UserRepository) GetAll() []domainUser.User {
return domainUsers
}
func (u *UserRepository) FindByIDs(ids []uint32) ([]domainUser.User, error) {
var users []entity.User
if err := u.db.Select("id", "username", "display_name").Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
found := make([]domainUser.User, 0, len(users))
for _, localUser := range users {
found = append(found, mappers.UserToDomain(localUser))
}
return found, nil
}
func (u *UserRepository) GetByUsername(username string) (domainUser.User, error) {
var localUser entity.User
err := u.db.Model(&entity.User{}).Where("username = ?", username).First(&localUser).Error
@@ -14,6 +14,7 @@ type CreateContentTypeUseCase struct {
type CreateContentTypeCommand struct {
Key string
Name string
Icon *string
Fields []contenttype.Field
}
@@ -28,6 +29,10 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
if err := contenttype.ValidateName(command.Name); err != nil {
return contenttype.ContentType{}, err
}
icon, err := contenttype.ValidateIcon(command.Icon)
if err != nil {
return contenttype.ContentType{}, err
}
fields, err := contenttype.ValidateFields(command.Fields)
if err != nil {
return contenttype.ContentType{}, err
@@ -39,6 +44,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
return u.contentTypes.Create(contenttype.ContentType{
Key: command.Key,
Name: command.Name,
Icon: icon,
Fields: fields,
CreatedAt: created,
UpdatedAt: created,
@@ -0,0 +1,13 @@
package useCases
import "RenewCMS/internal/domain/builtintype"
type GetBuiltinTypeUseCase struct{}
func NewGetBuiltinTypeUseCase() *GetBuiltinTypeUseCase {
return &GetBuiltinTypeUseCase{}
}
func (u *GetBuiltinTypeUseCase) Get(key string) (builtintype.Type, error) {
return builtintype.Find(key)
}
@@ -5,7 +5,6 @@ import (
"RenewCMS/internal/domain/user"
"errors"
"sort"
"strings"
"time"
)
@@ -62,7 +61,7 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
if requester.Role != user.RoleOwner {
return names, nil
}
names[requester.ID] = displayNameOrUsername(requester)
names[requester.ID] = requester.NameOrUsername()
for _, key := range keys {
if _, resolved := names[key.UserID]; resolved {
continue
@@ -75,18 +74,11 @@ func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.
if err != nil {
return nil, err
}
names[key.UserID] = displayNameOrUsername(creator)
names[key.UserID] = creator.NameOrUsername()
}
return names, nil
}
func displayNameOrUsername(account user.User) string {
if trimmed := strings.TrimSpace(account.DisplayName); trimmed != "" {
return trimmed
}
return account.Username
}
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
if requester.Role == user.RoleOwner {
return l.apiKeyRepository.ListAll()
@@ -0,0 +1,13 @@
package useCases
import "RenewCMS/internal/domain/builtintype"
type ListBuiltinTypesUseCase struct{}
func NewListBuiltinTypesUseCase() *ListBuiltinTypesUseCase {
return &ListBuiltinTypesUseCase{}
}
func (u *ListBuiltinTypesUseCase) List() []builtintype.Type {
return builtintype.Catalogue()
}
@@ -0,0 +1,48 @@
package useCases
import (
"RenewCMS/internal/domain/user"
"sort"
)
type LookupUserNamesUseCase struct {
users user.Repository
}
type UserName struct {
ID uint32
DisplayName string
}
func NewLookupUserNamesUseCase(users user.Repository) *LookupUserNamesUseCase {
return &LookupUserNamesUseCase{users}
}
func (u *LookupUserNamesUseCase) Lookup(ids []uint32) ([]UserName, error) {
distinct := distinctIDs(ids)
if len(distinct) == 0 || len(distinct) > user.MaxLookupIDs {
return nil, user.ErrInvalidLookup
}
found, err := u.users.FindByIDs(distinct)
if err != nil {
return nil, err
}
sort.Slice(found, func(i, j int) bool { return found[i].ID < found[j].ID })
names := make([]UserName, 0, len(found))
for _, record := range found {
names = append(names, UserName{ID: record.ID, DisplayName: record.NameOrUsername()})
}
return names, nil
}
func distinctIDs(ids []uint32) []uint32 {
seen := make(map[uint32]bool, len(ids))
distinct := make([]uint32, 0, len(ids))
for _, id := range ids {
if !seen[id] {
seen[id] = true
distinct = append(distinct, id)
}
}
return distinct
}
@@ -15,6 +15,7 @@ type UpdateContentTypeUseCase struct {
type UpdateContentTypeCommand struct {
Key string
Name string
Icon *string
Fields []contenttype.Field
}
@@ -30,6 +31,10 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
if err := contenttype.ValidateName(command.Name); err != nil {
return contenttype.ContentType{}, err
}
icon, err := contenttype.ValidateIcon(command.Icon)
if err != nil {
return contenttype.ContentType{}, err
}
fields, err := contenttype.ValidateFields(command.Fields)
if err != nil {
return contenttype.ContentType{}, err
@@ -44,6 +49,7 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
}
}
current.Name = command.Name
current.Icon = icon
current.Fields = fields
current.UpdatedAt = u.now()
return u.contentTypes.Update(current)
+117
View File
@@ -0,0 +1,117 @@
package test
import (
articleHandler "RenewCMS/api/handlers/article"
siteHandler "RenewCMS/api/handlers/site"
userHandler "RenewCMS/api/handlers/user"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"reflect"
"sort"
"strconv"
"strings"
"testing"
)
type driftTagRules struct {
required bool
min any
max any
options []string
}
func driftParseTag(tag string) driftTagRules {
rules := driftTagRules{}
for _, part := range strings.Split(tag, ",") {
switch {
case part == "required":
rules.required = true
case strings.HasPrefix(part, "min="):
value, _ := strconv.Atoi(strings.TrimPrefix(part, "min="))
rules.min = value
case strings.HasPrefix(part, "max="):
value, _ := strconv.Atoi(strings.TrimPrefix(part, "max="))
rules.max = value
case strings.HasPrefix(part, "oneof="):
rules.options = strings.Fields(strings.TrimPrefix(part, "oneof="))
}
}
return rules
}
func driftTagFor(t *testing.T, structType reflect.Type, jsonKey string) (string, bool) {
t.Helper()
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)
if strings.Split(field.Tag.Get("json"), ",")[0] == jsonKey {
return field.Tag.Get("validate"), true
}
}
return "", false
}
func driftSorted(values []string) []string {
out := append([]string{}, values...)
sort.Strings(out)
return out
}
type driftBinding struct {
typeKey string
request any
fields []string
checkRequired bool
}
func TestBuiltinCatalogueMatchesRequestTags(t *testing.T) {
bindings := []driftBinding{
{builtintype.KeyArticle, articleHandler.PostArticle{}, []string{"title"}, true},
{builtintype.KeyArticle, articleHandler.UpdateArticleBody{}, []string{"excerpt", "seo_title", "seo_description", "canonical_url"}, false},
{builtintype.KeyArticle, articleHandler.CheckArticleSEOBody{}, []string{"excerpt", "seo_title", "seo_description", "canonical_url"}, false},
{builtintype.KeySite, siteHandler.PostSite{}, []string{"name", "key", "base_url", "default_robots", "site_name", "title_template", "default_description"}, true},
{builtintype.KeySite, siteHandler.PatchSite{}, []string{"default_robots", "site_name", "title_template", "default_description"}, false},
{builtintype.KeyUser, userHandler.CreateInviteRequest{}, []string{"email"}, true},
}
covered := map[string]bool{}
for _, binding := range bindings {
builtin, err := builtintype.Find(binding.typeKey)
if err != nil {
t.Fatalf("catalogue type %s: %v", binding.typeKey, err)
}
byKey := map[string]contenttype.Field{}
for _, f := range builtin.Fields {
byKey[f.Key] = f
}
structType := reflect.TypeOf(binding.request)
for _, key := range binding.fields {
catalogueField, ok := byKey[key]
if !ok {
t.Errorf("%s.%s missing from catalogue", binding.typeKey, key)
continue
}
tag, found := driftTagFor(t, structType, key)
if !found {
t.Errorf("%s has no json field %s", structType.Name(), key)
continue
}
rules := driftParseTag(tag)
covered[binding.typeKey+"."+key] = true
label := binding.typeKey + "." + key + " vs " + structType.Name()
if binding.checkRequired && rules.required != catalogueField.Required {
t.Errorf("%s: required catalogue=%v tag=%v", label, catalogueField.Required, rules.required)
}
if !reflect.DeepEqual(rules.min, catalogueField.Min) {
t.Errorf("%s: min catalogue=%v tag=%v", label, catalogueField.Min, rules.min)
}
if !reflect.DeepEqual(rules.max, catalogueField.Max) {
t.Errorf("%s: max catalogue=%v tag=%v", label, catalogueField.Max, rules.max)
}
if !reflect.DeepEqual(driftSorted(rules.options), driftSorted(catalogueField.Options)) {
t.Errorf("%s: options catalogue=%v tag=%v", label, catalogueField.Options, rules.options)
}
}
}
if len(covered) != 13 {
t.Errorf("covered catalogue fields = %d, want 13", len(covered))
}
}
@@ -0,0 +1,684 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/persistence/mappers"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const supportRecipeKey = "recipe"
var supportContentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
func newSupportEnv(t *testing.T) *contentTypeEnv {
t.Helper()
env := newContentTypeEnv(t)
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner}
env.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "sam", DisplayName: "Sam Roy", IsVerified: true, Role: user.RoleAdmin}
return env
}
func recipeWithIcon(icon any, present bool) string {
body := map[string]any{"key": supportRecipeKey, "name": "Recipe", "fields": recipeFields()}
if present {
body["icon"] = icon
}
encoded, _ := json.Marshal(body)
return string(encoded)
}
func recipeUpdate(icon any, present bool) string {
body := map[string]any{"name": "Recipe", "fields": recipeFields()}
if present {
body["icon"] = icon
}
encoded, _ := json.Marshal(body)
return string(encoded)
}
func rawJSONValue(t *testing.T, rec *httptest.ResponseRecorder, key string) string {
t.Helper()
var decoded map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("failed to decode %q: %v", rec.Body.String(), err)
}
raw, present := decoded[key]
if !present {
t.Fatalf("key %q missing in %q", key, rec.Body.String())
}
return string(raw)
}
func decodeContentArray(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any {
t.Helper()
var decoded []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("failed to decode %q: %v", rec.Body.String(), err)
}
return decoded
}
func expectIconEverywhere(t *testing.T, env *contentTypeEnv, want string) {
t.Helper()
one := env.admin(t, http.MethodGet, "/v1/content-type/"+supportRecipeKey, "")
if got := rawJSONValue(t, one, "icon"); got != want {
t.Errorf("GET one: expected icon %s, got %s", want, got)
}
list := env.admin(t, http.MethodGet, "/v1/content-type", "")
var items []map[string]json.RawMessage
if err := json.Unmarshal(list.Body.Bytes(), &items); err != nil || len(items) != 1 {
t.Fatalf("unexpected list %q: %v", list.Body.String(), err)
}
if got := string(items[0]["icon"]); got != want {
t.Errorf("GET list: expected icon %s, got %s", want, got)
}
}
func TestContentTypeIconHTTP(t *testing.T) {
t.Run("Creating a content type with an icon", func(t *testing.T) {
env := newSupportEnv(t)
rec := env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon("chef-hat", true))
expectStatusAndBody(t, rec, http.StatusCreated, "")
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
t.Errorf("expected keys %v, got %v", supportContentTypeKeys, got)
}
if got := rawJSONValue(t, rec, "icon"); got != `"chef-hat"` {
t.Errorf("expected chef-hat, got %s", got)
}
expectIconEverywhere(t, env, `"chef-hat"`)
})
t.Run("A content type without an icon has a null icon", func(t *testing.T) {
cases := map[string]string{
"no icon key": recipeWithIcon(nil, false),
`"icon": null`: recipeWithIcon(nil, true),
`"icon": ""`: recipeWithIcon("", true),
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
env := newSupportEnv(t)
rec := env.admin(t, http.MethodPost, "/v1/content-type", body)
expectStatusAndBody(t, rec, http.StatusCreated, "")
if got := rawJSONValue(t, rec, "icon"); got != "null" {
t.Errorf("expected null, got %s", got)
}
expectIconEverywhere(t, env, "null")
})
}
})
t.Run("A content type stored before this slice still works", func(t *testing.T) {
env := newSupportEnv(t)
env.types.types[1] = contenttype.ContentType{ID: 1, Key: supportRecipeKey, Name: "Recipe", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}}
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/"+supportRecipeKey, "")
expectStatusAndBody(t, rec, http.StatusOK, "")
if got := rawJSONValue(t, rec, "icon"); got != "null" {
t.Errorf("expected null, got %s", got)
}
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
t.Errorf("unexpected keys %v", got)
}
expectIconEverywhere(t, env, "null")
})
t.Run("An invalid icon is refused and stores nothing", func(t *testing.T) {
invalid := []any{"Chef-Hat", "1star", "-star", "chef hat", "chef_hat", strings.Repeat("a", 41), 5}
for _, icon := range invalid {
t.Run(fmt.Sprint(icon), func(t *testing.T) {
env := newSupportEnv(t)
rec := env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon(icon, true))
expectStatusAndBody(t, rec, http.StatusBadRequest, apperr.InvalidIconErrorMessage)
if len(env.types.types) != 0 {
t.Errorf("expected nothing stored, got %d", len(env.types.types))
}
})
}
})
t.Run("Boundary icons are accepted", func(t *testing.T) {
env := newSupportEnv(t)
for index, icon := range []string{"a", "a" + strings.Repeat("b", 39)} {
body := map[string]any{"key": "recipe" + strconv.Itoa(index), "name": "Recipe", "icon": icon, "fields": recipeFields()}
encoded, _ := json.Marshal(body)
expectStatusAndBody(t, env.admin(t, http.MethodPost, "/v1/content-type", string(encoded)), http.StatusCreated, "")
}
})
t.Run("Updating the icon of a content type", func(t *testing.T) {
cases := []struct {
name string
body string
status int
message string
icon string
}{
{"utensils", recipeUpdate("utensils", true), http.StatusOK, "", `"utensils"`},
{"no icon key", recipeUpdate(nil, false), http.StatusOK, "", "null"},
{"null", recipeUpdate(nil, true), http.StatusOK, "", "null"},
{"bad icon", recipeUpdate("Bad Icon", true), http.StatusBadRequest, apperr.InvalidIconErrorMessage, `"chef-hat"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env := newSupportEnv(t)
expectStatusAndBody(t, env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon("chef-hat", true)), http.StatusCreated, "")
rec := env.admin(t, http.MethodPut, "/v1/content-type/"+supportRecipeKey, tc.body)
expectStatusAndBody(t, rec, tc.status, tc.message)
expectIconEverywhere(t, env, tc.icon)
})
}
})
t.Run("The icon does not affect entries", func(t *testing.T) {
env := newSupportEnv(t)
expectStatusAndBody(t, env.admin(t, http.MethodPost, "/v1/content-type", recipeWithIcon("chef-hat", true)), http.StatusCreated, "")
created := env.createEntry(t, supportRecipeKey, ctAdminId, `{"title": "Soup", "difficulty": "easy"}`)
expectStatusAndBody(t, created, http.StatusCreated, "")
before := env.entries.entries[1]
expectStatusAndBody(t, env.admin(t, http.MethodPut, "/v1/content-type/"+supportRecipeKey, recipeUpdate("utensils", true)), http.StatusOK, "")
if after := env.entries.entries[1]; !reflect.DeepEqual(before, after) {
t.Errorf("entry changed: %v -> %v", before, after)
}
})
}
func TestContentTypeIconUseCase(t *testing.T) {
invalid := []string{"Chef-Hat", "1star", "-star", "chef hat", "chef_hat", strings.Repeat("a", 41)}
now := func() time.Time { return time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) }
fields := []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}
for _, icon := range invalid {
t.Run("use case rejects "+icon, func(t *testing.T) {
repo := newMockContentTypeRepo()
repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields}
bad := icon
_, err := useCases.NewCreateContentTypeUseCase(repo, now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.createCalls != 0 {
t.Errorf("create: err %v, calls %d", err, repo.createCalls)
}
_, err = useCases.NewUpdateContentTypeUseCase(repo, newMockEntryRepo(), now).Update(useCases.UpdateContentTypeCommand{Key: "dish", Name: "Dish", Icon: &bad, Fields: fields})
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.updateCalls != 0 {
t.Errorf("update: err %v, calls %d", err, repo.updateCalls)
}
})
}
t.Run("domain rule", func(t *testing.T) {
empty, valid := "", "chef-hat"
if got, err := contenttype.ValidateIcon(&empty); got != nil || err != nil {
t.Errorf("empty icon should become nil, got %v %v", got, err)
}
if got, err := contenttype.ValidateIcon(&valid); err != nil || *got != valid {
t.Errorf("valid icon refused: %v", err)
}
if got, err := contenttype.ValidateIcon(nil); got != nil || err != nil {
t.Errorf("nil icon should stay nil, got %v %v", got, err)
}
})
}
func checkBuiltinItem(t *testing.T, item map[string]any) {
t.Helper()
if got := objectKeys(item); !reflect.DeepEqual(got, []string{"fields", "key", "name"}) {
t.Fatalf("unexpected item keys %v", got)
}
engineTypes := map[string]bool{"text": true, "richtext": true, "number": true, "boolean": true, "date": true, "select": true}
for _, raw := range item["fields"].([]any) {
field := raw.(map[string]any)
if got := objectKeys(field); !reflect.DeepEqual(got, supportFieldKeys) {
t.Errorf("unexpected field keys %v", got)
}
if !engineTypes[field["type"].(string)] {
t.Errorf("unknown field type %v", field["type"])
}
}
}
func builtinFieldKeys(item map[string]any) []string {
keys := []string{}
for _, raw := range item["fields"].([]any) {
keys = append(keys, raw.(map[string]any)["key"].(string))
}
return keys
}
func TestBuiltinTypeHTTP(t *testing.T) {
t.Run("Listing the built-in types", func(t *testing.T) {
env := newSupportEnv(t)
rec := env.as(t, ctOwnerId, http.MethodGet, "/v1/builtin-type", "")
expectStatusAndBody(t, rec, http.StatusOK, "")
items := decodeContentArray(t, rec)
keys, names := []string{}, []string{}
for _, item := range items {
checkBuiltinItem(t, item)
keys = append(keys, item["key"].(string))
names = append(names, item["name"].(string))
}
if !reflect.DeepEqual(keys, []string{"article", "author", "site", "media", "user"}) {
t.Errorf("unexpected keys %v", keys)
}
if !reflect.DeepEqual(names, []string{"Articles", "Authors", "Sites", "Media", "Users"}) {
t.Errorf("unexpected names %v", names)
}
})
t.Run("The built-in field lists match the real API", func(t *testing.T) {
env := newSupportEnv(t)
want := map[string]string{
"article": "title, body, slug, excerpt, seo_title, seo_description, is_online, canonical_url, noindex",
"author": "name, slug, bio, website",
"site": "name, key, base_url, default_robots, locale, is_default, site_name, title_template, default_description",
"media": "filename, size, width, height, content_type",
"user": "username, display_name, email, role",
}
forbidden := []string{"id", "created_at", "updated_at", "password", "password_reset_code", "verification_code", "token_version"}
for key, fields := range want {
t.Run(key, func(t *testing.T) {
rec := env.admin(t, http.MethodGet, "/v1/builtin-type/"+key, "")
expectStatusAndBody(t, rec, http.StatusOK, "")
got := builtinFieldKeys(decodeContentObject(t, rec))
if strings.Join(got, ", ") != fields {
t.Errorf("expected %s, got %v", fields, got)
}
for _, name := range got {
for _, bad := range forbidden {
if name == bad {
t.Errorf("forbidden field %s", name)
}
}
}
})
}
})
t.Run("Selected built-in field rules", func(t *testing.T) {
env := newSupportEnv(t)
cases := []struct {
key, field, fieldType string
required bool
min, max any
options []any
}{
{"article", "title", "text", true, float64(3), float64(200), []any{}},
{"article", "body", "richtext", false, nil, nil, []any{}},
{"article", "excerpt", "text", false, nil, float64(300), []any{}},
{"article", "is_online", "boolean", false, nil, nil, []any{}},
{"site", "default_robots", "select", false, nil, nil, []any{"index", "noindex"}},
{"site", "site_name", "text", false, nil, float64(100), []any{}},
{"site", "title_template", "text", false, nil, float64(200), []any{}},
{"site", "default_description", "text", false, nil, float64(320), []any{}},
{"user", "role", "select", true, nil, nil, []any{"owner", "admin"}},
{"user", "email", "text", true, nil, nil, []any{}},
}
for _, tc := range cases {
t.Run(tc.key+"."+tc.field, func(t *testing.T) {
item := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/builtin-type/"+tc.key, ""))
for _, raw := range item["fields"].([]any) {
field := raw.(map[string]any)
if field["key"] != tc.field {
continue
}
if field["type"] != tc.fieldType || field["required"] != tc.required || field["min"] != tc.min || field["max"] != tc.max || !reflect.DeepEqual(field["options"], tc.options) {
t.Errorf("unexpected field %v", field)
}
return
}
t.Errorf("field %s missing", tc.field)
})
}
})
t.Run("Reading one built-in type does not collide with a custom type of the same key", func(t *testing.T) {
env := newSupportEnv(t)
env.createType(t, "builtin", dishFields())
env.createType(t, "article", dishFields())
for _, key := range []string{"builtin", "article"} {
rec := env.admin(t, http.MethodGet, "/v1/content-type/"+key, "")
expectStatusAndBody(t, rec, http.StatusOK, "")
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
t.Errorf("%s is not the custom type: %v", key, got)
}
}
item := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/builtin-type/article", ""))
if item["name"] != "Articles" {
t.Errorf("expected the built-in article, got %v", item["name"])
}
listed := decodeContentArray(t, env.admin(t, http.MethodGet, "/v1/content-type", ""))
if len(listed) != 2 {
t.Errorf("expected 2 custom types, got %d", len(listed))
}
})
t.Run("The built-in routes are read-only and restricted", func(t *testing.T) {
env := newSupportEnv(t)
stale := func(req *http.Request) {
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
apiKey := env.issueKey(ctAdminId)
cases := []struct {
name, method, path string
mutate func(*http.Request)
status int
message string
}{
{"owner 1 cookie", http.MethodGet, "/v1/builtin-type", env.asUser(t, ctOwnerId), 200, ""},
{"admin 2 cookie", http.MethodGet, "/v1/builtin-type/user", env.asUser(t, ctAdminId), 200, ""},
{"an active API key of admin 2", http.MethodGet, "/v1/builtin-type", func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+apiKey) }, 200, ""},
{"a cookie for unverified 4", http.MethodGet, "/v1/builtin-type", env.asUser(t, ctUnverifiedId), 403, apperr.VerificationRequiredErrorMessage},
{"no credentials", http.MethodGet, "/v1/builtin-type", nil, 401, ""},
{"a stale token_version cookie", http.MethodGet, "/v1/builtin-type", stale, 401, ""},
{"unknown key", http.MethodGet, "/v1/builtin-type/nope", env.asUser(t, ctAdminId), 404, apperr.BuiltinTypeNotFoundErrorMessage},
{"uppercase key", http.MethodGet, "/v1/builtin-type/Article", env.asUser(t, ctAdminId), 404, apperr.BuiltinTypeNotFoundErrorMessage},
{"POST", http.MethodPost, "/v1/builtin-type", env.asUser(t, ctAdminId), 405, ""},
{"PUT", http.MethodPut, "/v1/builtin-type/article", env.asUser(t, ctAdminId), 405, ""},
{"DELETE", http.MethodDelete, "/v1/builtin-type/article", env.asUser(t, ctAdminId), 405, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := env.request(t, tc.method, tc.path, "", tc.mutate)
expectStatusAndBody(t, rec, tc.status, tc.message)
if tc.status == http.StatusMethodNotAllowed && rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
})
}
})
t.Run("Reading the built-in types never touches data", func(t *testing.T) {
validator.InitValidator()
users := newMockUserRepo()
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
token := jwtauth.New("HS256", []byte("test-secret"), nil)
now := func() time.Time { return time.Now() }
generator := hashing.NewSHA256KeyGenerator()
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, now),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
for _, path := range []string{"/v1/builtin-type", "/v1/builtin-type/site"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("%s: status %d", path, rec.Code)
}
}
})
}
type panickingContentTypeRepo struct{ contenttype.Repository }
type panickingEntryRepo struct{ entry.Repository }
func TestBuiltinTypeCatalogueMatchesEntities(t *testing.T) {
entities := map[string]any{
builtintype.KeyArticle: article.Article{},
builtintype.KeyAuthor: author.Author{},
builtintype.KeySite: site.Site{},
builtintype.KeyMedia: image.Image{},
builtintype.KeyUser: user.User{},
}
for key, entity := range entities {
t.Run(key, func(t *testing.T) {
encoded, err := json.Marshal(entity)
if err != nil {
t.Fatal(err)
}
var object map[string]any
_ = json.Unmarshal(encoded, &object)
found, err := builtintype.Find(key)
if err != nil {
t.Fatal(err)
}
for _, field := range found.Fields {
if _, exists := object[field.Key]; !exists {
t.Errorf("catalogue field %s.%s does not exist in the entity JSON", key, field.Key)
}
}
})
}
}
func TestBuiltinTypeUseCases(t *testing.T) {
if got := useCases.NewListBuiltinTypesUseCase().List(); len(got) != 5 {
t.Errorf("expected 5 types, got %d", len(got))
}
if _, err := useCases.NewGetBuiltinTypeUseCase().Get("nope"); !errors.Is(err, builtintype.ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
found, err := useCases.NewGetBuiltinTypeUseCase().Get("site")
if err != nil || found.Name != "Sites" {
t.Errorf("unexpected %v %v", found, err)
}
}
func lookupQuery(ids ...int) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, strconv.Itoa(id))
}
return "?ids=" + strings.Join(parts, ",")
}
func TestUserLookupHTTP(t *testing.T) {
t.Run("Any verified user resolves display names", func(t *testing.T) {
env := newSupportEnv(t)
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup?ids=1,2,3", "")
expectStatusAndBody(t, rec, http.StatusOK, "")
items := decodeContentArray(t, rec)
wantNames := []string{"Marta Alves", "dev", "Sam Roy"}
if len(items) != 3 {
t.Fatalf("expected 3 items, got %v", items)
}
for index, item := range items {
if got := objectKeys(item); !reflect.DeepEqual(got, []string{"display_name", "id"}) {
t.Errorf("unexpected keys %v", got)
}
if item["id"] != float64(index+1) || item["display_name"] != wantNames[index] {
t.Errorf("unexpected item %v", item)
}
}
for _, leaked := range []string{"email", "role", "username", "status", "password"} {
if strings.Contains(rec.Body.String(), leaked) {
t.Errorf("body leaks %s", leaked)
}
}
})
t.Run("Lookup edge cases", func(t *testing.T) {
env := newSupportEnv(t)
var hundred, overflow []int
for id := 1; id <= 100; id++ {
hundred = append(hundred, id)
}
overflow = append(hundred, 101)
cases := []struct {
name, query string
status int
body string
count int
}{
{"duplicates", "?ids=2,2,2", 200, "", 1},
{"one unknown", "?ids=1,999", 200, "", 1},
{"all unknown", "?ids=999", 200, "[]", 0},
{"unverified user", "?ids=4", 200, "", 1},
{"no query", "", 400, apperr.BodyErrorMessage, 0},
{"empty", "?ids=", 400, apperr.BodyErrorMessage, 0},
{"letters", "?ids=abc", 400, apperr.BodyErrorMessage, 0},
{"negative", "?ids=1,-2", 400, apperr.BodyErrorMessage, 0},
{"overflow", "?ids=1,4294967296", 400, apperr.BodyErrorMessage, 0},
{"empty element", "?ids=1,,2", 400, apperr.BodyErrorMessage, 0},
{"101 distinct ids", lookupQuery(overflow...), 400, apperr.BodyErrorMessage, 0},
{"exactly 100 ids", lookupQuery(hundred...), 200, "", 5},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup"+tc.query, "")
expectStatusAndBody(t, rec, tc.status, tc.body)
if tc.status == http.StatusOK {
if got := len(decodeContentArray(t, rec)); got != tc.count {
t.Errorf("expected %d items, got %d", tc.count, got)
}
}
})
}
})
t.Run("Repository failure is a 500, not a 400", func(t *testing.T) {
env := newSupportEnv(t)
env.users.fail.failNext("FindByIDs")
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup?ids=1", "")
expectStatusAndBody(t, rec, http.StatusInternalServerError, "Internal server error")
})
t.Run("More than the maximum raw parts is rejected", func(t *testing.T) {
env := newSupportEnv(t)
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/lookup?ids="+strings.Repeat("1,", 100)+"1", "")
expectStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
})
t.Run("Lookup needs a verified signed-in user or an API key", func(t *testing.T) {
env := newSupportEnv(t)
apiKey := env.issueKey(ctAdminId)
stale := func(req *http.Request) {
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
cases := []struct {
name string
mutate func(*http.Request)
status int
}{
{"verified user 3", env.asUser(t, ctMemberId), 200},
{"API key", func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+apiKey) }, 200},
{"no credentials", nil, 401},
{"unverified 4", env.asUser(t, ctUnverifiedId), 403},
{"stale token", stale, 401},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := env.request(t, http.MethodGet, "/v1/user/lookup?ids=1", "", tc.mutate)
if rec.Code != tc.status {
t.Errorf("expected %d, got %d (%s)", tc.status, rec.Code, rec.Body.String())
}
})
}
})
t.Run("Existing user and entry responses are unchanged", func(t *testing.T) {
env := newSupportEnv(t)
env.createType(t, supportRecipeKey, recipeFields())
expectStatusAndBody(t, env.createEntry(t, supportRecipeKey, ctMemberId, `{"title": "Soup", "difficulty": "easy"}`), http.StatusCreated, "")
rec := env.as(t, ctMemberId, http.MethodGet, "/v1/user/", "")
expectStatusAndBody(t, rec, http.StatusOK, "")
list := env.as(t, ctMemberId, http.MethodGet, "/v1/content-type/"+supportRecipeKey+"/entry", "")
expectStatusAndBody(t, list, http.StatusOK, "")
var page struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(list.Body.Bytes(), &page); err != nil || len(page.Items) != 1 {
t.Fatalf("unexpected page %q: %v", list.Body.String(), err)
}
if page.Items[0]["created_by"] != float64(ctMemberId) || page.Items[0]["updated_by"] != float64(ctMemberId) {
t.Errorf("created_by and updated_by should stay numeric ids: %v", page.Items[0])
}
})
}
func TestLookupUserNamesUseCase(t *testing.T) {
users := newMockUserRepo()
users.users[1] = user.User{ID: 1, Username: "marta", DisplayName: "Marta Alves"}
users.users[2] = user.User{ID: 2, Username: "dev"}
lookup := useCases.NewLookupUserNamesUseCase(users)
t.Run("display name, username fallback, unknown omitted, one batch call", func(t *testing.T) {
names, err := lookup.Lookup([]uint32{2, 999, 1, 1})
if err != nil {
t.Fatal(err)
}
want := []useCases.UserName{{ID: 1, DisplayName: "Marta Alves"}, {ID: 2, DisplayName: "dev"}}
if !reflect.DeepEqual(names, want) {
t.Errorf("expected %v, got %v", want, names)
}
if users.findByIDsCalls != 1 {
t.Errorf("expected a single batch call, got %d", users.findByIDsCalls)
}
})
t.Run("exactly the maximum number of ids is accepted", func(t *testing.T) {
atLimit := make([]uint32, 0, user.MaxLookupIDs)
for id := uint32(1); id <= user.MaxLookupIDs; id++ {
atLimit = append(atLimit, id)
}
if _, err := lookup.Lookup(atLimit); err != nil {
t.Errorf("expected no error at the limit, got %v", err)
}
})
t.Run("no ids or too many ids", func(t *testing.T) {
if _, err := lookup.Lookup(nil); !errors.Is(err, user.ErrInvalidLookup) {
t.Errorf("expected ErrInvalidLookup, got %v", err)
}
tooMany := make([]uint32, 0, 101)
for id := uint32(1); id <= 101; id++ {
tooMany = append(tooMany, id)
}
if _, err := lookup.Lookup(tooMany); !errors.Is(err, user.ErrInvalidLookup) {
t.Errorf("expected ErrInvalidLookup, got %v", err)
}
})
}
func TestContentTypeIconMapperRoundTrip(t *testing.T) {
icon := "chef-hat"
for name, stored := range map[string]*string{"nil": nil, "set": &icon} {
t.Run(name, func(t *testing.T) {
model, err := mappers.ContentTypeToModel(contenttype.ContentType{Key: "recipe", Icon: stored, Fields: []contenttype.Field{}})
if err != nil {
t.Fatal(err)
}
back, err := mappers.ContentTypeToDomain(model)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(back.Icon, stored) {
t.Errorf("expected %v, got %v", stored, back.Icon)
}
})
}
}
+17 -13
View File
@@ -241,19 +241,23 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
env.users.users[ctStaleId] = user.User{ID: ctStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1}
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(env.types, env.entries, now),
GetEntryUseCase: useCases.NewGetEntryUseCase(env.types, env.entries),
ListEntriesUseCase: useCases.NewListEntriesUseCase(env.types, env.entries),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(env.types, env.entries, now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(env.types, env.entries),
Token: env.token,
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(env.types, env.entries, now),
GetEntryUseCase: useCases.NewGetEntryUseCase(env.types, env.entries),
ListEntriesUseCase: useCases.NewListEntriesUseCase(env.types, env.entries),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(env.types, env.entries, now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(env.types, env.entries),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
var (
contentTypeKeys = []string{"created_at", "fields", "id", "key", "name", "updated_at"}
contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "updated_at", "updated_by"}
)
+15
View File
@@ -34,6 +34,7 @@ func (s *stubHasher) Compare(hashed string, value string) error {
type mockUserRepo struct {
users map[uint32]user.User
nextID uint32
findByIDsCalls int
fail failSwitch
clearVerificationCodeCalls int
clearVerificationCodeUserIds []uint32
@@ -112,6 +113,20 @@ func (m *mockUserRepo) GetAll() []user.User {
return result
}
func (m *mockUserRepo) FindByIDs(ids []uint32) ([]user.User, error) {
if err := m.fail.trigger("FindByIDs"); err != nil {
return nil, err
}
m.findByIDsCalls++
found := []user.User{}
for _, id := range ids {
if stored, exists := m.users[id]; exists {
found = append(found, stored)
}
}
return found, nil
}
func (m *mockUserRepo) Create(u user.User) (user.User, error) {
if err := m.fail.trigger("Create"); err != nil {
return user.User{}, err