1
0
forked from baron/baron-sso

kratos SSOT 재설계

This commit is contained in:
2026-06-12 18:36:18 +09:00
parent b96c8100e0
commit 8e9d015443
39 changed files with 3960 additions and 501 deletions

View File

@@ -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)