Files
hanko/backend/handler/metadata_admin_test.go
2025-09-25 19:15:20 +02:00

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"
}
]
}
]
}
}`