forked from baron/baron-sso
kratos SSOT 재설계
This commit is contained in:
@@ -981,15 +981,88 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
type identityMirrorRedisStub struct {
|
||||
mockRedisRepo
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||
identities := make([]service.KratosIdentity, 0, len(s.data))
|
||||
for key, raw := range s.data {
|
||||
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
|
||||
continue
|
||||
}
|
||||
var identity service.KratosIdentity
|
||||
if err := json.Unmarshal([]byte(raw), &identity); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(identity.ID) == "" {
|
||||
continue
|
||||
}
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
|
||||
raw := s.data["identity:mirror:state"]
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return domain.IdentityCacheStatus{RedisReady: true, Status: "empty"}, nil
|
||||
}
|
||||
var status domain.IdentityCacheStatus
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return domain.IdentityCacheStatus{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *identityMirrorRedisStub) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
|
||||
var deleted int64
|
||||
for key := range s.data {
|
||||
if strings.HasPrefix(key, "identity:mirror:") || strings.HasPrefix(key, "identity:index:") {
|
||||
delete(s.data, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return domain.IdentityCacheFlushResult{
|
||||
Status: "success",
|
||||
FlushedKeys: deleted,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC)
|
||||
mirrorIdentity := service.KratosIdentity{
|
||||
ID: "mirror-user-1",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "mirror1@example.com",
|
||||
"name": "Mirror One",
|
||||
},
|
||||
}
|
||||
rawMirrorIdentity, err := json.Marshal(mirrorIdentity)
|
||||
require.NoError(t, err)
|
||||
state := domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
MirrorVersion: identityMirrorVersion,
|
||||
ObservedCount: 1,
|
||||
}
|
||||
rawState, err := json.Marshal(state)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
|
||||
"identity:mirror:state": string(rawState),
|
||||
}}},
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -1000,19 +1073,6 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{}, errors.New("kratos down")).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 10, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), "", nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
@@ -1023,19 +1083,21 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(1), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
mockRepo.AssertExpectations(t)
|
||||
require.Equal(t, "mirror-user-1", res.Items[0].ID)
|
||||
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
|
||||
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *testing.T) {
|
||||
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockRepo := new(MockUserRepoForHandler)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
|
||||
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
UserRepo: mockRepo,
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -1046,27 +1108,11 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
kratosIdentities := make([]service.KratosIdentity, 250)
|
||||
for i := range kratosIdentities {
|
||||
kratosIdentities[i] = service.KratosIdentity{
|
||||
ID: "kratos-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt.Add(-time.Duration(i) * time.Second),
|
||||
Traits: map[string]any{"email": "kratos@example.com", "name": "Kratos"},
|
||||
}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Maybe()
|
||||
mockRepo.On("List", mock.Anything, 0, 50, "", []string(nil), "").Return([]domain.User{
|
||||
{
|
||||
ID: "local-user-1",
|
||||
Email: "local1@example.com",
|
||||
Name: "Local One",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
},
|
||||
}, int64(2114), "next-local-cursor", nil)
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -1076,11 +1122,162 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2114), res.Total)
|
||||
require.Len(t, res.Items, 1)
|
||||
require.Equal(t, "local1@example.com", res.Items[0].Email)
|
||||
require.Equal(t, "next-local-cursor", res.NextCursor)
|
||||
mockRepo.AssertExpectations(t)
|
||||
require.Equal(t, int64(2), res.Total)
|
||||
require.Len(t, res.Items, 2)
|
||||
require.Equal(t, "kratos-user-1", res.Items[0].ID)
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
|
||||
var status domain.IdentityCacheStatus
|
||||
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
|
||||
require.Equal(t, "ready", status.Status)
|
||||
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
|
||||
require.Equal(t, int64(2), status.ObservedCount)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
|
||||
}}}
|
||||
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
|
||||
identities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Second), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(identities, nil).Once()
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
count, err := h.WarmIdentityMirror(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, count)
|
||||
require.Empty(t, redis.data[identityMirrorKey("stale-user")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
|
||||
var status domain.IdentityCacheStatus
|
||||
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
|
||||
require.Equal(t, "ready", status.Status)
|
||||
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
|
||||
require.Equal(t, int64(2), status.ObservedCount)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 55, 0, 0, time.UTC)
|
||||
legacyIdentity := service.KratosIdentity{
|
||||
ID: "legacy-partial-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "legacy@example.com",
|
||||
"name": "Legacy Partial",
|
||||
},
|
||||
}
|
||||
rawLegacyIdentity, err := json.Marshal(legacyIdentity)
|
||||
require.NoError(t, err)
|
||||
legacyState := domain.IdentityCacheStatus{
|
||||
Status: "ready",
|
||||
RedisReady: true,
|
||||
ObservedCount: 1,
|
||||
}
|
||||
rawLegacyState, err := json.Marshal(legacyState)
|
||||
require.NoError(t, err)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
|
||||
"identity:mirror:state": string(rawLegacyState),
|
||||
}}}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2), res.Total)
|
||||
var status domain.IdentityCacheStatus
|
||||
require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status))
|
||||
require.Equal(t, identityMirrorVersion, status.MirrorVersion)
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
createdAt := time.Date(2026, 6, 8, 6, 50, 0, 0, time.UTC)
|
||||
partialIdentity := service.KratosIdentity{
|
||||
ID: "partial-user",
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "partial@example.com",
|
||||
"name": "Partial",
|
||||
},
|
||||
}
|
||||
rawPartialIdentity, err := json.Marshal(partialIdentity)
|
||||
require.NoError(t, err)
|
||||
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
|
||||
}}}
|
||||
kratosIdentities := []service.KratosIdentity{
|
||||
{ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}},
|
||||
{ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}},
|
||||
}
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once()
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users", h.ListUsers)
|
||||
|
||||
req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res userListResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||
require.Equal(t, int64(2), res.Total)
|
||||
require.Empty(t, redis.data[identityMirrorKey("partial-user")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")])
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
@@ -1117,6 +1314,86 @@ func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
|
||||
require.Equal(t, int64(3), res.Total)
|
||||
}
|
||||
|
||||
func TestUserHandler_GetUserUsesIdentityMirrorBeforeKratos(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
createdAt := time.Date(2026, 6, 12, 8, 20, 0, 0, time.UTC)
|
||||
userID := "2b7fd276-b25f-45ef-b691-ea9d72e701e1"
|
||||
identity := service.KratosIdentity{
|
||||
ID: userID,
|
||||
State: "active",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
Traits: map[string]any{
|
||||
"email": "mirror-user@example.com",
|
||||
"name": "Mirror User",
|
||||
},
|
||||
}
|
||||
rawIdentity, err := json.Marshal(identity)
|
||||
require.NoError(t, err)
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
identityMirrorKey(userID): string(rawIdentity),
|
||||
}}
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
IdentityCache: redis,
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/users/:id", h.GetUser)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/"+userID, nil)
|
||||
resp, err := app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got userSummary
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
require.Equal(t, userID, got.ID)
|
||||
require.Equal(t, "mirror-user@example.com", got.Email)
|
||||
require.Equal(t, "Mirror User", got.Name)
|
||||
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
|
||||
mockKratos.AssertNotCalled(t, "FindIdentityIDByIdentifier", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
|
||||
}}
|
||||
h := &UserHandler{IdentityCache: redis}
|
||||
identity := service.KratosIdentity{
|
||||
ID: "user-1",
|
||||
Traits: map[string]any{
|
||||
"email": "user1@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
h.updateIdentityMirrorEntry(identity)
|
||||
|
||||
require.Empty(t, redis.data["identity:mirror:state"])
|
||||
require.NotEmpty(t, redis.data[identityMirrorKey("user-1")])
|
||||
}
|
||||
|
||||
func TestUserHandler_DeleteIdentityMirrorEntryInvalidatesReadyState(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: map[string]string{
|
||||
"identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`,
|
||||
identityMirrorKey("u-1"): `{"id":"u-1"}`,
|
||||
}}
|
||||
h := &UserHandler{IdentityCache: redis}
|
||||
|
||||
h.deleteIdentityMirrorEntry("u-1")
|
||||
|
||||
require.Empty(t, redis.data["identity:mirror:state"])
|
||||
require.Empty(t, redis.data[identityMirrorKey("u-1")])
|
||||
}
|
||||
|
||||
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
|
||||
Reference in New Issue
Block a user