package identity import ( "context" "errors" "testing" "time" ) type pairingRepositoryFake struct { revision Revision exchange PairingExchange } 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) { revision, err := ApplyManifest(f.revision, applied) if err != nil { return Revision{}, err } revision.Version++ f.revision = revision 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 } f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID f.exchange.Version++ 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 view Exchange delivery CredentialDelivery deliveryCalls int completed bool } func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) { return f.claimed, nil } 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 nil } type securityEventPreparerFake struct{ called bool } func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error { f.called = true return nil } 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) } }