支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
114 lines
4.1 KiB
Go
114 lines
4.1 KiB
Go
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")
|
|
}
|
|
}
|