fix(ssf): 允许同一配对恢复未完成连接

首次创建 SSF Stream 失败后,恢复管理器会因缺少尚未生成的 Audience 而将同一 Revision 误判为凭据交接冲突。现在仅对同一 owner、Transmitter、机器客户端且尚未绑定 Stream 的可恢复状态放行重试,跨 Revision 与不完整绑定配置仍保持安全拒绝。\n\n验证:gofmt、go vet ./...、env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1。
This commit is contained in:
2026-07-28 04:48:15 +08:00
parent 9e8722dc9f
commit 1b48a91af0
2 changed files with 114 additions and 0 deletions
@@ -409,6 +409,101 @@ func TestConnectionManagerRecoveryCanLoadDifferentPersistedConnection(t *testing
}
}
func TestConnectionManagerRecoveryResumesSameOwnerIncompleteConnection(t *testing.T) {
var requests atomic.Int32
transmitter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
http.Error(w, "expected retry probe", http.StatusServiceUnavailable)
}))
defer transmitter.Close()
const (
managementClient = "gateway-machine"
managementSecret = "new-machine-secret-value-0000000000"
managementRef = "ssf-management-current"
pushRef = "ssf-push-current"
ownerKey = "identity-pairing-ssf-current-revision"
)
managementReference := managementRef
repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{
ConnectionID: uuid.NewString(),
TransmitterIssuer: transmitter.URL + "/ssf",
EndpointURL: transmitter.URL + "/api/v1/security-events/ssf",
CredentialRef: pushRef,
ManagementClientID: managementClient,
ManagementCredentialRef: &managementReference,
LifecycleStatus: "error",
IdempotencyKey: ownerKey,
Version: 2,
}}
secrets := &memorySecretStore{values: map[string][]byte{
managementRef: []byte(managementSecret),
pushRef: []byte("push-secret-value-0000000000000000"),
}}
manager, err := NewConnectionManager(context.Background(), repository, secrets, ConnectionManagerConfig{
AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/issuer", OIDCTenantID: uuid.NewString(),
ManagementClientID: managementClient, ExpectedTransmitterIssuer: transmitter.URL + "/ssf",
ExpectedAudience: "urn:easyai:ssf:receiver:current", ExpectedOwnerKey: ownerKey,
PublicBaseURL: transmitter.URL, HTTPClient: transmitter.Client(),
}, &Metrics{})
if err != nil {
t.Fatal(err)
}
_, err = manager.Connect(
context.Background(),
transmitter.URL+"/ssf",
managementClient,
[]byte(managementSecret),
ownerKey,
)
var categorized interface{ SafeErrorCategory() string }
if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "discovery_failed" {
t.Fatalf("same-owner incomplete retry error=%v category=%v", err, categorized)
}
if requests.Load() != 1 {
t.Fatalf("same-owner incomplete retry sent %d discovery requests", requests.Load())
}
}
func TestConnectionManagerRecoveryRejectsIncompleteRevisionExpectation(t *testing.T) {
const (
managementClient = "gateway-machine"
managementSecret = "new-machine-secret-value-0000000000"
ownerKey = "identity-pairing-ssf-current-revision"
)
repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{
ConnectionID: uuid.NewString(),
TransmitterIssuer: "https://auth.example/ssf",
EndpointURL: "https://gateway.example/api/v1/security-events/ssf",
CredentialRef: "ssf-push-current",
ManagementClientID: managementClient,
LifecycleStatus: "error",
IdempotencyKey: ownerKey,
Version: 2,
}}
manager, err := NewConnectionManager(context.Background(), repository, &memorySecretStore{}, ConnectionManagerConfig{
OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(),
ManagementClientID: managementClient, ExpectedTransmitterIssuer: "https://auth.example/ssf",
ExpectedOwnerKey: ownerKey,
}, &Metrics{})
if err != nil {
t.Fatal(err)
}
_, err = manager.Connect(
context.Background(),
"https://auth.example/ssf",
managementClient,
[]byte(managementSecret),
ownerKey,
)
var categorized interface{ SafeErrorCategory() string }
if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "credential_handoff_unsafe" {
t.Fatalf("incomplete Revision expectation error=%v category=%v", err, categorized)
}
}
func TestForeignRevisionRecoveryNeverUsesPersistedCredentialOnGenericOperations(t *testing.T) {
var requests atomic.Int32
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {