mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-26 21:57:14 +08:00
812 lines
22 KiB
Go
812 lines
22 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/tidwall/gjson"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/stretchr/testify/suite"
|
|
"github.com/teamhanko/hanko/backend/v2/dto/admin"
|
|
"github.com/teamhanko/hanko/backend/v2/test"
|
|
)
|
|
|
|
func TestMetadataAdminSuite(t *testing.T) {
|
|
t.Parallel()
|
|
suite.Run(t, new(metadataAdminSuite))
|
|
}
|
|
|
|
type metadataAdminSuite struct {
|
|
test.Suite
|
|
}
|
|
|
|
func (s *metadataAdminSuite) TestMetadataAdminHandler_Get() {
|
|
if testing.Short() {
|
|
s.T().Skip("skipping test in short mode.")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
userId string
|
|
expectedStatusCode int
|
|
expectedMetadata *admin.Metadata
|
|
}{
|
|
{
|
|
name: "should return metadata for user with metadata",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Public: json.RawMessage(`{
|
|
"existing_public_str": "data",
|
|
"existing_public_num": 1,
|
|
"existing_public_bool": true
|
|
}`),
|
|
Private: json.RawMessage(`{
|
|
"existing_private_str": "data",
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0",
|
|
"existing_private_arr_1"
|
|
]
|
|
}`),
|
|
Unsafe: json.RawMessage(`{
|
|
"existing_unsafe_str": "data",
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value"
|
|
}
|
|
}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should return no content for user without metadata",
|
|
userId: "38bf5a00-d7ea-40a5-a5de-48722c148925",
|
|
expectedStatusCode: http.StatusNoContent,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on non uuid userID",
|
|
userId: "customUserId",
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on empty userID",
|
|
userId: "",
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on non existing user",
|
|
userId: "30f41697-b413-43cc-8cca-d55298683607",
|
|
expectedStatusCode: http.StatusNotFound,
|
|
expectedMetadata: nil,
|
|
},
|
|
}
|
|
|
|
for _, currentTest := range tests {
|
|
s.Run(currentTest.name, func() {
|
|
err := s.LoadFixtures("../test/fixtures/metadata")
|
|
s.Require().NoError(err)
|
|
|
|
e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil)
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
fmt.Sprintf("/users/%s/metadata", currentTest.userId),
|
|
nil,
|
|
)
|
|
rec := httptest.NewRecorder()
|
|
|
|
e.ServeHTTP(rec, req)
|
|
|
|
s.Require().Equal(currentTest.expectedStatusCode, rec.Code)
|
|
|
|
if currentTest.expectedStatusCode == http.StatusOK {
|
|
var metadataResponse *admin.Metadata
|
|
s.NoError(json.Unmarshal(rec.Body.Bytes(), &metadataResponse))
|
|
|
|
if currentTest.expectedMetadata == nil {
|
|
s.Nil(metadataResponse)
|
|
} else {
|
|
s.NotNil(metadataResponse)
|
|
|
|
s.Require().Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Public, `@pretty:{"sortKeys":true}`).String(),
|
|
gjson.GetBytes(metadataResponse.Public, `@pretty:{"sortKeys":true}`).String(),
|
|
)
|
|
s.Require().Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Private, `@pretty:{"sortKeys":true}`).String(),
|
|
gjson.GetBytes(metadataResponse.Private, `@pretty:{"sortKeys":true}`).String(),
|
|
)
|
|
s.Require().Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Unsafe, `@pretty:{"sortKeys":true}`).String(),
|
|
gjson.GetBytes(metadataResponse.Unsafe, `@pretty:{"sortKeys":true}`).String(),
|
|
)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func (s *metadataAdminSuite) TestMetadataAdminHandler_Patch_Errors() {
|
|
if testing.Short() {
|
|
s.T().Skip("skipping test in short mode.")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
userId string
|
|
patchMetadata json.RawMessage
|
|
expectedStatusCode int
|
|
expectedMetadata *admin.Metadata
|
|
}{
|
|
{
|
|
name: "should fail on non uuid userID",
|
|
userId: "customUserId",
|
|
patchMetadata: json.RawMessage(`{"public_metadata":{"key":"value"}}`),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on empty userID",
|
|
userId: "",
|
|
patchMetadata: json.RawMessage(`{"public_metadata":{"key":"value"}}`),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on non existing user",
|
|
userId: "30f41697-b413-43cc-8cca-d55298683607",
|
|
patchMetadata: json.RawMessage(`{"public_metadata":{"key":"value"}}`),
|
|
expectedStatusCode: http.StatusNotFound,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on invalid JSON",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{"public_metadata":"key":"value"`),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on top level empty string as metadata patch",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`""`),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on top level string as metadata patch",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`"invalid"`),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on top level array as metadata patch",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`["in", "valid"]`),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should fail on exceeding metadata size limit (3000)",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(metadataExceedingLimit),
|
|
expectedStatusCode: http.StatusBadRequest,
|
|
expectedMetadata: nil,
|
|
},
|
|
}
|
|
|
|
for _, currentTest := range tests {
|
|
s.Run(currentTest.name, func() {
|
|
err := s.LoadFixtures("../test/fixtures/metadata")
|
|
s.Require().NoError(err)
|
|
|
|
e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil)
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPatch,
|
|
fmt.Sprintf("/users/%s/metadata", currentTest.userId),
|
|
bytes.NewReader(currentTest.patchMetadata),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
e.ServeHTTP(rec, req)
|
|
|
|
s.Require().Equal(currentTest.expectedStatusCode, rec.Code)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *metadataAdminSuite) TestMetadataAdminHandler_Patch() {
|
|
if testing.Short() {
|
|
s.T().Skip("skipping test in short mode.")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
userId string
|
|
patchMetadata json.RawMessage
|
|
expectedStatusCode int
|
|
expectedMetadata *admin.Metadata
|
|
}{
|
|
{
|
|
name: "should do nothing on empty patch object",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{}`),
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Public: json.RawMessage(`{
|
|
"existing_public_str": "data",
|
|
"existing_public_num": 1,
|
|
"existing_public_bool": true
|
|
}`),
|
|
Private: json.RawMessage(`{
|
|
"existing_private_str": "data",
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0",
|
|
"existing_private_arr_1"
|
|
]
|
|
}`),
|
|
Unsafe: json.RawMessage(`{
|
|
"existing_unsafe_str": "data",
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value"
|
|
}
|
|
}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should do nothing on empty patch object for top level keys",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{
|
|
"public_metadata": {},
|
|
"private_metadata": {},
|
|
"unsafe_metadata": {}
|
|
}`),
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Public: json.RawMessage(`{
|
|
"existing_public_str": "data",
|
|
"existing_public_num": 1,
|
|
"existing_public_bool": true
|
|
}`),
|
|
Private: json.RawMessage(`{
|
|
"existing_private_str": "data",
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0",
|
|
"existing_private_arr_1"
|
|
]
|
|
}`),
|
|
Unsafe: json.RawMessage(`{
|
|
"existing_unsafe_str": "data",
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value"
|
|
}
|
|
}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should ignore unknown top level keys",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{
|
|
"public_metadata": {
|
|
"new_key":"new_value"
|
|
},
|
|
"unknown_metadata": {
|
|
"key":"value"
|
|
}
|
|
}`),
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Public: json.RawMessage(`{
|
|
"existing_public_str": "data",
|
|
"existing_public_num": 1,
|
|
"existing_public_bool": true,
|
|
"new_key": "new_value"
|
|
}`),
|
|
Private: json.RawMessage(`{
|
|
"existing_private_str": "data",
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0",
|
|
"existing_private_arr_1"
|
|
]
|
|
}`),
|
|
Unsafe: json.RawMessage(`{
|
|
"existing_unsafe_str": "data",
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value"
|
|
}
|
|
}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should merge new fields with existing metadata",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{
|
|
"public_metadata": {
|
|
"existing_public_str": "data_updated",
|
|
"new_key": "new_value"
|
|
},
|
|
"private_metadata": {
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0_updated"
|
|
],
|
|
"new_key": "new_value"
|
|
},
|
|
"unsafe_metadata": {
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value_updated"
|
|
},
|
|
"new_key": "new_value"
|
|
}
|
|
}`),
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Public: json.RawMessage(`{
|
|
"existing_public_str": "data_updated",
|
|
"existing_public_num": 1,
|
|
"existing_public_bool": true,
|
|
"new_key": "new_value"
|
|
}`),
|
|
Private: json.RawMessage(`{
|
|
"existing_private_str": "data",
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0_updated"
|
|
],
|
|
"new_key": "new_value"
|
|
}`),
|
|
Unsafe: json.RawMessage(`{
|
|
"existing_unsafe_str": "data",
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value_updated"
|
|
},
|
|
"new_key": "new_value"
|
|
}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should clear specific metadata fields when sending null",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{
|
|
"public_metadata": null,
|
|
"private_metadata": {"existing_private_str": null},
|
|
"unsafe_metadata": {"existing_unsafe_str": null}
|
|
}`),
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Private: json.RawMessage(`{
|
|
"existing_private_arr": [
|
|
"existing_private_arr_0",
|
|
"existing_private_arr_1"
|
|
]
|
|
}`),
|
|
Unsafe: json.RawMessage(`{
|
|
"existing_unsafe_obj": {
|
|
"existing_unsafe_obj_key": "existing_unsafe_obj_value"
|
|
}
|
|
}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should clear all metadata fields when sending null for all fields",
|
|
userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5",
|
|
patchMetadata: json.RawMessage(`{
|
|
"public_metadata": null,
|
|
"private_metadata": null,
|
|
"unsafe_metadata": null
|
|
}`),
|
|
expectedStatusCode: http.StatusNoContent,
|
|
expectedMetadata: nil,
|
|
},
|
|
{
|
|
name: "should create metadata for user without metadata",
|
|
userId: "38bf5a00-d7ea-40a5-a5de-48722c148925",
|
|
patchMetadata: json.RawMessage(`{
|
|
"public_metadata": {"public_key": "public_value"},
|
|
"private_metadata": {"private_key": "private_value"},
|
|
"unsafe_metadata": {"unsafe_key": "unsafe_value"}
|
|
}`),
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedMetadata: &admin.Metadata{
|
|
Public: json.RawMessage(`{"public_key":"public_value"}`),
|
|
Private: json.RawMessage(`{"private_key":"private_value"}`),
|
|
Unsafe: json.RawMessage(`{"unsafe_key":"unsafe_value"}`),
|
|
},
|
|
},
|
|
{
|
|
name: "should clear all metadata when sending null",
|
|
userId: "38bf5a00-d7ea-40a5-a5de-48722c148925",
|
|
patchMetadata: json.RawMessage(`null`),
|
|
expectedStatusCode: http.StatusNoContent,
|
|
expectedMetadata: nil,
|
|
},
|
|
}
|
|
|
|
for _, currentTest := range tests {
|
|
s.Run(currentTest.name, func() {
|
|
err := s.LoadFixtures("../test/fixtures/metadata")
|
|
s.Require().NoError(err)
|
|
|
|
e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil)
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPatch,
|
|
fmt.Sprintf("/users/%s/metadata", currentTest.userId),
|
|
bytes.NewReader(currentTest.patchMetadata),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
e.ServeHTTP(rec, req)
|
|
|
|
s.Require().Equal(currentTest.expectedStatusCode, rec.Code)
|
|
|
|
if currentTest.expectedStatusCode == http.StatusOK ||
|
|
currentTest.expectedStatusCode == http.StatusNoContent {
|
|
|
|
// Verify the response contains correct updated metadata
|
|
var metadataResponse *admin.Metadata
|
|
if rec.Code == http.StatusOK {
|
|
s.NoError(json.Unmarshal(rec.Body.Bytes(), &metadataResponse))
|
|
} else if rec.Code == http.StatusNoContent {
|
|
s.Nil(rec.Body.Bytes())
|
|
}
|
|
if currentTest.expectedMetadata == nil {
|
|
s.Nil(metadataResponse)
|
|
} else {
|
|
s.NotNil(metadataResponse)
|
|
s.Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Public, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
gjson.GetBytes(metadataResponse.Public, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
)
|
|
s.Require().Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Private, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
gjson.GetBytes(metadataResponse.Private, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
)
|
|
s.Require().Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Unsafe, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
gjson.GetBytes(metadataResponse.Unsafe, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
)
|
|
|
|
// Also verify the metadata was actually updated in the database
|
|
userID, err := uuid.FromString(currentTest.userId)
|
|
s.Require().NoError(err)
|
|
metadataModel, err := s.Storage.GetUserMetadataPersister().Get(userID)
|
|
s.Require().NoError(err)
|
|
if currentTest.expectedMetadata == nil {
|
|
s.False(metadataModel.Public.Valid)
|
|
s.False(metadataModel.Private.Valid)
|
|
s.False(metadataModel.Unsafe.Valid)
|
|
} else {
|
|
if currentTest.expectedMetadata.Public != nil {
|
|
s.True(metadataModel.Public.Valid)
|
|
s.Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Public, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
gjson.GetBytes([]byte(metadataModel.Public.String), `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
)
|
|
} else {
|
|
s.False(metadataModel.Public.Valid)
|
|
}
|
|
if currentTest.expectedMetadata.Private != nil {
|
|
s.True(metadataModel.Private.Valid)
|
|
s.Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Private, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
gjson.GetBytes([]byte(metadataModel.Private.String), `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
)
|
|
} else {
|
|
s.False(metadataModel.Private.Valid)
|
|
}
|
|
if currentTest.expectedMetadata.Unsafe != nil {
|
|
s.True(metadataModel.Unsafe.Valid)
|
|
s.Equal(
|
|
gjson.GetBytes(currentTest.expectedMetadata.Unsafe, `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
gjson.GetBytes([]byte(metadataModel.Unsafe.String), `@pretty:{"sortKeys":true}`).
|
|
String(),
|
|
)
|
|
} else {
|
|
s.False(metadataModel.Unsafe.Valid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const metadataExceedingLimit = `{
|
|
"public_metadata": {
|
|
"users": [
|
|
{
|
|
"id": 1,
|
|
"name": "Alice",
|
|
"email": "alice@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"admin",
|
|
"editor"
|
|
],
|
|
"settings": {
|
|
"theme": "dark",
|
|
"language": "en"
|
|
},
|
|
"preferences": {
|
|
"newsletter": true,
|
|
"notifications": {
|
|
"email": true,
|
|
"sms": false
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-05-07T10:00:00Z",
|
|
"action": "login"
|
|
},
|
|
{
|
|
"timestamp": "2025-05-06T08:32:00Z",
|
|
"action": "update_profile"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 2,
|
|
"name": "Bob",
|
|
"email": "bob@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"user"
|
|
],
|
|
"settings": {
|
|
"theme": "light",
|
|
"language": "fr"
|
|
},
|
|
"preferences": {
|
|
"newsletter": false,
|
|
"notifications": {
|
|
"email": true,
|
|
"sms": true
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-05-06T22:00:00Z",
|
|
"action": "logout"
|
|
},
|
|
{
|
|
"timestamp": "2025-05-05T14:12:00Z",
|
|
"action": "purchase"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 3,
|
|
"name": "Carol",
|
|
"email": "carol@example.com",
|
|
"active": false,
|
|
"roles": [
|
|
"user"
|
|
],
|
|
"settings": {
|
|
"theme": "dark",
|
|
"language": "es"
|
|
},
|
|
"preferences": {
|
|
"newsletter": true,
|
|
"notifications": {
|
|
"email": false,
|
|
"sms": false
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-05-04T19:40:00Z",
|
|
"action": "deactivate_account"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 4,
|
|
"name": "Dan",
|
|
"email": "dan@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"user",
|
|
"moderator"
|
|
],
|
|
"settings": {
|
|
"theme": "light",
|
|
"language": "de"
|
|
},
|
|
"preferences": {
|
|
"newsletter": true,
|
|
"notifications": {
|
|
"email": true,
|
|
"sms": false
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-05-03T08:00:00Z",
|
|
"action": "flag_post"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 5,
|
|
"name": "Eve",
|
|
"email": "eve@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"editor"
|
|
],
|
|
"settings": {
|
|
"theme": "dark",
|
|
"language": "it"
|
|
},
|
|
"preferences": {
|
|
"newsletter": false,
|
|
"notifications": {
|
|
"email": false,
|
|
"sms": true
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-05-01T13:20:00Z",
|
|
"action": "edit_post"
|
|
},
|
|
{
|
|
"timestamp": "2025-04-30T09:45:00Z",
|
|
"action": "comment"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 6,
|
|
"name": "Frank",
|
|
"email": "frank@example.com",
|
|
"active": false,
|
|
"roles": [
|
|
"user"
|
|
],
|
|
"settings": {
|
|
"theme": "light",
|
|
"language": "en"
|
|
},
|
|
"preferences": {
|
|
"newsletter": true,
|
|
"notifications": {
|
|
"email": true,
|
|
"sms": false
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-04-29T10:00:00Z",
|
|
"action": "unsubscribe"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 7,
|
|
"name": "Grace",
|
|
"email": "grace@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"admin"
|
|
],
|
|
"settings": {
|
|
"theme": "dark",
|
|
"language": "en"
|
|
},
|
|
"preferences": {
|
|
"newsletter": false,
|
|
"notifications": {
|
|
"email": false,
|
|
"sms": false
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-04-28T11:30:00Z",
|
|
"action": "ban_user"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 8,
|
|
"name": "Hank",
|
|
"email": "hank@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"user"
|
|
],
|
|
"settings": {
|
|
"theme": "light",
|
|
"language": "pt"
|
|
},
|
|
"preferences": {
|
|
"newsletter": true,
|
|
"notifications": {
|
|
"email": true,
|
|
"sms": true
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-04-27T07:00:00Z",
|
|
"action": "like_post"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 9,
|
|
"name": "Ivy",
|
|
"email": "ivy@example.com",
|
|
"active": false,
|
|
"roles": [
|
|
"editor"
|
|
],
|
|
"settings": {
|
|
"theme": "dark",
|
|
"language": "ja"
|
|
},
|
|
"preferences": {
|
|
"newsletter": false,
|
|
"notifications": {
|
|
"email": true,
|
|
"sms": false
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-04-26T06:00:00Z",
|
|
"action": "edit_post"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": 10,
|
|
"name": "Jack",
|
|
"email": "jack@example.com",
|
|
"active": true,
|
|
"roles": [
|
|
"user"
|
|
],
|
|
"settings": {
|
|
"theme": "light",
|
|
"language": "ru"
|
|
},
|
|
"preferences": {
|
|
"newsletter": true,
|
|
"notifications": {
|
|
"email": false,
|
|
"sms": true
|
|
}
|
|
},
|
|
"activity": [
|
|
{
|
|
"timestamp": "2025-04-25T15:00:00Z",
|
|
"action": "create_post"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`
|