easyai-ai-gateway/apps/api/internal/identity/pairing_service_test.go
chengcheng 96bbd3a2f6 feat(identity): 实现接入码配对后台流程
Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./...
2026-07-17 11:55:24 +08:00

157 lines
6.6 KiB
Go

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)
}
}