修复 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。
668 lines
29 KiB
Go
668 lines
29 KiB
Go
package identityruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
|
)
|
|
|
|
type runtimeRepositoryFake struct {
|
|
revisions map[string]identity.Revision
|
|
active identity.Revision
|
|
activateCalled bool
|
|
activeLookupErr error
|
|
markValidatedErr error
|
|
markValidatedErrAfterApply bool
|
|
activateErr error
|
|
activateErrAfterApply bool
|
|
disableErr error
|
|
disableErrAfterApply bool
|
|
}
|
|
|
|
func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, id string) (identity.Revision, error) {
|
|
revision, ok := f.revisions[id]
|
|
if !ok {
|
|
return identity.Revision{}, identity.ErrRevisionNotFound
|
|
}
|
|
return revision, nil
|
|
}
|
|
func (f *runtimeRepositoryFake) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) {
|
|
if f.activeLookupErr != nil {
|
|
return identity.Revision{}, f.activeLookupErr
|
|
}
|
|
if f.active.ID == "" {
|
|
return identity.Revision{}, identity.ErrRevisionNotFound
|
|
}
|
|
return f.active, nil
|
|
}
|
|
func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, id string, expected int64, _, _ string) (identity.Revision, error) {
|
|
if f.markValidatedErr != nil && !f.markValidatedErrAfterApply {
|
|
return identity.Revision{}, f.markValidatedErr
|
|
}
|
|
revision := f.revisions[id]
|
|
if revision.Version != expected {
|
|
return identity.Revision{}, identity.ErrRevisionConflict
|
|
}
|
|
revision.State, revision.Version = identity.RevisionValidated, revision.Version+1
|
|
f.revisions[id] = revision
|
|
if f.markValidatedErr != nil {
|
|
return identity.Revision{}, f.markValidatedErr
|
|
}
|
|
return revision, nil
|
|
}
|
|
func (f *runtimeRepositoryFake) RevalidateActiveIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, error) {
|
|
revision := f.revisions[id]
|
|
if revision.Version != expected || revision.State != identity.RevisionActive {
|
|
return identity.Revision{}, identity.ErrRevisionConflict
|
|
}
|
|
revision.Version, revision.LastTraceID, revision.LastAuditID = revision.Version+1, traceID, auditID
|
|
f.revisions[id], f.active = revision, revision
|
|
return revision, nil
|
|
}
|
|
func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id string, expected int64, category, _, _ string) (identity.Revision, error) {
|
|
revision := f.revisions[id]
|
|
if revision.Version != expected {
|
|
return identity.Revision{}, identity.ErrRevisionConflict
|
|
}
|
|
revision.State, revision.Version, revision.LastErrorCategory = identity.RevisionFailed, revision.Version+1, category
|
|
f.revisions[id] = revision
|
|
return revision, nil
|
|
}
|
|
func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, bool, error) {
|
|
f.activateCalled = true
|
|
if f.activateErr != nil && !f.activateErrAfterApply {
|
|
return identity.Revision{}, false, f.activateErr
|
|
}
|
|
revision := f.revisions[id]
|
|
if revision.Version != expected || revision.State != identity.RevisionValidated {
|
|
return identity.Revision{}, false, identity.ErrRevisionConflict
|
|
}
|
|
if f.active.ID != "" {
|
|
old := f.active
|
|
old.State = identity.RevisionSuperseded
|
|
f.revisions[old.ID] = old
|
|
}
|
|
revision.State, revision.Version, revision.LastTraceID, revision.LastAuditID = identity.RevisionActive, revision.Version+1, traceID, auditID
|
|
f.active, f.revisions[id] = revision, revision
|
|
if f.activateErr != nil {
|
|
return identity.Revision{}, false, f.activateErr
|
|
}
|
|
return revision, true, nil
|
|
}
|
|
func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64, traceID, auditID string) (identity.Revision, error) {
|
|
if f.disableErr != nil && !f.disableErrAfterApply {
|
|
return identity.Revision{}, f.disableErr
|
|
}
|
|
if f.active.Version != expected {
|
|
return identity.Revision{}, identity.ErrRevisionConflict
|
|
}
|
|
disabled := f.active
|
|
disabled.State, disabled.Version, disabled.LastTraceID, disabled.LastAuditID = identity.RevisionSuperseded, disabled.Version+1, traceID, auditID
|
|
f.revisions[disabled.ID] = disabled
|
|
f.active = identity.Revision{}
|
|
if f.disableErr != nil {
|
|
return identity.Revision{}, f.disableErr
|
|
}
|
|
return disabled, nil
|
|
}
|
|
|
|
type runtimeBuilderFake struct {
|
|
err error
|
|
builtIDs []string
|
|
buildStarted chan struct{}
|
|
buildRelease chan struct{}
|
|
prepareStarted chan struct{}
|
|
prepareRelease chan struct{}
|
|
prepareCalls int
|
|
cleanupCalledFor string
|
|
adoptedRevision string
|
|
preparedCancel context.CancelFunc
|
|
preparedManager *securityevents.ConnectionManager
|
|
preparedReceiver http.Handler
|
|
recoveredSecurityEventDisconnector SecurityEventDisconnector
|
|
recoverSecurityEventErr error
|
|
}
|
|
|
|
type runtimeSecurityEventDisconnector struct {
|
|
lifecycle string
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (f *runtimeSecurityEventDisconnector) Disconnect(context.Context) (securityevents.ConnectionView, error) {
|
|
f.calls++
|
|
return securityevents.ConnectionView{LifecycleStatus: f.lifecycle}, f.err
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) Build(_ context.Context, revision identity.Revision) (*Runtime, error) {
|
|
f.builtIDs = append(f.builtIDs, revision.ID)
|
|
if f.buildStarted != nil {
|
|
close(f.buildStarted)
|
|
}
|
|
if f.buildRelease != nil {
|
|
<-f.buildRelease
|
|
}
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return &Runtime{Revision: revision}, nil
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) PrepareSecurityEvents(_ context.Context, _ identity.Revision, _ []byte) error {
|
|
f.prepareCalls++
|
|
if f.prepareStarted != nil {
|
|
close(f.prepareStarted)
|
|
}
|
|
if f.prepareRelease != nil {
|
|
<-f.prepareRelease
|
|
}
|
|
return f.err
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) CleanupPreparedSecurityEvents(_ context.Context, revision identity.Revision) error {
|
|
f.cleanupCalledFor = revision.ID
|
|
return f.err
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc {
|
|
f.adoptedRevision = revisionID
|
|
return f.preparedCancel
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) PreparedSecurityEventManager() *securityevents.ConnectionManager {
|
|
return f.preparedManager
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) PreparedSecurityEventReceiver() http.Handler {
|
|
return f.preparedReceiver
|
|
}
|
|
|
|
func (f *runtimeBuilderFake) RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error) {
|
|
return f.recoveredSecurityEventDisconnector, f.recoverSecurityEventErr
|
|
}
|
|
|
|
func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
|
|
draft := identity.Revision{ID: "draft", State: identity.RevisionDraft, Version: 1}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active, "draft": draft}, active: active}
|
|
builder := &runtimeBuilderFake{err: errors.New("discovery failed")}
|
|
manager := NewManager(repository, builder)
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
if _, err := manager.Validate(context.Background(), draft.ID, draft.Version, "trace", "audit"); err == nil {
|
|
t.Fatal("validation failure was ignored")
|
|
}
|
|
if manager.Current().Revision.ID != active.ID || repository.activateCalled {
|
|
t.Fatal("validation failure changed current runtime or activated the draft")
|
|
}
|
|
if repository.revisions[draft.ID].State != identity.RevisionFailed {
|
|
t.Fatal("failed draft was not marked failed")
|
|
}
|
|
}
|
|
|
|
func TestLoadActiveMarksTransientFailureForAutomaticReconciliation(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"active": active},
|
|
active: active,
|
|
activeLookupErr: errors.New("database temporarily unavailable"),
|
|
}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(repository, builder)
|
|
|
|
if err := manager.LoadActive(context.Background()); err == nil {
|
|
t.Fatal("transient active lookup failure was ignored")
|
|
}
|
|
if !manager.ReconciliationRequired() || manager.Current() != nil {
|
|
t.Fatal("failed startup load was not left fail-closed and retryable")
|
|
}
|
|
repository.activeLookupErr = nil
|
|
builder.err = errors.New("SecretStore temporarily unavailable")
|
|
if err := manager.ReconcileActive(context.Background()); err == nil {
|
|
t.Fatal("transient runtime build failure was ignored")
|
|
}
|
|
if !manager.ReconciliationRequired() {
|
|
t.Fatal("failed runtime build cleared automatic reconciliation")
|
|
}
|
|
builder.err = nil
|
|
if err := manager.ReconcileActive(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
|
|
t.Fatalf("startup runtime was not recovered: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired())
|
|
}
|
|
}
|
|
|
|
func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) {
|
|
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
|
|
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"old": active, "new": candidate}, active: active}
|
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !repository.activateCalled || activated.State != identity.RevisionActive || manager.Current().Revision.ID != candidate.ID {
|
|
t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestActivationReconcilesCommitAppliedThenResponseLost(t *testing.T) {
|
|
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
|
|
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"old": active, "new": candidate},
|
|
active: active,
|
|
activateErr: errors.New("commit response lost"),
|
|
activateErrAfterApply: true,
|
|
}
|
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if activated.State != identity.RevisionActive || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID {
|
|
t.Fatalf("committed activation was not reconciled: activated=%#v current=%#v", activated, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestActivationKeepsCurrentRuntimeWhenMutationWasNotApplied(t *testing.T) {
|
|
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
|
|
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
|
|
mutationErr := errors.New("activation rejected before commit")
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"old": active, "new": candidate},
|
|
active: active,
|
|
activateErr: mutationErr,
|
|
}
|
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
|
original := &Runtime{Revision: active}
|
|
manager.current.Store(original)
|
|
|
|
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); !errors.Is(err, mutationErr) {
|
|
t.Fatalf("activation error = %v, want %v", err, mutationErr)
|
|
}
|
|
if manager.Current() != original {
|
|
t.Fatal("definitely uncommitted activation replaced the current runtime")
|
|
}
|
|
}
|
|
|
|
func TestActivationFailsClosedWhenCommitOutcomeCannotBeReconciled(t *testing.T) {
|
|
active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2}
|
|
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"old": active, "new": candidate},
|
|
active: active,
|
|
activateErr: errors.New("commit outcome unknown"),
|
|
activeLookupErr: errors.New("database unavailable during reconciliation"),
|
|
}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(repository, builder)
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err == nil {
|
|
t.Fatal("unknown activation outcome was reported as successful")
|
|
}
|
|
if manager.Current() != nil {
|
|
t.Fatal("unknown activation outcome did not fail OIDC closed")
|
|
}
|
|
if !manager.ReconciliationRequired() {
|
|
t.Fatal("unknown activation outcome did not request background reconciliation")
|
|
}
|
|
|
|
repository.activeLookupErr = nil
|
|
builder.err = errors.New("runtime dependency temporarily unavailable")
|
|
if err := manager.ReconcileActive(context.Background()); err == nil {
|
|
t.Fatal("runtime reconciliation unexpectedly ignored build failure")
|
|
}
|
|
if !manager.ReconciliationRequired() || manager.Current() != nil {
|
|
t.Fatal("failed runtime rebuild cleared fail-closed reconciliation state")
|
|
}
|
|
builder.err = nil
|
|
if err := manager.ReconcileActive(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
|
|
t.Fatalf("runtime did not recover after reconciliation: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired())
|
|
}
|
|
}
|
|
|
|
func TestRollbackRejectsOIDCOnlyRevisionBeforeRuntimeBuild(t *testing.T) {
|
|
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
|
|
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
|
|
}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(repository, builder)
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) {
|
|
t.Fatalf("rollback error=%v, want remote resource handoff required", err)
|
|
}
|
|
if len(builder.builtIDs) != 0 || repository.activateCalled || manager.Current() == nil || manager.Current().Revision.ID != active.ID {
|
|
t.Fatalf("unsafe rollback changed runtime: built=%v activate=%t current=%#v", builder.builtIDs, repository.activateCalled, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestRollbackRejectsSupersededMachineCredentialBeforeRuntimeBuild(t *testing.T) {
|
|
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
|
|
previous := identity.Revision{
|
|
ID: "previous", State: identity.RevisionSuperseded, Version: 3,
|
|
MachineCredentialRef: "identity-machine-previous", TokenIntrospection: true,
|
|
}
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
|
|
}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(repository, builder)
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) {
|
|
t.Fatalf("rollback error=%v, want credential handoff required", err)
|
|
}
|
|
if len(builder.builtIDs) != 0 || repository.activateCalled {
|
|
t.Fatal("unsafe rollback reached runtime build or activation")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsSupersededRevisionBeforeRuntimeBuild(t *testing.T) {
|
|
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"previous": previous}}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(repository, builder)
|
|
|
|
if _, err := manager.Validate(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRevisionConflict) {
|
|
t.Fatalf("validate error=%v, want revision conflict", err)
|
|
}
|
|
if len(builder.builtIDs) != 0 || repository.revisions[previous.ID].State != identity.RevisionSuperseded {
|
|
t.Fatalf("superseded revision reached runtime validation: built=%v revision=%#v", builder.builtIDs, repository.revisions[previous.ID])
|
|
}
|
|
}
|
|
|
|
func TestDisableKeepsActiveRevisionUntilSecurityEventStreamCanRetire(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
|
|
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "disconnect_pending"}
|
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
|
original := &Runtime{Revision: active, securityEventDisconnector: disconnector}
|
|
manager.current.Store(original)
|
|
|
|
if _, err := manager.Disable(context.Background(), active.Version, "trace", "audit"); !errors.Is(err, identity.ErrSecurityEventRetirementPending) {
|
|
t.Fatalf("disable error=%v, want security event retirement pending", err)
|
|
}
|
|
if disconnector.calls != 1 || repository.active.ID != active.ID || manager.Current() != original {
|
|
t.Fatalf("pending retirement changed Active state: calls=%d repository=%#v current=%#v", disconnector.calls, repository.active, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestDisableRecoversSSFWithoutBuildingBrokenFullIdentityRuntime(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
|
|
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"}
|
|
builder := &runtimeBuilderFake{
|
|
err: errors.New("OIDC discovery unavailable"),
|
|
recoveredSecurityEventDisconnector: disconnector,
|
|
}
|
|
manager := NewManager(repository, builder)
|
|
|
|
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if disabled.State != identity.RevisionSuperseded || disconnector.calls != 1 || len(builder.builtIDs) != 0 || manager.Current() != nil {
|
|
t.Fatalf("SSF-only recovery did not disable safely: disabled=%#v calls=%d built=%v current=%#v", disabled, disconnector.calls, builder.builtIDs, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestDisableRetiresSecurityEventStreamBeforeSupersedingActiveRevision(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
|
|
disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"}
|
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
|
manager.current.Store(&Runtime{Revision: active, securityEventDisconnector: disconnector})
|
|
|
|
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if disconnector.calls != 1 || disabled.State != identity.RevisionSuperseded || repository.active.ID != "" || manager.Current() != nil {
|
|
t.Fatalf("safe disable state: calls=%d disabled=%#v repository=%#v current=%#v", disconnector.calls, disabled, repository.active, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestDisableReconcilesCommitAppliedThenResponseLost(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
|
|
repository := &runtimeRepositoryFake{
|
|
revisions: map[string]identity.Revision{"active": active},
|
|
active: active,
|
|
disableErr: errors.New("commit response lost"),
|
|
disableErrAfterApply: true,
|
|
}
|
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if disabled.State != identity.RevisionSuperseded || manager.Current() != nil {
|
|
t.Fatalf("committed disable was not reconciled: disabled=%#v current=%#v", disabled, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestActiveRevalidationSwapsOnlyAfterSuccessfulValidation(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(repository, builder)
|
|
original := &Runtime{Revision: active}
|
|
manager.current.Store(original)
|
|
|
|
revalidated, err := manager.Validate(context.Background(), active.ID, active.Version, "trace-new", "audit-new")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if revalidated.State != identity.RevisionActive || revalidated.Version != 5 || manager.Current() == original || manager.Current().Revision.LastAuditID != "audit-new" {
|
|
t.Fatalf("active runtime was not safely revalidated: %#v", revalidated)
|
|
}
|
|
}
|
|
|
|
func TestLoadFailureKeepsPersistedActiveWebOriginForBreakGlassRecovery(t *testing.T) {
|
|
active := identity.Revision{
|
|
ID: "active", State: identity.RevisionActive, Version: 4,
|
|
WebBaseURL: "https://gateway.example.com",
|
|
}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active}
|
|
manager := NewManager(repository, &runtimeBuilderFake{err: errors.New("OIDC discovery unavailable")})
|
|
|
|
if err := manager.LoadActive(context.Background()); err == nil {
|
|
t.Fatal("active runtime load unexpectedly succeeded")
|
|
}
|
|
if manager.Current() != nil || manager.TrustedWebBaseURL() != active.WebBaseURL {
|
|
t.Fatalf("failed Active load lost recovery origin: current=%#v origin=%q", manager.Current(), manager.TrustedWebBaseURL())
|
|
}
|
|
|
|
repository.active = identity.Revision{}
|
|
if err := manager.ReconcileActive(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manager.TrustedWebBaseURL() != "" {
|
|
t.Fatalf("confirmed disable retained stale recovery origin %q", manager.TrustedWebBaseURL())
|
|
}
|
|
}
|
|
|
|
func TestActivationFinishingFirstPreventsStalePreparedSecurityEventRestore(t *testing.T) {
|
|
candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}}
|
|
builder := &runtimeBuilderFake{buildStarted: make(chan struct{}), buildRelease: make(chan struct{})}
|
|
manager := NewManager(repository, builder)
|
|
|
|
activationDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
|
|
activationDone <- err
|
|
}()
|
|
<-builder.buildStarted
|
|
restoreDone := make(chan error, 1)
|
|
go func() {
|
|
restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret"))
|
|
}()
|
|
close(builder.buildRelease)
|
|
if err := <-activationDone; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := <-restoreDone; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if builder.prepareCalls != 0 || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID {
|
|
t.Fatalf("stale restore survived activation: prepare_calls=%d current=%#v", builder.prepareCalls, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestPreparedSecurityEventRestoreFinishingFirstIsAdoptedByActivation(t *testing.T) {
|
|
candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}}
|
|
builder := &runtimeBuilderFake{
|
|
buildStarted: make(chan struct{}), buildRelease: make(chan struct{}),
|
|
prepareStarted: make(chan struct{}), prepareRelease: make(chan struct{}),
|
|
}
|
|
manager := NewManager(repository, builder)
|
|
|
|
restoreDone := make(chan error, 1)
|
|
go func() {
|
|
restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret"))
|
|
}()
|
|
<-builder.prepareStarted
|
|
activationDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
|
|
activationDone <- err
|
|
}()
|
|
select {
|
|
case <-builder.buildStarted:
|
|
t.Fatal("activation did not wait for prepared Receiver restoration")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
close(builder.prepareRelease)
|
|
if err := <-restoreDone; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-builder.buildStarted
|
|
close(builder.buildRelease)
|
|
if err := <-activationDone; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if builder.prepareCalls != 1 || builder.adoptedRevision != candidate.ID {
|
|
t.Fatalf("prepared restore was not adopted: prepare_calls=%d adopted=%q", builder.prepareCalls, builder.adoptedRevision)
|
|
}
|
|
}
|
|
|
|
func TestManagerDelegatesPreparedSecurityEventCleanupWithoutChangingActiveRuntime(t *testing.T) {
|
|
active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4}
|
|
draft := identity.Revision{ID: "draft", State: identity.RevisionFailed, Version: 2}
|
|
builder := &runtimeBuilderFake{}
|
|
manager := NewManager(&runtimeRepositoryFake{}, builder)
|
|
manager.current.Store(&Runtime{Revision: active})
|
|
|
|
if err := manager.CleanupPreparedSecurityEvents(context.Background(), draft); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if builder.cleanupCalledFor != draft.ID || manager.Current().Revision.ID != active.ID {
|
|
t.Fatalf("cleanup changed active runtime or skipped the draft: called=%q current=%#v", builder.cleanupCalledFor, manager.Current())
|
|
}
|
|
}
|
|
|
|
func TestActivationAdoptsPreparedSecurityEventLifetime(t *testing.T) {
|
|
candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3}
|
|
repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"new": candidate}}
|
|
preparedContext, preparedCancel := context.WithCancel(context.Background())
|
|
builder := &runtimeBuilderFake{preparedCancel: preparedCancel}
|
|
manager := NewManager(repository, builder)
|
|
|
|
if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if builder.adoptedRevision != candidate.ID {
|
|
t.Fatalf("prepared security events were not adopted: %q", builder.adoptedRevision)
|
|
}
|
|
manager.Current().Close()
|
|
select {
|
|
case <-preparedContext.Done():
|
|
default:
|
|
t.Fatal("closing the active runtime did not release its prepared security event manager")
|
|
}
|
|
}
|
|
|
|
func TestPreparedSecurityEventReceiverRemainsAvailableUntilAdoption(t *testing.T) {
|
|
cancelled := false
|
|
builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{
|
|
"draft": {manager: &securityevents.ConnectionManager{}, cancel: func() { cancelled = true }, receiverReady: true},
|
|
}}
|
|
if builder.PreparedSecurityEventReceiver() == nil {
|
|
t.Fatal("prepared receiver was unavailable before activation")
|
|
}
|
|
cancel := builder.AdoptPreparedSecurityEvents("draft")
|
|
if cancel == nil || builder.PreparedSecurityEventReceiver() != nil {
|
|
t.Fatal("prepared receiver ownership was not transferred exactly once")
|
|
}
|
|
cancel()
|
|
if !cancelled {
|
|
t.Fatal("adopted receiver lifetime could not be released")
|
|
}
|
|
}
|
|
|
|
func TestSecurityEventManagerExposesPreparedRecoveryManagerWithoutActiveRuntime(t *testing.T) {
|
|
prepared := &securityevents.ConnectionManager{}
|
|
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared})
|
|
|
|
if manager.SecurityEventManager() != prepared || manager.SecurityEventReceiver() != prepared {
|
|
t.Fatal("prepared security event manager was unavailable to recovery handlers")
|
|
}
|
|
}
|
|
|
|
func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) {
|
|
active := &securityevents.ConnectionManager{}
|
|
prepared := &securityevents.ConnectionManager{}
|
|
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared})
|
|
manager.current.Store(&Runtime{SecurityEvents: active})
|
|
|
|
if manager.SecurityEventManager() != active {
|
|
t.Fatal("prepared recovery manager replaced the active runtime manager")
|
|
}
|
|
}
|
|
|
|
func TestSecurityEventReceiverPrefersReadyDraftDuringRepairing(t *testing.T) {
|
|
active := &securityevents.ConnectionManager{}
|
|
prepared := &securityevents.ConnectionManager{}
|
|
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared, preparedReceiver: prepared})
|
|
manager.current.Store(&Runtime{SecurityEvents: active})
|
|
|
|
if manager.SecurityEventReceiver() != prepared {
|
|
t.Fatal("verification callback was not routed to the ready draft receiver")
|
|
}
|
|
}
|
|
|
|
func TestConflictingPreparedManagerIsRecoverableButNotUsedAsReceiver(t *testing.T) {
|
|
prepared := &securityevents.ConnectionManager{}
|
|
builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{
|
|
"draft": {manager: prepared},
|
|
}}
|
|
|
|
if builder.PreparedSecurityEventManager() != prepared {
|
|
t.Fatal("conflicting connection manager was unavailable for retirement")
|
|
}
|
|
if builder.PreparedSecurityEventReceiver() != nil {
|
|
t.Fatal("unprepared conflict manager was exposed as a verification receiver")
|
|
}
|
|
}
|