forked from baron/baron-sso
feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
257
backend/internal/handler/user_handler_uuid_test.go
Normal file
257
backend/internal/handler/user_handler_uuid_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestUserHandler_BulkCreateUsers_UUIDSupport(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
|
||||
app.Post("/users/bulk", h.BulkCreateUsers)
|
||||
|
||||
t.Run("Success - Provided UUID", func(t *testing.T) {
|
||||
testUuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
|
||||
// 1. Search-first check: Simulate user NOT found by this UUID
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
|
||||
|
||||
// 2. Create identity
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user.ID == testUuid && user.Email == "uuid@test.com"
|
||||
}), mock.Anything).Return(testUuid, nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "uuid@test.com",
|
||||
"name": "UUID User",
|
||||
"tenantSlug": "test-tenant",
|
||||
"id": testUuid,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Results []bulkUserResult `json:"results"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Results, 1)
|
||||
assert.True(t, result.Results[0].Success)
|
||||
assert.Equal(t, testUuid, result.Results[0].UserID)
|
||||
})
|
||||
|
||||
t.Run("Success - Provided UUID Already Exists (Idempotency)", func(t *testing.T) {
|
||||
testUuid := "550e8400-e29b-41d4-a716-446655440005"
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
|
||||
// 1. Search-first check: Simulate user FOUND by this UUID
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return(testUuid, nil).Once()
|
||||
|
||||
// 2. Fetch existing identity for field comparison
|
||||
mockKratos.On("GetIdentity", mock.Anything, testUuid).Return(&service.KratosIdentity{
|
||||
ID: testUuid,
|
||||
Traits: map[string]any{
|
||||
"email": "existing@test.com",
|
||||
"name": "Old Name",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "existing@test.com",
|
||||
"name": "Existing User",
|
||||
"tenantSlug": "test-tenant",
|
||||
"id": testUuid,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Results []bulkUserResult `json:"results"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Results, 1)
|
||||
assert.True(t, result.Results[0].Success)
|
||||
assert.Equal(t, testUuid, result.Results[0].UserID)
|
||||
assert.Equal(t, "updated", result.Results[0].Status)
|
||||
// Should have "Name" in modified fields since we changed from "Old Name" to "Existing User"
|
||||
assert.Contains(t, result.Results[0].ModifiedFields, "Name")
|
||||
})
|
||||
|
||||
t.Run("Fail - Duplicate UUID Conflict", func(t *testing.T) {
|
||||
testUuid := "550e8400-e29b-41d4-a716-446655440001"
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
|
||||
// 1. Search-first check: Simulate NOT found by this UUID (initial check)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
|
||||
|
||||
// 2. Create identity: Simulate Ory returning a 409 conflict for the UUID
|
||||
conflictErr := fmt.Errorf("ory provider: identity already exists for uuid=%s", testUuid)
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user.ID == testUuid
|
||||
}), mock.Anything).Return("", conflictErr).Once()
|
||||
|
||||
// 3. Conflict double-check: Search by EMAIL to see if it's the same person
|
||||
// If NOT found by email, it means it's a different person using this UUID
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "conflict@test.com").Return("", nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "conflict@test.com",
|
||||
"name": "Conflict User",
|
||||
"tenantSlug": "test-tenant",
|
||||
"uuid": testUuid,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Results []bulkUserResult `json:"results"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Results, 1)
|
||||
assert.False(t, result.Results[0].Success)
|
||||
assert.Contains(t, result.Results[0].Message, "Conflict: UUID already exists")
|
||||
})
|
||||
|
||||
t.Run("Fail - Invalid UUID Format", func(t *testing.T) {
|
||||
invalidUuid := "not-a-uuid"
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
|
||||
// 1. Search-first check (even for invalid format, it currently tries to search)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, invalidUuid).Return("", nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "invalid@test.com",
|
||||
"name": "Invalid User",
|
||||
"tenantSlug": "test-tenant",
|
||||
"id": invalidUuid,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Results []bulkUserResult `json:"results"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Results, 1)
|
||||
assert.False(t, result.Results[0].Success)
|
||||
assert.Contains(t, result.Results[0].Message, "invalid UUID format")
|
||||
})
|
||||
|
||||
t.Run("Success - Import after Delete (Visibility Check)", func(t *testing.T) {
|
||||
// This test is to ensure that if a user is deleted and then re-imported with the same UUID,
|
||||
// the process succeeds and ideally would result in a visible user.
|
||||
// NOTE: In this unit test we mock the Repository, so we can't fully test DB behavior,
|
||||
// but we can verify the Handler logic flow.
|
||||
|
||||
testUuid := "550e8400-e29b-41d4-a716-446655440007"
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
|
||||
// 1. Search-first: Simulate NOT found (it was deleted from Kratos)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, testUuid).Return("", nil).Once()
|
||||
|
||||
// 2. Create identity: Succeeds and returns a NEW Kratos ID
|
||||
newKratosId := "new-kratos-id-after-delete"
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
return user.ID == testUuid
|
||||
}), mock.Anything).Return(newKratosId, nil).Once()
|
||||
|
||||
payload := map[string]any{
|
||||
"users": []map[string]any{
|
||||
{
|
||||
"email": "reimport@test.com",
|
||||
"name": "Re-import User",
|
||||
"tenantSlug": "test-tenant",
|
||||
"id": testUuid,
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Results []bulkUserResult `json:"results"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.True(t, result.Results[0].Success)
|
||||
assert.Equal(t, testUuid, result.Results[0].UserID)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user