easyai-ai-gateway/apps/api/internal/identity/pairing_service_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

881 lines
39 KiB
Go

package identity
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
)
type pairingRepositoryFake struct {
revision Revision
exchange PairingExchange
blocked bool
startReservationErr error
startReservationID string
startReservationState string
startReservationCalls int
startReservationReleases int
pendingSecretCleanups map[string]time.Time
commitStartErr error
commitStartAmbiguous bool
applyManifestAmbiguousOnce bool
failCredentialsSavedOnce bool
}
func (f *pairingRepositoryFake) ReserveIdentityPairingStart(_ context.Context, attemptID string, _ time.Time) error {
f.startReservationCalls++
if f.startReservationErr != nil {
return f.startReservationErr
}
if f.blocked || f.startReservationID != "" {
return ErrPairingInProgress
}
f.startReservationID = attemptID
f.startReservationState = "starting"
return nil
}
func (f *pairingRepositoryFake) ReleaseIdentityPairingStart(_ context.Context, attemptID string) error {
if f.startReservationID == attemptID && f.startReservationState == "starting" {
f.startReservationID = ""
f.startReservationState = ""
f.startReservationReleases++
}
return nil
}
func (f *pairingRepositoryFake) CommitIdentityPairingStart(_ context.Context, revision Revision, exchange PairingExchange) (PairingExchange, error) {
if f.commitStartErr != nil && !f.commitStartAmbiguous {
return PairingExchange{}, f.commitStartErr
}
if f.startReservationID != exchange.ID || f.startReservationState != "starting" {
return PairingExchange{}, ErrPairingInProgress
}
if _, staged := f.pendingSecretCleanups[exchange.ExchangeTokenRef]; !staged {
return PairingExchange{}, errors.New("exchange token was not staged")
}
delete(f.pendingSecretCleanups, exchange.ExchangeTokenRef)
f.revision = revision
f.exchange = exchange
f.startReservationState = "paired"
if f.commitStartAmbiguous {
return PairingExchange{}, f.commitStartErr
}
return exchange, nil
}
func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) {
f.revision = revision
return revision, nil
}
func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, string) (Revision, error) {
return f.revision, nil
}
func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) {
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if _, staged := f.pendingSecretCleanups[reference]; !staged {
return Revision{}, errors.New("identity secret was not staged")
}
}
oldMachineReference, oldSessionReference := f.revision.MachineCredentialRef, f.revision.SessionEncryptionKeyRef
revision, err := ApplyManifest(f.revision, applied)
if err != nil {
return Revision{}, err
}
revision.Version++
f.revision = revision
for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} {
delete(f.pendingSecretCleanups, reference)
}
for _, reference := range []string{oldMachineReference, oldSessionReference} {
if reference != "" && reference != applied.MachineCredentialRef && reference != applied.SessionEncryptionKeyRef {
f.pendingSecretCleanups[reference] = time.Now()
}
}
if f.applyManifestAmbiguousOnce {
f.applyManifestAmbiguousOnce = false
return Revision{}, errors.New("manifest commit response lost")
}
return revision, nil
}
func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) {
f.exchange = exchange
return exchange, nil
}
func (f *pairingRepositoryFake) IdentityPairingExchange(context.Context, string) (PairingExchange, error) {
return f.exchange, nil
}
func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context, id string, expected int64, update PairingExchangeUpdate) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
if update.Status == PairingCredentialsSaved && f.failCredentialsSavedOnce {
f.failCredentialsSavedOnce = false
return PairingExchange{}, errors.New("pairing status temporarily unavailable")
}
f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion
f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID
f.exchange.Version++
if update.Status == PairingCompleted || update.Status == PairingFailed || update.Status == PairingExpired {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) RecordIdentityPairingRetryFailure(_ context.Context, id string, status PairingStatus, category string) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Status != status {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.LastErrorCategory = category
return f.exchange, nil
}
func (f *pairingRepositoryFake) IdentityPairingStartBlocked(context.Context) (bool, error) {
return f.blocked, nil
}
func (f *pairingRepositoryFake) QueueIdentitySecretCleanup(_ context.Context, reference string, notBefore time.Time) error {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
current, exists := f.pendingSecretCleanups[reference]
if !exists || notBefore.Before(current) {
f.pendingSecretCleanups[reference] = notBefore
}
return nil
}
func (f *pairingRepositoryFake) RemovePendingIdentitySecretCleanup(_ context.Context, reference string) (bool, error) {
if _, exists := f.pendingSecretCleanups[reference]; !exists {
return false, nil
}
delete(f.pendingSecretCleanups, reference)
return true, nil
}
func (f *pairingRepositoryFake) CancelIdentityPairingExchange(_ context.Context, id string, expected int64, traceID, auditID string) (PairingExchange, error) {
if f.exchange.ID != id || f.revision.State == RevisionActive || f.revision.State == RevisionSuperseded {
return PairingExchange{}, ErrRevisionConflict
}
if f.exchange.Status == PairingCancelled {
return f.exchange, nil
}
if f.exchange.Version != expected {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.Status = PairingCancelled
f.exchange.CleanupStatus = PairingCleanupPending
f.exchange.Version++
now := time.Now().UTC()
f.exchange.CancelledAt = &now
f.revision.State = RevisionFailed
f.revision.LastErrorCategory = "pairing_cancelled"
f.revision.LastTraceID = traceID
f.revision.LastAuditID = auditID
f.revision.Version++
if f.exchange.ExchangeTokenRef != "" {
if f.pendingSecretCleanups == nil {
f.pendingSecretCleanups = map[string]time.Time{}
}
f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now()
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) CompleteIdentityPairingCleanup(_ context.Context, id string, expected int64) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Version != expected || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.CleanupStatus = PairingCleanupCompleted
f.exchange.Version++
now := time.Now().UTC()
f.exchange.CleanupCompletedAt = &now
f.revision.MachineCredentialRef = ""
f.revision.SessionEncryptionKeyRef = ""
if f.startReservationState == "paired" && f.startReservationID == f.exchange.ID {
f.startReservationID = ""
f.startReservationState = ""
}
return f.exchange, nil
}
func (f *pairingRepositoryFake) RecordIdentityPairingCleanupFailure(_ context.Context, id string, category string) (PairingExchange, error) {
if f.exchange.ID != id || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending {
return PairingExchange{}, ErrRevisionConflict
}
f.exchange.LastErrorCategory = category
return f.exchange, nil
}
type secretStoreFake struct {
values map[string][]byte
failMachine bool
}
func (f *secretStoreFake) Put(_ context.Context, reference string, value []byte) error {
if f.failMachine && len(reference) > len("identity-machine-") && reference[:len("identity-machine-")] == "identity-machine-" {
return errors.New("secret store unavailable")
}
if f.values == nil {
f.values = map[string][]byte{}
}
f.values[reference] = append([]byte(nil), value...)
return nil
}
func (f *secretStoreFake) Get(_ context.Context, reference string) ([]byte, error) {
value, ok := f.values[reference]
if !ok {
return nil, errors.New("secret not found")
}
return append([]byte(nil), value...), nil
}
func (f *secretStoreFake) Delete(_ context.Context, reference string) error {
delete(f.values, reference)
return nil
}
type onboardingRemoteFake struct {
claimed ClaimedExchange
claimErr error
claimCalls int
view Exchange
delivery CredentialDelivery
deliveryCalls int
completed bool
completeErr error
}
func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) {
f.claimCalls++
return f.claimed, f.claimErr
}
func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangePreparing, 2
return f.view, nil
}
func (f *onboardingRemoteFake) Get(context.Context, string, string) (Exchange, error) {
f.view.Status, f.view.Version = ExchangeReady, 3
return f.view, nil
}
func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) {
f.deliveryCalls++
f.delivery.MachineCredential.ClientSecret = "rotated-machine-secret-value-000000000000-" + string(rune('0'+f.deliveryCalls))
f.delivery.Version = int64(3 + f.deliveryCalls)
return f.delivery, nil
}
func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error {
f.completed = true
return f.completeErr
}
type securityEventPreparerFake struct {
called bool
err error
cleanupCalled bool
cleanupErr error
conflictRetireCalled bool
conflictRetireErr error
conflictRevisionID string
}
type blockingSecurityEventPreparer struct {
prepareStarted chan struct{}
releasePrepare chan struct{}
cleanupStarted chan struct{}
cleanupOnce sync.Once
}
func (preparer *blockingSecurityEventPreparer) PrepareSecurityEvents(context.Context, Revision, []byte) error {
close(preparer.prepareStarted)
<-preparer.releasePrepare
return nil
}
func (preparer *blockingSecurityEventPreparer) CleanupPreparedSecurityEvents(context.Context, Revision) error {
preparer.cleanupOnce.Do(func() { close(preparer.cleanupStarted) })
return nil
}
func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error {
f.called = true
return f.err
}
func (f *securityEventPreparerFake) CleanupPreparedSecurityEvents(context.Context, Revision) error {
f.cleanupCalled = true
return f.cleanupErr
}
func (f *securityEventPreparerFake) RetireConflictingSecurityEvents(_ context.Context, revision Revision) error {
f.conflictRetireCalled = true
f.conflictRevisionID = revision.ID
return f.conflictRetireErr
}
type safeCategoryTestError struct {
category string
message string
}
func (err safeCategoryTestError) Error() string { return err.message }
func (err safeCategoryTestError) SafeErrorCategory() string { return err.category }
func TestPairingStartReservationSerializesAndRecoversFailedClaims(t *testing.T) {
input := PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}
t.Run("existing pairing blocks before remote claim", func(t *testing.T) {
repository := &pairingRepositoryFake{blocked: true}
remote := &onboardingRemoteFake{}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-blocked"); !errors.Is(err, ErrPairingInProgress) {
t.Fatalf("blocked Start error=%v", err)
}
if repository.startReservationCalls != 1 || repository.startReservationReleases != 0 || repository.startReservationID != "" || remote.claimCalls != 0 {
t.Fatalf("reservation/claim lifecycle calls=%d releases=%d id=%q claims=%d",
repository.startReservationCalls, repository.startReservationReleases, repository.startReservationID, remote.claimCalls)
}
})
t.Run("active machine credential blocks destructive rotation before remote claim", func(t *testing.T) {
repository := &pairingRepositoryFake{startReservationErr: ErrActiveConfigurationHandoffRequired}
remote := &onboardingRemoteFake{}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-active"); !errors.Is(err, ErrActiveConfigurationHandoffRequired) {
t.Fatalf("active credential guard error=%v", err)
}
if remote.claimCalls != 0 {
t.Fatal("active credential guard ran after the one-time onboarding code was claimed")
}
})
t.Run("remote failure releases database guard", func(t *testing.T) {
repository := &pairingRepositoryFake{}
remote := &onboardingRemoteFake{claimErr: errors.New("remote unavailable")}
service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), input, "trace-failed"); err == nil {
t.Fatal("remote claim failure was ignored")
}
if repository.startReservationCalls != 1 || repository.startReservationReleases != 1 || repository.startReservationID != "" || remote.claimCalls != 1 {
t.Fatalf("reservation was not released after remote failure: %#v remote=%#v", repository, remote)
}
})
}
func TestPairingStartLeavesUncommittedExchangeTokenInDurableCleanupQueue(t *testing.T) {
repository := &pairingRepositoryFake{commitStartErr: errors.New("database commit unavailable")}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-uncommitted"); err == nil {
t.Fatal("failed pairing commit was ignored")
}
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 1 || repository.startReservationID != "" || repository.startReservationReleases != 1 {
t.Fatalf("uncommitted token was not left recoverable: secrets=%d cleanup=%d reservation=%q releases=%d",
len(secrets.values), len(repository.pendingSecretCleanups), repository.startReservationID, repository.startReservationReleases)
}
for reference := range secrets.values {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("uncommitted Secret reference %q is not queued for cleanup", reference)
}
}
}
func TestPairingStartAmbiguousCommitNeverRequeuesAdoptedExchangeToken(t *testing.T) {
repository := &pairingRepositoryFake{
commitStartErr: errors.New("commit response lost"), commitStartAmbiguous: true,
}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{claimed: ClaimedExchange{
ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value",
Version: 1, ExpiresAt: time.Now().Add(time.Hour),
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
if _, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-ambiguous"); err == nil {
t.Fatal("ambiguous pairing commit was not reported")
}
if repository.exchange.ID == "" || repository.revision.ID == "" {
t.Fatal("test did not simulate a committed Pairing")
}
if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 0 {
t.Fatalf("adopted exchange token was re-queued: secrets=%d cleanup=%d", len(secrets.values), len(repository.pendingSecretCleanups))
}
if repository.startReservationID != repository.exchange.ID || repository.startReservationState != "paired" || repository.startReservationReleases != 0 {
t.Fatalf("committed reservation was not retained: id=%q state=%q releases=%d",
repository.startReservationID, repository.startReservationState, repository.startReservationReleases)
}
}
func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) {
repository := &pairingRepositoryFake{}
secrets := &secretStoreFake{values: map[string][]byte{}, failMachine: true}
remote := &onboardingRemoteFake{
claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
delivery: CredentialDelivery{Manifest: ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333",
ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"},
}, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
pairing, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-pairing")
if err != nil {
t.Fatal(err)
}
if _, ok := secrets.values[pairing.ExchangeTokenRef]; !ok {
t.Fatal("exchange token was not saved in SecretStore")
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("machine SecretStore failure was ignored")
}
if remote.completed || repository.revision.ApplicationID != "" {
t.Fatal("failed secret save advanced the exchange or revision")
}
secrets.failMachine = false
completed, err := service.Continue(context.Background(), pairing.ID)
if err != nil {
t.Fatal(err)
}
if !remote.completed || completed.Status != PairingCompleted || remote.deliveryCalls != 2 || !preparer.called {
t.Fatalf("pairing did not recover safely: pairing=%#v calls=%d completed=%v", completed, remote.deliveryCalls, remote.completed)
}
if repository.revision.MachineCredentialRef == "" || repository.revision.SessionEncryptionKeyRef == "" {
t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision)
}
}
func TestPairingRetryNeverOverwritesOrDeletesAdoptedIdentitySecrets(t *testing.T) {
repository := &pairingRepositoryFake{applyManifestAmbiguousOnce: true, failCredentialsSavedOnce: true}
secrets := &secretStoreFake{values: map[string][]byte{}}
remote := &onboardingRemoteFake{
claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)},
delivery: CredentialDelivery{Manifest: ManifestV1{
SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333",
ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222",
Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"},
Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}},
SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"},
}, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
pairing, err := service.Start(context.Background(), PairingInput{
AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value",
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
}, "trace-retry")
if err != nil {
t.Fatal(err)
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("simulated pairing status persistence failure was ignored")
}
firstMachine := repository.revision.MachineCredentialRef
firstSession := repository.revision.SessionEncryptionKeyRef
if firstMachine == "" || firstSession == "" {
t.Fatalf("first delivery was not adopted: %#v", repository.revision)
}
if _, ok := secrets.values[firstMachine]; !ok {
t.Fatal("first adopted machine Secret was deleted")
}
if _, ok := secrets.values[firstSession]; !ok {
t.Fatal("first adopted session Secret was deleted")
}
for _, reference := range []string{firstMachine, firstSession} {
if _, queued := repository.pendingSecretCleanups[reference]; queued {
t.Fatalf("ambiguously adopted identity Secret %q was re-queued", reference)
}
}
if _, err := service.Continue(context.Background(), pairing.ID); err == nil {
t.Fatal("simulated pairing status persistence failure was ignored")
}
secondMachine := repository.revision.MachineCredentialRef
secondSession := repository.revision.SessionEncryptionKeyRef
if secondMachine == firstMachine || secondSession == firstSession {
t.Fatalf("identity Secret references were overwritten in place: first=(%s,%s) second=(%s,%s)", firstMachine, firstSession, secondMachine, secondSession)
}
for _, reference := range []string{firstMachine, firstSession} {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("first superseded identity Secret %q was not queued for cleanup", reference)
}
}
completed, err := service.Continue(context.Background(), pairing.ID)
if err != nil {
t.Fatal(err)
}
if completed.Status != PairingCompleted {
t.Fatalf("retry did not complete: %#v", completed)
}
thirdMachine := repository.revision.MachineCredentialRef
thirdSession := repository.revision.SessionEncryptionKeyRef
if thirdMachine == secondMachine || thirdSession == secondSession {
t.Fatalf("second retry overwrote identity Secret references in place: second=(%s,%s) third=(%s,%s)", secondMachine, secondSession, thirdMachine, thirdSession)
}
for _, reference := range []string{thirdMachine, thirdSession} {
if _, ok := secrets.values[reference]; !ok {
t.Fatalf("active identity Secret %q was deleted", reference)
}
if _, queued := repository.pendingSecretCleanups[reference]; queued {
t.Fatalf("active identity Secret %q remained in cleanup queue", reference)
}
}
for _, reference := range []string{firstMachine, firstSession, secondMachine, secondSession} {
if _, queued := repository.pendingSecretCleanups[reference]; !queued {
t.Fatalf("superseded identity Secret %q was not queued for cleanup", reference)
}
}
}
func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
repository := &pairingRepositoryFake{exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingPreparing, RemoteVersion: 2, ExpiresAt: time.Now().Add(-time.Minute), Version: 4,
}}
secrets := &secretStoreFake{values: map[string][]byte{"identity-exchange-pairing": []byte("temporary-token")}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
t.Fatal("expired exchange must not call the remote service")
return nil, nil
}, nil)
expired, err := service.Continue(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if expired.Status != PairingExpired || expired.LastErrorCategory != "exchange_expired" {
t.Fatalf("expired pairing was not persisted: %#v", expired)
}
if _, exists := secrets.values["identity-exchange-pairing"]; exists {
t.Fatal("expired exchange token remained in SecretStore")
}
}
func TestPairingPersistsOnlySafeSecurityEventFailureCategory(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com",
SessionRevocation: true, MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
}}
remote := &onboardingRemoteFake{}
preparer := &securityEventPreparerFake{err: safeCategoryTestError{
category: "discovery_failed",
message: "sensitive-marker https://internal.example/ssf token-value",
}}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer)
failed, err := service.Continue(context.Background(), "pairing")
if err == nil {
t.Fatal("security event discovery failure was ignored")
}
if failed.Status != PairingCredentialsSaved || failed.LastErrorCategory != "security_event_discovery_failed" {
t.Fatalf("unsafe or missing pairing failure state: %#v", failed)
}
encoded, marshalErr := json.Marshal(failed)
if marshalErr != nil {
t.Fatal(marshalErr)
}
if strings.Contains(string(encoded), "sensitive-marker") || strings.Contains(string(encoded), "internal.example") || strings.Contains(string(encoded), "token-value") {
t.Fatalf("raw security event error escaped through pairing JSON: %s", encoded)
}
preparer.err = nil
completed, err := service.Continue(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if completed.Status != PairingCompleted || completed.LastErrorCategory != "" {
t.Fatalf("successful retry did not clear the diagnostic category: %#v", completed)
}
}
func TestPairingCancellationPersistsIntentBeforeCleaningTemporaryResources(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision", SessionEncryptionKeyRef: "identity-session-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingCredentialsSaved, CleanupStatus: PairingCleanupNone, RemoteVersion: 4,
ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
"identity-machine-revision": []byte("temporary-machine-secret-value-0000000"),
"identity-session-revision": []byte("temporary-session-secret-value-0000000"),
}}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
t.Fatal("cancellation must not call the onboarding remote")
return nil, nil
}, preparer)
cancelled, err := service.Cancel(context.Background(), "pairing", 7, "trace-cancel", "audit-cancel")
if err != nil {
t.Fatal(err)
}
if cancelled.Status != PairingCancelled || cancelled.CleanupStatus != PairingCleanupPending || cancelled.CancelledAt == nil {
t.Fatalf("cancellation intent was not persisted: %#v", cancelled)
}
if repository.revision.State != RevisionFailed || repository.revision.LastErrorCategory != "pairing_cancelled" {
t.Fatalf("draft was not atomically retired: %#v", repository.revision)
}
if len(secrets.values) != 3 {
t.Fatal("synchronous cancellation removed resources before the cleanup worker could resume them")
}
replayed, err := service.Cancel(context.Background(), "pairing", 7, "trace-replay", "audit-replay")
if err != nil || replayed.Version != cancelled.Version || replayed.Status != PairingCancelled {
t.Fatalf("lost cancellation response could not be replayed safely: %#v err=%v", replayed, err)
}
cleaned, err := service.Cleanup(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if cleaned.CleanupStatus != PairingCleanupCompleted || cleaned.CleanupCompletedAt == nil || !preparer.cleanupCalled {
t.Fatalf("pairing cleanup was not completed: %#v", cleaned)
}
if len(secrets.values) != 0 || repository.revision.MachineCredentialRef != "" || repository.revision.SessionEncryptionKeyRef != "" {
t.Fatalf("temporary secret references survived cleanup: secrets=%d revision=%#v", len(secrets.values), repository.revision)
}
}
func TestPairingCleanupRetriesWithSafeCategory(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionFailed, Version: 4, SessionRevocation: true},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", Status: PairingCancelled, CleanupStatus: PairingCleanupPending,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 8,
},
}
preparer := &securityEventPreparerFake{cleanupErr: safeCategoryTestError{
category: "retirement_pending", message: "sensitive cleanup marker token-value",
}}
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{}}, nil, preparer)
pending, err := service.Cleanup(context.Background(), "pairing")
if err == nil {
t.Fatal("pending security event retirement was treated as completed")
}
if pending.CleanupStatus != PairingCleanupPending || pending.LastErrorCategory != "cleanup_security_event_retirement_pending" || strings.Contains(pending.LastErrorCategory, "sensitive") {
t.Fatalf("cleanup failure was not safely persisted: %#v", pending)
}
preparer.cleanupErr = nil
completed, err := service.Cleanup(context.Background(), "pairing")
if err != nil {
t.Fatal(err)
}
if completed.CleanupStatus != PairingCleanupCompleted {
t.Fatalf("cleanup retry did not complete: %#v", completed)
}
}
func TestPairingMapsUntrustedRemoteErrorCategoryToStableValue(t *testing.T) {
repository := &pairingRepositoryFake{exchange: PairingExchange{ID: "pairing", Version: 2}}
service := NewPairingService(repository, &secretStoreFake{}, nil, nil)
updated, err := service.updateFromRemote(context.Background(), repository.exchange, Exchange{
Status: ExchangeFailed, Version: 3, LastErrorCategory: "secret_token_abcdef",
})
if err != nil {
t.Fatal(err)
}
if updated.Status != PairingFailed || updated.LastErrorCategory != "remote_exchange_failed" {
t.Fatalf("untrusted remote category was persisted: %#v", updated)
}
}
func TestPairingConflictRetirementIsScopedToCurrentBlockedPairing(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", Status: PairingCredentialsSaved,
CleanupStatus: PairingCleanupNone, Version: 7, LastErrorCategory: "security_event_connection_conflict",
},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, &secretStoreFake{}, nil, preparer)
resolved, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7)
if err != nil {
t.Fatal(err)
}
if resolved.ID != "pairing" || resolved.LastErrorCategory != "security_event_retirement_pending" || !preparer.conflictRetireCalled || preparer.conflictRevisionID != "revision" {
t.Fatalf("conflict retirement escaped pairing scope: pairing=%#v preparer=%#v", resolved, preparer)
}
preparer.conflictRetireCalled = false
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 6); !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("stale pairing version error=%v", err)
}
repository.exchange.LastErrorCategory = ""
if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7); !errors.Is(err, ErrPairingConflictNotResolvable) {
t.Fatalf("resolved pairing could retire another connection: %v", err)
}
if preparer.conflictRetireCalled {
t.Fatal("stale conflict action reached the security event manager")
}
}
func TestCompletedPairingRestoresPreparedReceiverAfterRestart(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionValidated, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-machine-revision": []byte("machine-secret-value-restored-from-store"),
}}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, secrets, nil, preparer)
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); err != nil {
t.Fatal(err)
}
if !preparer.called {
t.Fatal("completed pairing did not reconstruct its prepared security event receiver")
}
}
func TestCompletedPairingDoesNotRestoreReceiverForSupersededRevision(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionSuperseded, Version: 5, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8},
}
preparer := &securityEventPreparerFake{}
service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{
"identity-machine-revision": []byte("must-not-be-read"),
}}, nil, preparer)
if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("restore error=%v, want revision conflict", err)
}
if preparer.called {
t.Fatal("superseded Revision reconstructed an orphan prepared Receiver")
}
}
func TestCancelledPairingCleanupWaitsForCompletedReceiverRestore(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{
ID: "revision", State: RevisionValidated, Version: 3, SessionRevocation: true,
MachineCredentialRef: "identity-machine-revision",
},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
Status: PairingCompleted, CleanupStatus: PairingCleanupNone, Version: 8,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("exchange-token"),
"identity-machine-revision": []byte("machine-secret"),
}}
preparer := &blockingSecurityEventPreparer{
prepareStarted: make(chan struct{}),
releasePrepare: make(chan struct{}),
cleanupStarted: make(chan struct{}),
}
service := NewPairingService(repository, secrets, nil, preparer)
restoreResult := make(chan error, 1)
go func() { restoreResult <- service.RestoreCompletedSecurityEvents(context.Background(), "pairing") }()
<-preparer.prepareStarted
cancelled, err := service.Cancel(context.Background(), "pairing", 8, "trace", "audit")
if err != nil {
t.Fatal(err)
}
cleanupResult := make(chan PairingExchange, 1)
cleanupError := make(chan error, 1)
go func() {
cleaned, cleanupErr := service.Cleanup(context.Background(), "pairing")
cleanupResult <- cleaned
cleanupError <- cleanupErr
}()
select {
case <-preparer.cleanupStarted:
t.Fatal("cleanup overtook an in-flight completed receiver restore")
case <-time.After(100 * time.Millisecond):
}
close(preparer.releasePrepare)
if err := <-restoreResult; !errors.Is(err, ErrRevisionConflict) {
t.Fatalf("restore error = %v, want revision conflict after cancellation", err)
}
if err := <-cleanupError; err != nil {
t.Fatal(err)
}
cleaned := <-cleanupResult
if cancelled.Status != PairingCancelled || cleaned.CleanupStatus != PairingCleanupCompleted {
t.Fatalf("cancel/cleanup did not converge: cancelled=%#v cleaned=%#v", cancelled, cleaned)
}
if len(secrets.values) != 0 {
t.Fatalf("cancelled restore left temporary secrets: %d", len(secrets.values))
}
}
func TestPairingClassifiesExchangeCompletionFailureSeparatelyFromSSF(t *testing.T) {
repository := &pairingRepositoryFake{
revision: Revision{ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com"},
exchange: PairingExchange{
ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111",
ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved,
RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7,
},
}
secrets := &secretStoreFake{values: map[string][]byte{
"identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"),
}}
remote := &onboardingRemoteFake{completeErr: errors.New("upstream body contains sensitive marker")}
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil)
failed, err := service.Continue(context.Background(), "pairing")
if err == nil {
t.Fatal("exchange completion failure was ignored")
}
if failed.LastErrorCategory != "exchange_completion_failed" {
t.Fatalf("completion failure was misclassified: %#v", failed)
}
}