feat: Hosted login translation API (#10011)

# Which Problems Are Solved

This PR implements https://github.com/zitadel/zitadel/issues/9850

# How the Problems Are Solved

  - New protobuf definition
  - Implementation of retrieval of system translations
- Implementation of retrieval and persistence of organization and
instance level translations

# Additional Context

- Closes #9850

# TODO

- [x] Integration tests for Get and Set hosted login translation
endpoints
- [x] DB migration test
- [x] Command function tests
- [x] Command util functions tests
- [x] Query function test
- [x] Query util functions tests
This commit is contained in:
Marco A.
2025-06-18 13:24:39 +02:00
committed by GitHub
parent cddbd3dd47
commit 28f7218ea1
23 changed files with 3613 additions and 527 deletions

View File

@@ -0,0 +1,73 @@
package command
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
func (c *Commands) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (res *settings.SetHostedLoginTranslationResponse, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
var agg eventstore.Aggregate
switch t := req.GetLevel().(type) {
case *settings.SetHostedLoginTranslationRequest_Instance:
agg = instance.NewAggregate(authz.GetInstance(ctx).InstanceID()).Aggregate
case *settings.SetHostedLoginTranslationRequest_OrganizationId:
agg = org.NewAggregate(t.OrganizationId).Aggregate
default:
return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-YB6Sri", "Errors.Arguments.Level.Invalid")
}
lang, err := language.Parse(req.GetLocale())
if err != nil || lang.IsRoot() {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid")
}
commands, wm, err := c.setTranslationEvents(ctx, agg, lang, req.GetTranslations().AsMap())
if err != nil {
return nil, err
}
pushedEvents, err := c.eventstore.Push(ctx, commands...)
if err != nil {
return nil, zerrors.ThrowInternal(err, "COMMA-i8nqFl", "Errors.Internal")
}
err = AppendAndReduce(wm, pushedEvents...)
if err != nil {
return nil, err
}
etag := md5.Sum(fmt.Append(nil, wm.Translation))
return &settings.SetHostedLoginTranslationResponse{
Etag: hex.EncodeToString(etag[:]),
}, nil
}
func (c *Commands) setTranslationEvents(ctx context.Context, agg eventstore.Aggregate, lang language.Tag, translations map[string]any) ([]eventstore.Command, *HostedLoginTranslationWriteModel, error) {
wm := NewHostedLoginTranslationWriteModel(agg.ID)
events := []eventstore.Command{}
switch agg.Type {
case instance.AggregateType:
events = append(events, instance.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang))
case org.AggregateType:
events = append(events, org.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang))
default:
return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid")
}
return events, wm, nil
}

View File

@@ -0,0 +1,45 @@
package command
import (
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/org"
)
type HostedLoginTranslationWriteModel struct {
eventstore.WriteModel
Language language.Tag
Translation map[string]any
Level string
LevelID string
}
func NewHostedLoginTranslationWriteModel(resourceID string) *HostedLoginTranslationWriteModel {
return &HostedLoginTranslationWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: resourceID,
ResourceOwner: resourceID,
},
}
}
func (wm *HostedLoginTranslationWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *org.HostedLoginTranslationSetEvent:
wm.Language = e.Language
wm.Translation = e.Translation
wm.Level = e.Level
wm.LevelID = e.Aggregate().ID
case *instance.HostedLoginTranslationSetEvent:
wm.Language = e.Language
wm.Translation = e.Translation
wm.Level = e.Level
wm.LevelID = e.Aggregate().ID
}
}
return wm.WriteModel.Reduce()
}

View File

@@ -0,0 +1,211 @@
package command
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"google.golang.org/protobuf/types/known/structpb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/service"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
func TestSetTranslationEvents(t *testing.T) {
t.Parallel()
testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"})
testCtx = service.WithService(testCtx, "test-service")
tt := []struct {
testName string
inputAggregate eventstore.Aggregate
inputLanguage language.Tag
inputTranslations map[string]any
expectedCommands []eventstore.Command
expectedWriteModel *HostedLoginTranslationWriteModel
expectedError error
}{
{
testName: "when aggregate type is instance should return matching write model and instance.hosted_login_translation_set event",
inputAggregate: eventstore.Aggregate{ID: "123", Type: instance.AggregateType},
inputLanguage: language.MustParse("en-US"),
inputTranslations: map[string]any{"test": "translation"},
expectedCommands: []eventstore.Command{
instance.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-US")),
},
expectedWriteModel: &HostedLoginTranslationWriteModel{
WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"},
},
},
{
testName: "when aggregate type is org should return matching write model and org.hosted_login_translation_set event",
inputAggregate: eventstore.Aggregate{ID: "123", Type: org.AggregateType},
inputLanguage: language.MustParse("en-GB"),
inputTranslations: map[string]any{"test": "translation"},
expectedCommands: []eventstore.Command{
org.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: org.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-GB")),
},
expectedWriteModel: &HostedLoginTranslationWriteModel{
WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"},
},
},
{
testName: "when aggregate type is neither org nor instance should return invalid argument error",
inputAggregate: eventstore.Aggregate{ID: "123"},
inputLanguage: language.MustParse("en-US"),
inputTranslations: map[string]any{"test": "translation"},
expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid"),
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
c := Commands{}
// When
events, writeModel, err := c.setTranslationEvents(testCtx, tc.inputAggregate, tc.inputLanguage, tc.inputTranslations)
// Verify
require.Equal(t, tc.expectedError, err)
assert.Equal(t, tc.expectedWriteModel, writeModel)
require.Len(t, events, len(tc.expectedCommands))
assert.ElementsMatch(t, tc.expectedCommands, events)
})
}
}
func TestSetHostedLoginTranslation(t *testing.T) {
t.Parallel()
testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"})
testCtx = service.WithService(testCtx, "test-service")
testCtx = authz.WithInstanceID(testCtx, "instance-id")
testTranslation := map[string]any{"test": "translation", "translation": "2"}
protoTranslation, err := structpb.NewStruct(testTranslation)
require.NoError(t, err)
hashTestTranslation := md5.Sum(fmt.Append(nil, testTranslation))
require.NotEmpty(t, hashTestTranslation)
tt := []struct {
testName string
mockPush func(*testing.T) *eventstore.Eventstore
inputReq *settings.SetHostedLoginTranslationRequest
expectedError error
expectedResult *settings.SetHostedLoginTranslationResponse
}{
{
testName: "when locale is malformed should return invalid argument error",
mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} },
inputReq: &settings.SetHostedLoginTranslationRequest{
Level: &settings.SetHostedLoginTranslationRequest_Instance{},
Locale: "123",
},
expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"),
},
{
testName: "when locale is unknown should return invalid argument error",
mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} },
inputReq: &settings.SetHostedLoginTranslationRequest{
Level: &settings.SetHostedLoginTranslationRequest_Instance{},
Locale: "root",
},
expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"),
},
{
testName: "when event pushing fails should return internal error",
mockPush: expectEventstore(expectPushFailed(
errors.New("mock push failed"),
instance.NewHostedLoginTranslationSetEvent(
testCtx, &eventstore.Aggregate{
ID: "instance-id",
Type: instance.AggregateType,
ResourceOwner: "instance-id",
InstanceID: "instance-id",
Version: instance.AggregateVersion,
},
testTranslation,
language.MustParse("it-CH"),
),
)),
inputReq: &settings.SetHostedLoginTranslationRequest{
Level: &settings.SetHostedLoginTranslationRequest_Instance{},
Locale: "it-CH",
Translations: protoTranslation,
},
expectedError: zerrors.ThrowInternal(errors.New("mock push failed"), "COMMA-i8nqFl", "Errors.Internal"),
},
{
testName: "when request is valid should return expected response",
mockPush: expectEventstore(expectPush(
org.NewHostedLoginTranslationSetEvent(
testCtx, &eventstore.Aggregate{
ID: "org-id",
Type: org.AggregateType,
ResourceOwner: "org-id",
InstanceID: "",
Version: org.AggregateVersion,
},
testTranslation,
language.MustParse("it-CH"),
),
)),
inputReq: &settings.SetHostedLoginTranslationRequest{
Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{OrganizationId: "org-id"},
Locale: "it-CH",
Translations: protoTranslation,
},
expectedResult: &settings.SetHostedLoginTranslationResponse{
Etag: hex.EncodeToString(hashTestTranslation[:]),
},
},
}
for _, tc := range tt {
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
// Given
c := Commands{
eventstore: tc.mockPush(t),
}
// When
res, err := c.SetHostedLoginTranslation(testCtx, tc.inputReq)
// Verify
require.Equal(t, tc.expectedError, err)
assert.Equal(t, tc.expectedResult, res)
})
}
}