forked from baron/baron-sso
- 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
258 lines
8.4 KiB
Go
258 lines
8.4 KiB
Go
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)
|
|
})
|
|
}
|