Files
sub2api/backend/internal/service/plugin_host_services_broker_test.go
T
shaw 29eead917f feat(pluginapi): HostService 账号目录返回结构化只读元数据
纯宿主侧改动,向后兼容。

- 契约(pkg/pluginapi/v1/plugin.proto,已重新生成):ListAccounts 增补
  repeated AccountInfo accounts(保留 account_ids 向后兼容),新增 metadata_json
  只读快照字段。HostServiceAPIVersion 1→2(InitHostServices 运行时协商,加法式,
  旧调用方忽略新字段即可)。
- 权限边界:可见范围由清单能力经 pluginCapabilityAccountScopeGrants 推导,
  ListAccounts 与 ResolveOutboundIdentity 均以该范围为权威边界。
- metadata_json 采用 denylist:剥离 Credentials 与含反向引用的关联对象
  Groups/AccountGroups(避免 encoding/json 循环引用),其余字段自动透传,
  数据模型新增非机密字段无需改宿主。

测试:范围/元数据/无凭据泄漏用例、denylist 反射护栏 + 循环引用安全断言、纵深过滤用例;
build/vet/gofmt、-tags unit 与 -race 全绿。
2026-09-20 11:26:19 +08:00

131 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"sync"
"testing"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
hcplugin "github.com/hashicorp/go-plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// brokerProbePlugin 是仅用于测试的插件传输实现:它实现 HostBrokerReceiver 以拿到
// broker,并在 InitHostServices 中用宿主下发的 broker id 拨号回宿主的 HostService,
// 走一次真实 gRPC 的 KVSet/KVGet 往返。
type brokerProbePlugin struct {
pluginv1.UnimplementedTransportPluginServer
broker *hcplugin.GRPCBroker
mu sync.Mutex
ready bool
getFound bool
getValue []byte
failure string
}
func (p *brokerProbePlugin) SetHostBroker(broker *hcplugin.GRPCBroker) { p.broker = broker }
func (p *brokerProbePlugin) InitHostServices(ctx context.Context, req *pluginv1.InitHostServicesRequest) (*pluginv1.InitHostServicesResponse, error) {
if p.broker == nil {
return &pluginv1.InitHostServicesResponse{Ready: false, Message: "broker 未注入"}, nil
}
conn, err := p.broker.Dial(req.HostServiceId)
if err != nil {
p.record("dial: " + err.Error())
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
}
defer func() { _ = conn.Close() }()
client := pluginv1.NewHostServiceClient(conn)
if _, err := client.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "probe", Value: []byte("hello"), TtlSeconds: 60}); err != nil {
p.record("set: " + err.Error())
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
}
got, err := client.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "probe"})
if err != nil {
p.record("get: " + err.Error())
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
}
p.mu.Lock()
p.ready = true
p.getFound = got.Found
p.getValue = got.Value
p.mu.Unlock()
return &pluginv1.InitHostServicesResponse{Ready: true}, nil
}
func (p *brokerProbePlugin) record(failure string) {
p.mu.Lock()
p.failure = failure
p.mu.Unlock()
}
// noHostServicesPlugin 模拟老插件:不实现 InitHostServices(返回 Unimplemented),
// 也不接收 broker。
type noHostServicesPlugin struct {
pluginv1.UnimplementedTransportPluginServer
}
func dispenseTransportClient(t *testing.T, impl pluginv1.TransportPluginServer) *pluginv1.TransportClient {
t.Helper()
client, _ := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{
pluginv1.TransportPluginName: &pluginv1.GRPCPlugin{Impl: impl},
})
t.Cleanup(func() { _ = client.Close() })
dispensed, err := client.Dispense(pluginv1.TransportPluginName)
require.NoError(t, err)
tc, ok := dispensed.(*pluginv1.TransportClient)
require.True(t, ok)
require.NotNil(t, tc.Broker)
return tc
}
// 端到端验证 broker 反向通道:宿主服务 AcceptAndServe + 插件 Dial 回来,经真实 gRPC
// 完成 KV 往返,且写入落到宿主注入的 pluginKey 命名空间下。
func TestOfferPluginHostServices_BrokerRoundtrip(t *testing.T) {
probe := &brokerProbePlugin{}
tc := dispenseTransportClient(t, probe)
store := newFakePluginKVStore()
hostServer := newPluginHostServiceServer("test.plugin", store, nil, PluginAccountScope{})
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
probe.mu.Lock()
defer probe.mu.Unlock()
require.Empty(t, probe.failure)
require.True(t, probe.ready)
assert.True(t, probe.getFound)
assert.Equal(t, []byte("hello"), probe.getValue)
stored, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, []byte("hello"), stored)
}
// 老插件不实现 InitHostServices 时,offerPluginHostServices 必须优雅降级、不 panic、
// 不写入任何状态。
func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) {
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
store := newFakePluginKVStore()
hostServer := newPluginHostServiceServer("test.plugin", store, nil, PluginAccountScope{})
require.NotPanics(t, func() {
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
})
_, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
require.NoError(t, err)
assert.False(t, found)
}
// hostServices 为 nil(未配置键值存储)时不得触发任何 broker 交互。
func TestOfferPluginHostServices_NilHostServicesNoop(t *testing.T) {
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
require.NotPanics(t, func() {
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, nil, 5*time.Second)
})
}