package identityruntime import ( "context" "errors" "testing" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) type tenantSyncRepository struct { targets []store.OIDCTenantBindingSyncTarget synced []string unchanged []string rejected []string failed []string failureNext time.Time } func (repository *tenantSyncRepository) DueOIDCTenantBindingSyncs(context.Context, string, string, int) ([]store.OIDCTenantBindingSyncTarget, error) { return repository.targets, nil } func (repository *tenantSyncRepository) ApplyOIDCTenantBindingSync(_ context.Context, id string, _ identity.TenantContext, unchanged bool, _ time.Time) error { if unchanged { repository.unchanged = append(repository.unchanged, id) } else { repository.synced = append(repository.synced, id) } return nil } func (repository *tenantSyncRepository) RejectOIDCTenantBinding(_ context.Context, id, _ string, _ time.Time) error { repository.rejected = append(repository.rejected, id) return nil } func (repository *tenantSyncRepository) FailOIDCTenantBindingSync(_ context.Context, id, _ string, next time.Time) error { repository.failed = append(repository.failed, id) repository.failureNext = next return nil } type tenantSyncReader struct { tenants map[string]identity.TenantContext unchanged map[string]bool errors map[string]error } func (reader tenantSyncReader) Get(_ context.Context, tenantID, _ string) (identity.TenantContext, bool, error) { return reader.tenants[tenantID], reader.unchanged[tenantID], reader.errors[tenantID] } func TestSynchronizeDueTenantContextsAppliesETagRejectsAndBacksOff(t *testing.T) { now := time.Unix(1_780_000_000, 0).UTC() repository := &tenantSyncRepository{targets: []store.OIDCTenantBindingSyncTarget{ {ID: "binding-a", ExternalTenantID: "tenant-a", ETag: `"a"`, FailureCount: 0}, {ID: "binding-b", ExternalTenantID: "tenant-b", ETag: `"b"`, FailureCount: 0}, {ID: "binding-c", ExternalTenantID: "tenant-c", FailureCount: 3}, {ID: "binding-d", ExternalTenantID: "tenant-d", FailureCount: 0}, }} reader := tenantSyncReader{ tenants: map[string]identity.TenantContext{ "tenant-a": {TenantStatus: "active", TenantApplicationStatus: "active"}, "tenant-d": {TenantStatus: "suspended", TenantApplicationStatus: "active"}, }, unchanged: map[string]bool{"tenant-b": true}, errors: map[string]error{ "tenant-c": identity.ErrTenantContextUnavailable, }, } if err := synchronizeDueTenantContexts(context.Background(), repository, reader, "issuer", "application", now); err != nil { t.Fatal(err) } if len(repository.synced) != 1 || repository.synced[0] != "binding-a" || len(repository.unchanged) != 1 || repository.unchanged[0] != "binding-b" || len(repository.rejected) != 1 || repository.rejected[0] != "binding-d" || len(repository.failed) != 1 || repository.failed[0] != "binding-c" { t.Fatalf("sync outcomes=%#v", repository) } if repository.failureNext.Sub(now) != 4*time.Minute { t.Fatalf("retry delay=%s", repository.failureNext.Sub(now)) } } func TestSynchronizeDueTenantContextsTreatsNotFoundAsRevocation(t *testing.T) { repository := &tenantSyncRepository{targets: []store.OIDCTenantBindingSyncTarget{ {ID: "binding-a", ExternalTenantID: "tenant-a"}, }} reader := tenantSyncReader{ tenants: map[string]identity.TenantContext{}, unchanged: map[string]bool{}, errors: map[string]error{"tenant-a": identity.ErrTenantContextNotFound}, } if err := synchronizeDueTenantContexts( context.Background(), repository, reader, "issuer", "application", time.Now(), ); err != nil { t.Fatal(err) } if len(repository.rejected) != 1 || len(repository.failed) != 0 { t.Fatalf("not-found outcome=%#v", repository) } } func TestTenantContextRetryDelayIsBounded(t *testing.T) { if got := tenantContextRetryDelay(0); got != 30*time.Second { t.Fatalf("first retry=%s", got) } if got := tenantContextRetryDelay(100); got != 5*time.Minute { t.Fatalf("bounded retry=%s", got) } if !errors.Is(identity.ErrTenantContextUnavailable, identity.ErrTenantContextUnavailable) { t.Fatal("sentinel error changed unexpectedly") } }