修复 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。
881 lines
39 KiB
Go
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)
|
|
}
|
|
}
|