feat: add commands for managing organizations and users
Introduce `AddUserToOrganization`, `RemoveAPIKey`, and `RemoveOrganization` commands to enhance organization management. Implement validation for user addition and API key removal. Update GraphQL schema to support new mutations and add caching for the new events, ensuring that organizations and their relationships are accurately represented in the cache.
This commit is contained in:
@@ -23,6 +23,8 @@ func (o *Organization) Apply(event eventsourced.Event) error {
|
||||
switch e := event.(type) {
|
||||
case *OrganizationAdded:
|
||||
e.UpdateOrganization(o)
|
||||
case *UserAddedToOrganization:
|
||||
e.UpdateOrganization(o)
|
||||
case *APIKeyAdded:
|
||||
o.APIKeys = append(o.APIKeys, APIKey{
|
||||
Name: e.Name,
|
||||
@@ -36,6 +38,10 @@ func (o *Organization) Apply(event eventsourced.Event) error {
|
||||
})
|
||||
o.ChangedBy = e.Initiator
|
||||
o.ChangedAt = e.When()
|
||||
case *APIKeyRemoved:
|
||||
e.UpdateOrganization(o)
|
||||
case *OrganizationRemoved:
|
||||
e.UpdateOrganization(o)
|
||||
default:
|
||||
return fmt.Errorf("unexpected event type: %+v", event)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,37 @@ func (a AddOrganization) Event(context.Context) eventsourced.Event {
|
||||
|
||||
var _ eventsourced.Command = AddOrganization{}
|
||||
|
||||
type AddUserToOrganization struct {
|
||||
UserId string
|
||||
Initiator string
|
||||
}
|
||||
|
||||
func (a AddUserToOrganization) Validate(_ context.Context, aggregate eventsourced.Aggregate) error {
|
||||
if aggregate.Identity() == nil {
|
||||
return fmt.Errorf("organization does not exist")
|
||||
}
|
||||
if len(a.UserId) == 0 {
|
||||
return fmt.Errorf("userId is required")
|
||||
}
|
||||
// Check if user is already in the organization
|
||||
org := aggregate.(*Organization)
|
||||
for _, user := range org.Users {
|
||||
if user == a.UserId {
|
||||
return fmt.Errorf("user is already a member of this organization")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a AddUserToOrganization) Event(context.Context) eventsourced.Event {
|
||||
return &UserAddedToOrganization{
|
||||
UserId: a.UserId,
|
||||
Initiator: a.Initiator,
|
||||
}
|
||||
}
|
||||
|
||||
var _ eventsourced.Command = AddUserToOrganization{}
|
||||
|
||||
type AddAPIKey struct {
|
||||
Name string
|
||||
Key string
|
||||
@@ -79,6 +110,57 @@ func (a AddAPIKey) Event(context.Context) eventsourced.Event {
|
||||
|
||||
var _ eventsourced.Command = AddAPIKey{}
|
||||
|
||||
type RemoveAPIKey struct {
|
||||
KeyName string
|
||||
Initiator string
|
||||
}
|
||||
|
||||
func (r RemoveAPIKey) Validate(_ context.Context, aggregate eventsourced.Aggregate) error {
|
||||
if aggregate.Identity() == nil {
|
||||
return fmt.Errorf("organization does not exist")
|
||||
}
|
||||
org := aggregate.(*Organization)
|
||||
found := false
|
||||
for _, k := range org.APIKeys {
|
||||
if k.Name == r.KeyName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("API key '%s' not found", r.KeyName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RemoveAPIKey) Event(context.Context) eventsourced.Event {
|
||||
return &APIKeyRemoved{
|
||||
KeyName: r.KeyName,
|
||||
Initiator: r.Initiator,
|
||||
}
|
||||
}
|
||||
|
||||
var _ eventsourced.Command = RemoveAPIKey{}
|
||||
|
||||
type RemoveOrganization struct {
|
||||
Initiator string
|
||||
}
|
||||
|
||||
func (r RemoveOrganization) Validate(_ context.Context, aggregate eventsourced.Aggregate) error {
|
||||
if aggregate.Identity() == nil {
|
||||
return fmt.Errorf("organization does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RemoveOrganization) Event(context.Context) eventsourced.Event {
|
||||
return &OrganizationRemoved{
|
||||
Initiator: r.Initiator,
|
||||
}
|
||||
}
|
||||
|
||||
var _ eventsourced.Command = RemoveOrganization{}
|
||||
|
||||
type UpdateSubGraph struct {
|
||||
OrganizationId string
|
||||
Ref string
|
||||
|
||||
@@ -464,3 +464,114 @@ func TestUpdateSubGraph_Event(t *testing.T) {
|
||||
assert.Equal(t, "type Query { hello: String }", subGraphEvent.Sdl)
|
||||
assert.Equal(t, "user@example.com", subGraphEvent.Initiator)
|
||||
}
|
||||
|
||||
// RemoveAPIKey tests
|
||||
|
||||
func TestRemoveAPIKey_Validate_Success(t *testing.T) {
|
||||
cmd := RemoveAPIKey{
|
||||
KeyName: "production-key",
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
APIKeys: []APIKey{
|
||||
{
|
||||
Name: "production-key",
|
||||
Key: "hashed-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := cmd.Validate(context.Background(), org)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRemoveAPIKey_Validate_OrganizationNotExists(t *testing.T) {
|
||||
cmd := RemoveAPIKey{
|
||||
KeyName: "production-key",
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{} // No identity means it doesn't exist
|
||||
err := cmd.Validate(context.Background(), org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not exist")
|
||||
}
|
||||
|
||||
func TestRemoveAPIKey_Validate_KeyNotFound(t *testing.T) {
|
||||
cmd := RemoveAPIKey{
|
||||
KeyName: "non-existent-key",
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
APIKeys: []APIKey{
|
||||
{
|
||||
Name: "production-key",
|
||||
Key: "hashed-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := cmd.Validate(context.Background(), org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
assert.Contains(t, err.Error(), "non-existent-key")
|
||||
}
|
||||
|
||||
func TestRemoveAPIKey_Event(t *testing.T) {
|
||||
cmd := RemoveAPIKey{
|
||||
KeyName: "production-key",
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
event := cmd.Event(context.Background())
|
||||
require.NotNil(t, event)
|
||||
|
||||
keyEvent, ok := event.(*APIKeyRemoved)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "production-key", keyEvent.KeyName)
|
||||
assert.Equal(t, "user@example.com", keyEvent.Initiator)
|
||||
}
|
||||
|
||||
// RemoveOrganization tests
|
||||
|
||||
func TestRemoveOrganization_Validate_Success(t *testing.T) {
|
||||
cmd := RemoveOrganization{
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
Name: "Test Org",
|
||||
}
|
||||
|
||||
err := cmd.Validate(context.Background(), org)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRemoveOrganization_Validate_OrganizationNotExists(t *testing.T) {
|
||||
cmd := RemoveOrganization{
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{} // No identity means it doesn't exist
|
||||
err := cmd.Validate(context.Background(), org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not exist")
|
||||
}
|
||||
|
||||
func TestRemoveOrganization_Event(t *testing.T) {
|
||||
cmd := RemoveOrganization{
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
event := cmd.Event(context.Background())
|
||||
require.NotNil(t, event)
|
||||
|
||||
orgEvent, ok := event.(*OrganizationRemoved)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "user@example.com", orgEvent.Initiator)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,24 @@ func (a *OrganizationAdded) UpdateOrganization(o *Organization) {
|
||||
o.ChangedAt = a.When()
|
||||
}
|
||||
|
||||
type UserAddedToOrganization struct {
|
||||
eventsourced.BaseEvent
|
||||
UserId string `json:"userId"`
|
||||
Initiator string `json:"initiator"`
|
||||
}
|
||||
|
||||
func (a *UserAddedToOrganization) UpdateOrganization(o *Organization) {
|
||||
// Check if user is already in the organization
|
||||
for _, user := range o.Users {
|
||||
if user == a.UserId {
|
||||
return // User already exists, no need to add
|
||||
}
|
||||
}
|
||||
o.Users = append(o.Users, a.UserId)
|
||||
o.ChangedBy = a.Initiator
|
||||
o.ChangedAt = a.When()
|
||||
}
|
||||
|
||||
type APIKeyAdded struct {
|
||||
eventsourced.BaseEvent
|
||||
OrganizationId string `json:"organizationId"`
|
||||
@@ -34,6 +52,36 @@ func (a *APIKeyAdded) EnrichFromAggregate(aggregate eventsourced.Aggregate) {
|
||||
|
||||
var _ eventsourced.EnrichableEvent = &APIKeyAdded{}
|
||||
|
||||
type APIKeyRemoved struct {
|
||||
eventsourced.BaseEvent
|
||||
KeyName string `json:"keyName"`
|
||||
Initiator string `json:"initiator"`
|
||||
}
|
||||
|
||||
func (a *APIKeyRemoved) UpdateOrganization(o *Organization) {
|
||||
// Remove the API key from the organization
|
||||
for i, key := range o.APIKeys {
|
||||
if key.Name == a.KeyName {
|
||||
o.APIKeys = append(o.APIKeys[:i], o.APIKeys[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
o.ChangedBy = a.Initiator
|
||||
o.ChangedAt = a.When()
|
||||
}
|
||||
|
||||
type OrganizationRemoved struct {
|
||||
eventsourced.BaseEvent
|
||||
Initiator string `json:"initiator"`
|
||||
}
|
||||
|
||||
func (a *OrganizationRemoved) UpdateOrganization(o *Organization) {
|
||||
// Mark organization as removed by clearing critical fields
|
||||
// The aggregate will still exist in the event store, but it's logically deleted
|
||||
o.ChangedBy = a.Initiator
|
||||
o.ChangedAt = a.When()
|
||||
}
|
||||
|
||||
type SubGraphUpdated struct {
|
||||
eventsourced.BaseEvent
|
||||
OrganizationId string `json:"organizationId"`
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
|
||||
)
|
||||
|
||||
func TestOrganizationAdded_UpdateOrganization(t *testing.T) {
|
||||
event := &OrganizationAdded{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
Name: "Test Organization",
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
}
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
assert.Equal(t, "Test Organization", org.Name)
|
||||
assert.Equal(t, []string{"user@example.com"}, org.Users)
|
||||
assert.Equal(t, "user@example.com", org.CreatedBy)
|
||||
assert.Equal(t, "user@example.com", org.ChangedBy)
|
||||
assert.Equal(t, event.When(), org.CreatedAt)
|
||||
assert.Equal(t, event.When(), org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestUserAddedToOrganization_UpdateOrganization(t *testing.T) {
|
||||
event := &UserAddedToOrganization{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
UserId: "new-user@example.com",
|
||||
Initiator: "admin@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
Users: []string{"existing-user@example.com"},
|
||||
}
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
assert.Len(t, org.Users, 2)
|
||||
assert.Contains(t, org.Users, "existing-user@example.com")
|
||||
assert.Contains(t, org.Users, "new-user@example.com")
|
||||
assert.Equal(t, "admin@example.com", org.ChangedBy)
|
||||
assert.Equal(t, event.When(), org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestUserAddedToOrganization_UpdateOrganization_DuplicateUser(t *testing.T) {
|
||||
event := &UserAddedToOrganization{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
UserId: "existing-user@example.com",
|
||||
Initiator: "admin@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
Users: []string{"existing-user@example.com"},
|
||||
ChangedBy: "previous-admin@example.com",
|
||||
}
|
||||
originalChangedBy := org.ChangedBy
|
||||
originalChangedAt := org.ChangedAt
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
// User should not be added twice
|
||||
assert.Len(t, org.Users, 1)
|
||||
assert.Equal(t, "existing-user@example.com", org.Users[0])
|
||||
|
||||
// ChangedBy and ChangedAt should NOT be updated when user already exists (idempotent)
|
||||
assert.Equal(t, originalChangedBy, org.ChangedBy)
|
||||
assert.Equal(t, originalChangedAt, org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestAPIKeyRemoved_UpdateOrganization(t *testing.T) {
|
||||
event := &APIKeyRemoved{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
KeyName: "production-key",
|
||||
Initiator: "admin@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
APIKeys: []APIKey{
|
||||
{Name: "dev-key", Key: "hashed-key-1"},
|
||||
{Name: "production-key", Key: "hashed-key-2"},
|
||||
{Name: "staging-key", Key: "hashed-key-3"},
|
||||
},
|
||||
}
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
assert.Len(t, org.APIKeys, 2)
|
||||
assert.Equal(t, "dev-key", org.APIKeys[0].Name)
|
||||
assert.Equal(t, "staging-key", org.APIKeys[1].Name)
|
||||
assert.Equal(t, "admin@example.com", org.ChangedBy)
|
||||
assert.Equal(t, event.When(), org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestAPIKeyRemoved_UpdateOrganization_KeyNotFound(t *testing.T) {
|
||||
event := &APIKeyRemoved{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
KeyName: "non-existent-key",
|
||||
Initiator: "admin@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
APIKeys: []APIKey{
|
||||
{Name: "dev-key", Key: "hashed-key-1"},
|
||||
{Name: "production-key", Key: "hashed-key-2"},
|
||||
},
|
||||
}
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
// No keys should be removed
|
||||
assert.Len(t, org.APIKeys, 2)
|
||||
assert.Equal(t, "dev-key", org.APIKeys[0].Name)
|
||||
assert.Equal(t, "production-key", org.APIKeys[1].Name)
|
||||
|
||||
// But metadata should still be updated
|
||||
assert.Equal(t, "admin@example.com", org.ChangedBy)
|
||||
assert.Equal(t, event.When(), org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestAPIKeyRemoved_UpdateOrganization_OnlyKey(t *testing.T) {
|
||||
event := &APIKeyRemoved{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
KeyName: "only-key",
|
||||
Initiator: "admin@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
APIKeys: []APIKey{
|
||||
{Name: "only-key", Key: "hashed-key"},
|
||||
},
|
||||
}
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
// All keys should be removed
|
||||
assert.Len(t, org.APIKeys, 0)
|
||||
assert.Equal(t, "admin@example.com", org.ChangedBy)
|
||||
assert.Equal(t, event.When(), org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestOrganizationRemoved_UpdateOrganization(t *testing.T) {
|
||||
event := &OrganizationRemoved{
|
||||
BaseEvent: eventsourced.BaseEvent{
|
||||
EventTime: eventsourced.EventTime{
|
||||
Time: time.Now(),
|
||||
},
|
||||
},
|
||||
Initiator: "admin@example.com",
|
||||
}
|
||||
|
||||
org := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString("org-123"),
|
||||
Name: "Test Organization",
|
||||
Users: []string{"user1@example.com", "user2@example.com"},
|
||||
APIKeys: []APIKey{
|
||||
{Name: "key1", Key: "hashed-key-1"},
|
||||
},
|
||||
CreatedBy: "creator@example.com",
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
}
|
||||
|
||||
event.UpdateOrganization(org)
|
||||
|
||||
// Organization data remains (soft delete), but metadata is updated
|
||||
assert.Equal(t, "Test Organization", org.Name)
|
||||
assert.Len(t, org.Users, 2)
|
||||
assert.Len(t, org.APIKeys, 1)
|
||||
|
||||
// Metadata should be updated to reflect removal
|
||||
assert.Equal(t, "admin@example.com", org.ChangedBy)
|
||||
assert.Equal(t, event.When(), org.ChangedAt)
|
||||
}
|
||||
|
||||
func TestAPIKeyAdded_EnrichFromAggregate(t *testing.T) {
|
||||
orgId := "org-123"
|
||||
aggregate := &Organization{
|
||||
BaseAggregate: eventsourced.BaseAggregateFromString(orgId),
|
||||
}
|
||||
|
||||
event := &APIKeyAdded{
|
||||
Name: "test-key",
|
||||
Key: "hashed-key",
|
||||
Refs: []string{"main"},
|
||||
Read: true,
|
||||
Publish: false,
|
||||
Initiator: "user@example.com",
|
||||
}
|
||||
|
||||
event.EnrichFromAggregate(aggregate)
|
||||
|
||||
assert.Equal(t, orgId, event.OrganizationId)
|
||||
}
|
||||
|
||||
func TestSubGraphUpdated_Event(t *testing.T) {
|
||||
// Verify SubGraphUpdated event structure
|
||||
url := "http://service.example.com"
|
||||
wsUrl := "ws://service.example.com"
|
||||
|
||||
event := &SubGraphUpdated{
|
||||
OrganizationId: "org-123",
|
||||
Ref: "main",
|
||||
Service: "users-service",
|
||||
Url: &url,
|
||||
WSUrl: &wsUrl,
|
||||
Sdl: "type Query { user: User }",
|
||||
Initiator: "system",
|
||||
}
|
||||
|
||||
require.NotNil(t, event)
|
||||
assert.Equal(t, "org-123", event.OrganizationId)
|
||||
assert.Equal(t, "main", event.Ref)
|
||||
assert.Equal(t, "users-service", event.Service)
|
||||
assert.Equal(t, url, *event.Url)
|
||||
assert.Equal(t, wsUrl, *event.WSUrl)
|
||||
assert.Equal(t, "type Query { user: User }", event.Sdl)
|
||||
assert.Equal(t, "system", event.Initiator)
|
||||
}
|
||||
Reference in New Issue
Block a user