Files
zitadel/internal/actions/object/metadata_test.go
T
2bd42e8fc4 fix(actions): allow adding raw metadata values via appendMetadataRaw (#12567)
# Which Problems Are Solved

Since #10666 (v4.1+, backported to v4.x), metadata values set through
actions v1 (`api.metadata.push` and `api.v1.user.appendMetadata`) are
always JSON-encoded via `json.Marshal`. This made the write path
consistent with the JSON-based read path, but removed the ability to
store raw (unencoded) metadata values:

- A scalar string is now always stored quoted (`"de"` instead of `de`).
- The previous byte-array convention (mapping a string to an integer
array in the script, handled by `mapBytesToByteArray` introduced in
#5526) now stores the literal integer-array text (e.g. `[100,101]`)
instead of the raw bytes.

Customers migrating from v3.x whose downstream systems base64-decode
metadata values from tokens and expect raw bytes have no way to produce
them anymore — changing the consuming system is not always possible.

Reverting the default is not an option either, as clients that adopted
actions v1 on v4.x now rely on the JSON encoding.

# How the Problems Are Solved

- Adds a new, opt-in function `api.v1.user.appendMetadataRaw(key,
value)` to the actions v1 login flows (external / internal
authentication post authentication and pre creation), next to the
existing `appendMetadata`.
- The value is stored as raw bytes without JSON encoding:
  - a string is stored as its plain UTF-8 bytes (`de`, not `"de"`)
- byte arrays (`Uint8Array` or a plain array of integers 0-255, the old
convention) are stored as-is, so existing v3 scripts using a
string-to-byte-array helper only need to switch the function name
  - other types (and empty values) throw an error
- The existing `appendMetadata` and `api.metadata.push` behavior remains
byte-for-byte unchanged.

# Additional Changes

- Documented `appendMetadataRaw` (and the JSON encoding behavior of
`appendMetadata`) in the external and internal authentication actions
docs.
- Added unit tests for the new function (through a real goja runtime)
and a test locking in the existing `appendMetadata` JSON-encoding
behavior.

# Additional Context

- Regression introduced as a side effect of #10666 (which fixed #10470);
the raw byte handling was originally introduced in #5526.
- Reported by a customer upgrading from v3.4.x to v4.16.x, whose
PostAuthentication action maps token payload claims into user metadata.
- Requires backport to v4.x.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 15:44:15 +02:00

249 lines
5.4 KiB
Go

package object
import (
"testing"
"github.com/dop251/goja"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
)
func TestMetadataListToDomain(t *testing.T) {
type args struct {
metadataList *MetadataList
}
tests := []struct {
name string
args args
want []*domain.Metadata
}{
{
name: "nil",
args: args{metadataList: nil},
want: nil,
},
{
name: "empty",
args: args{metadataList: &MetadataList{}},
want: []*domain.Metadata{},
},
{
name: "from mapped value",
args: args{metadataList: &MetadataList{
metadata: []*Metadata{
{
Key: "key1",
value: []byte("value1"),
},
},
}},
want: []*domain.Metadata{
{
Key: "key1",
Value: []byte("value1"),
},
},
},
{
name: "from goja value string",
args: args{metadataList: &MetadataList{
metadata: []*Metadata{
{
Key: "key1",
Value: (&goja.Runtime{}).ToValue("value1"),
},
},
}},
want: []*domain.Metadata{
{
Key: "key1",
Value: []byte(`"value1"`),
},
},
},
{
name: "from goja value int",
args: args{metadataList: &MetadataList{
metadata: []*Metadata{
{
Key: "key1",
Value: (&goja.Runtime{}).ToValue(1),
},
},
}},
want: []*domain.Metadata{
{
Key: "key1",
Value: []byte("1"),
},
},
},
{
name: "from goja value float",
args: args{metadataList: &MetadataList{
metadata: []*Metadata{
{
Key: "key1",
Value: (&goja.Runtime{}).ToValue(1.2),
},
},
}},
want: []*domain.Metadata{
{
Key: "key1",
Value: []byte("1.2"),
},
},
},
{
name: "from goja value bool",
args: args{metadataList: &MetadataList{
metadata: []*Metadata{
{
Key: "key1",
Value: (&goja.Runtime{}).ToValue(true),
},
},
}},
want: []*domain.Metadata{
{
Key: "key1",
Value: []byte("true"),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, MetadataListToDomain(tt.args.metadataList))
})
}
}
func TestAppendMetadataRawFunc(t *testing.T) {
tests := []struct {
name string
script string
want []*domain.Metadata
}{
{
name: "string is stored as raw bytes without quotes",
script: `appendMetadataRaw("locale", "de")`,
want: []*domain.Metadata{
{
Key: "locale",
Value: []byte("de"),
},
},
},
{
name: "json string is stored verbatim",
script: `appendMetadataRaw("tokenPayload", '{"acr":"phrh"}')`,
want: []*domain.Metadata{
{
Key: "tokenPayload",
Value: []byte(`{"acr":"phrh"}`),
},
},
},
{
name: "uint8 array is stored as raw bytes",
script: `appendMetadataRaw("locale", new Uint8Array([100, 101]))`,
want: []*domain.Metadata{
{
Key: "locale",
Value: []byte("de"),
},
},
},
{
name: "integer array is stored as raw bytes",
script: `appendMetadataRaw("locale", [100, 101])`,
want: []*domain.Metadata{
{
Key: "locale",
Value: []byte("de"),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runtime := goja.New()
metadataList := &MetadataList{}
require.NoError(t, runtime.Set("appendMetadataRaw", metadataList.AppendMetadataRawFunc))
_, err := runtime.RunString(tt.script)
require.NoError(t, err)
assert.Equal(t, tt.want, MetadataListToDomain(metadataList))
})
}
}
func TestAppendMetadataRawFuncPanics(t *testing.T) {
runtime := goja.New()
tests := []struct {
name string
args []goja.Value
}{
{
name: "wrong argument count",
args: []goja.Value{runtime.ToValue("locale")},
},
{
name: "empty string value",
args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue("")},
},
{
name: "empty array value",
args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue([]interface{}{})},
},
{
name: "number value",
args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue(1)},
},
{
name: "object value",
args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue(map[string]interface{}{"locale": "de"})},
},
{
name: "array value out of byte range",
args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue([]interface{}{int64(300)})},
},
{
name: "array value with non integer",
args: []goja.Value{runtime.ToValue("locale"), runtime.ToValue([]interface{}{1.5})},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
metadataList := &MetadataList{}
assert.Panics(t, func() {
metadataList.AppendMetadataRawFunc(goja.FunctionCall{Arguments: tt.args})
})
})
}
}
// TestAppendMetadataFuncJSONEncodes locks in that the existing appendMetadata
// still JSON-encodes its value (a string is stored quoted), in contrast to appendMetadataRaw.
func TestAppendMetadataFuncJSONEncodes(t *testing.T) {
runtime := goja.New()
metadataList := &MetadataList{}
require.NoError(t, runtime.Set("appendMetadata", metadataList.AppendMetadataFunc))
require.NoError(t, runtime.Set("appendMetadataRaw", metadataList.AppendMetadataRawFunc))
_, err := runtime.RunString(`appendMetadata("locale", "de"); appendMetadataRaw("locale", "de")`)
require.NoError(t, err)
assert.Equal(t, []*domain.Metadata{
{
Key: "locale",
Value: []byte(`"de"`),
},
{
Key: "locale",
Value: []byte(`de`),
},
}, MetadataListToDomain(metadataList))
}