easyai-ai-gateway/apps/api/internal/securityevents/retirement_worker_test.go
chengcheng a312ad880d fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。

验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
2026-07-17 18:31:12 +08:00

179 lines
6.9 KiB
Go

package securityevents
import (
"context"
"errors"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
func TestSecurityEventRetirementWorkerOutlivesCanceledRuntime(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC())
repository := &memoryConnectionRepository{connection: &connection}
secrets := &memorySecretStore{values: retirementWorkerTestSecrets(connection)}
runtimeCtx, cancelRuntime := context.WithCancel(context.Background())
manager := &ConnectionManager{
ctx: runtimeCtx, repository: repository, secrets: secrets,
config: ConnectionManagerConfig{RetirementDuration: 40 * time.Millisecond},
}
go manager.finishRetirement(connection)
cancelRuntime()
time.Sleep(10 * time.Millisecond)
if _, err := repository.SecurityEventConnection(context.Background()); err != nil {
t.Fatalf("runtime cancellation unexpectedly finalized retirement: %v", err)
}
serverCtx, cancelServer := context.WithCancel(context.Background())
defer cancelServer()
go runSecurityEventRetirementWorker(serverCtx, repository, 40*time.Millisecond, 5*time.Millisecond)
waitForRetirementFinalization(t, repository)
for reference := range retirementWorkerTestSecrets(connection) {
repository.mutex.Lock()
_, queued := repository.cleanupDue[reference]
repository.mutex.Unlock()
if !queued {
t.Fatalf("retired Secret %q was not durably queued", reference)
}
if !secrets.Has(reference) {
t.Fatalf("retirement worker directly deleted Secret %q before the durable cleanup worker claimed it", reference)
}
}
}
func TestSecurityEventRetirementWorkerResumesAfterServerRestart(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC())
repository := &memoryConnectionRepository{connection: &connection}
firstCtx, stopFirst := context.WithCancel(context.Background())
go runSecurityEventRetirementWorker(firstCtx, repository, 80*time.Millisecond, 5*time.Millisecond)
time.Sleep(15 * time.Millisecond)
stopFirst()
if _, err := repository.SecurityEventConnection(context.Background()); err != nil {
t.Fatalf("not-yet-due retirement disappeared before restart: %v", err)
}
time.Sleep(75 * time.Millisecond)
secondCtx, stopSecond := context.WithCancel(context.Background())
defer stopSecond()
go runSecurityEventRetirementWorker(secondCtx, repository, 80*time.Millisecond, 5*time.Millisecond)
waitForRetirementFinalization(t, repository)
}
func TestConcurrentSecurityEventRetirementWorkersFinalizeOneGenerationOnce(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
repository := &memoryConnectionRepository{connection: &connection}
start := make(chan struct{})
done := make(chan struct{}, 2)
for range 2 {
go func() {
<-start
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
done <- struct{}{}
}()
}
close(start)
<-done
<-done
if _, err := repository.SecurityEventConnection(context.Background()); !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
t.Fatalf("current retiring generation still exists: %v", err)
}
repository.mutex.Lock()
finalizeCalls := repository.finalizeCalls
queued := len(repository.cleanupDue)
repository.mutex.Unlock()
if finalizeCalls != 1 {
t.Fatalf("retirement generation finalized %d times, want 1", finalizeCalls)
}
if queued != len(retirementWorkerTestSecrets(connection)) {
t.Fatalf("queued Secret references=%d want=%d", queued, len(retirementWorkerTestSecrets(connection)))
}
}
func TestSecurityEventRetirementWorkerPreservesChangedGeneration(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
initialVersion := connection.Version
repository := &memoryConnectionRepository{connection: &connection}
repository.beforeFinalize = func(current *store.SecurityEventConnection) {
current.LifecycleStatus = "enabled"
current.Version++
}
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
current, err := repository.SecurityEventConnection(context.Background())
if err != nil || current.LifecycleStatus != "enabled" || current.Version != initialVersion+1 {
t.Fatalf("changed generation was not preserved: lifecycle=%q version=%d err=%v", current.LifecycleStatus, current.Version, err)
}
repository.mutex.Lock()
queued := len(repository.cleanupDue)
repository.mutex.Unlock()
if queued != 0 {
t.Fatalf("changed generation queued %d active Secret references", queued)
}
}
func TestSecurityEventRetirementWorkerKeepsConnectionWhenCleanupQueueCannotAdoptReferences(t *testing.T) {
connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute))
repository := &memoryConnectionRepository{
connection: &connection,
cleanupClaims: map[string]string{connection.CredentialRef: uuid.NewString()},
}
finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond)
current, err := repository.SecurityEventConnection(context.Background())
if err != nil || current.ConnectionID != connection.ConnectionID || current.LifecycleStatus != "retiring" {
t.Fatalf("failed durable handoff lost retiring connection: lifecycle=%q err=%v", current.LifecycleStatus, err)
}
repository.mutex.Lock()
finalizeCalls := repository.finalizeCalls
repository.mutex.Unlock()
if finalizeCalls != 0 {
t.Fatalf("failed durable handoff finalized generation %d times", finalizeCalls)
}
}
func retiringWorkerTestConnection(updatedAt time.Time) store.SecurityEventConnection {
managementReference := "ssf-management-retirement-worker-" + uuid.NewString()
nextReference := "ssf-push-next-retirement-worker-" + uuid.NewString()
return store.SecurityEventConnection{
ConnectionID: uuid.NewString(),
CredentialRef: "ssf-push-retirement-worker-" + uuid.NewString(),
NextCredentialRef: &nextReference,
ManagementCredentialRef: &managementReference,
LifecycleStatus: "retiring",
Version: 9,
UpdatedAt: updatedAt,
}
}
func retirementWorkerTestSecrets(connection store.SecurityEventConnection) map[string][]byte {
secrets := map[string][]byte{connection.CredentialRef: []byte("retired-push-secret-value")}
if connection.NextCredentialRef != nil {
secrets[*connection.NextCredentialRef] = []byte("retired-next-push-secret-value")
}
if connection.ManagementCredentialRef != nil {
secrets[*connection.ManagementCredentialRef] = []byte("retired-management-secret-value")
}
return secrets
}
func waitForRetirementFinalization(t *testing.T, repository *memoryConnectionRepository) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if _, err := repository.SecurityEventConnection(context.Background()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("retiring connection was not finalized by the server-lifecycle worker")
}