package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" "encoding/json" "net/http" "strings" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) func TestPruneDeletedTenantReferences_PreservesOtherAllowedTenants(t *testing.T) { metadata := map[string]any{ clientTenantAccessRestrictedKey: true, clientAllowedTenantsKey: []string{"keep-tenant", "deleted-tenant"}, "tenant_id": "deleted-tenant", } updated, changed, removedOwnerTenantID := pruneDeletedTenantReferences(metadata, map[string]struct{}{ "deleted-tenant": {}, }) require.True(t, changed) assert.Equal(t, "deleted-tenant", removedOwnerTenantID) assert.Equal(t, true, updated[clientTenantAccessRestrictedKey]) assert.Equal(t, []string{"keep-tenant"}, updated[clientAllowedTenantsKey]) _, exists := updated["tenant_id"] assert.False(t, exists) } func TestPruneDeletedTenantReferences_DisablesRestrictionWhenLastTenantRemoved(t *testing.T) { metadata := map[string]any{ clientTenantAccessRestrictedKey: true, clientAllowedTenantsKey: []string{"deleted-tenant"}, "tenant_id": "deleted-tenant", } updated, changed, removedOwnerTenantID := pruneDeletedTenantReferences(metadata, map[string]struct{}{ "deleted-tenant": {}, }) require.True(t, changed) assert.Equal(t, "deleted-tenant", removedOwnerTenantID) assert.Equal(t, false, updated[clientTenantAccessRestrictedKey]) _, exists := updated[clientAllowedTenantsKey] assert.False(t, exists) _, exists = updated["tenant_id"] assert.False(t, exists) } func TestCleanupDeletedTenantReferences_PrunesClientsAndRevokesConsents(t *testing.T) { var ( mu sync.Mutex page0Called bool updated = map[string]map[string]any{} revokes []string ) transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { mu.Lock() defer mu.Unlock() switch { case req.Method == http.MethodGet && req.URL.Path == "/clients": switch req.URL.Query().Get("offset") { case "": page0Called = true return httpJSONAny(req, http.StatusOK, []domain.HydraClient{ { ClientID: "client-keep", Metadata: map[string]any{ clientTenantAccessRestrictedKey: true, clientAllowedTenantsKey: []string{"keep-tenant", "deleted-tenant"}, "tenant_id": "deleted-tenant", }, }, { ClientID: "client-drop", Metadata: map[string]any{ clientTenantAccessRestrictedKey: true, clientAllowedTenantsKey: []string{"deleted-tenant"}, "tenant_id": "deleted-tenant", }, }, }), nil default: return httpResponse(req, http.StatusBadRequest, "unexpected offset"), nil } case req.Method == http.MethodPut && strings.HasPrefix(req.URL.Path, "/clients/"): var client domain.HydraClient require.NoError(t, json.NewDecoder(req.Body).Decode(&client)) updated[client.ClientID] = client.Metadata return httpJSONAny(req, http.StatusOK, client), nil case req.Method == http.MethodDelete && req.URL.Path == "/oauth2/auth/sessions/consent": revokes = append(revokes, req.URL.Query().Get("subject")+"|"+req.URL.Query().Get("client")) return httpResponse(req, http.StatusNoContent, ""), nil default: return httpResponse(req, http.StatusNotFound, "unexpected request"), nil } }) hydra := &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, } consentRepo := &mockConsentRepo{ consents: []domain.ClientConsent{ {ClientID: "client-keep", Subject: "user-a"}, {ClientID: "client-drop", Subject: "user-b"}, }, } outbox := &tenantCleanupMockKetoOutboxRepository{} err := cleanupDeletedTenantReferences(context.Background(), hydra, consentRepo, outbox, []string{"deleted-tenant"}) require.NoError(t, err) assert.True(t, page0Called) assert.Equal(t, map[string]any{ clientTenantAccessRestrictedKey: true, clientAllowedTenantsKey: []any{"keep-tenant"}, }, updated["client-keep"]) assert.Equal(t, map[string]any{ clientTenantAccessRestrictedKey: false, }, updated["client-drop"]) assert.ElementsMatch(t, []string{"user-a|client-keep", "user-b|client-drop"}, revokes) assert.Empty(t, consentRepo.consents) require.Len(t, outbox.entries, 2) assert.ElementsMatch(t, []string{"client-keep", "client-drop"}, []string{outbox.entries[0].Object, outbox.entries[1].Object}) for _, entry := range outbox.entries { assert.Equal(t, "RelyingParty", entry.Namespace) assert.Equal(t, "parents", entry.Relation) assert.Equal(t, "Tenant:deleted-tenant", entry.Subject) assert.Equal(t, domain.KetoOutboxActionDelete, entry.Action) } } type tenantCleanupMockKetoOutboxRepository struct { entries []domain.KetoOutbox } var _ repository.KetoOutboxRepository = (*tenantCleanupMockKetoOutboxRepository)(nil) func (m *tenantCleanupMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error { if entry == nil { return nil } m.entries = append(m.entries, *entry) return nil } func (m *tenantCleanupMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error { return m.Create(context.Background(), entry) } func (m *tenantCleanupMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) { return nil, nil } func (m *tenantCleanupMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) { return nil, nil } func (m *tenantCleanupMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error { return nil } func (m *tenantCleanupMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error { return nil }