Files
sub2api/backend/internal/service/plugin_host_services_test.go
T

332 lines
13 KiB
Go

package service
import (
"context"
"math"
"net/http"
"sort"
"strings"
"sync"
"testing"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// fakePluginKVStore 是内存版 PluginKVStore,按 (pluginKey, namespace, key) 三元组隔离,
// 用于确定性地验证宿主服务端的校验与命名空间隔离逻辑。
type fakePluginKVStore struct {
mu sync.Mutex
values map[string][]byte
ttls map[string]time.Duration
}
func newFakePluginKVStore() *fakePluginKVStore {
return &fakePluginKVStore{values: map[string][]byte{}, ttls: map[string]time.Duration{}}
}
func (f *fakePluginKVStore) compose(pluginKey, namespace, key string) string {
return pluginKey + "\x00" + namespace + "\x00" + key
}
func (f *fakePluginKVStore) Get(_ context.Context, pluginKey, namespace, key string) ([]byte, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
value, ok := f.values[f.compose(pluginKey, namespace, key)]
if !ok {
return nil, false, nil
}
return append([]byte(nil), value...), true, nil
}
func (f *fakePluginKVStore) Set(_ context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
composed := f.compose(pluginKey, namespace, key)
f.values[composed] = append([]byte(nil), value...)
f.ttls[composed] = ttl
return nil
}
func (f *fakePluginKVStore) Delete(_ context.Context, pluginKey, namespace, key string) error {
f.mu.Lock()
defer f.mu.Unlock()
composed := f.compose(pluginKey, namespace, key)
delete(f.values, composed)
delete(f.ttls, composed)
return nil
}
func (f *fakePluginKVStore) List(_ context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
prefix := f.compose(pluginKey, namespace, keyPrefix)
scope := f.compose(pluginKey, namespace, "")
var keys []string
for composed := range f.values {
if strings.HasPrefix(composed, prefix) {
keys = append(keys, strings.TrimPrefix(composed, scope))
}
}
sort.Strings(keys)
if limit > 0 && len(keys) > limit {
keys = keys[:limit]
}
return keys, nil
}
func TestPluginHostServiceServer_SetGetDeleteRoundtrip(t *testing.T) {
store := newFakePluginKVStore()
server := newPluginHostServiceServer("local.example.plugin", store, nil)
ctx := context.Background()
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "alpha", Value: []byte("v1"), TtlSeconds: 60})
require.NoError(t, err)
got, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "alpha"})
require.NoError(t, err)
assert.True(t, got.Found)
assert.Equal(t, []byte("v1"), got.Value)
assert.Equal(t, time.Minute, store.ttls[store.compose("local.example.plugin", "state", "alpha")])
_, err = server.KVDelete(ctx, &pluginv1.KVDeleteRequest{Namespace: "state", Key: "alpha"})
require.NoError(t, err)
got, err = server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "alpha"})
require.NoError(t, err)
assert.False(t, got.Found)
}
func TestPluginHostServiceServer_GetNotFound(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
got, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "missing"})
require.NoError(t, err)
assert.False(t, got.Found)
assert.Nil(t, got.Value)
}
func TestPluginHostServiceServer_NamespaceIsolationAcrossPlugins(t *testing.T) {
store := newFakePluginKVStore()
first := newPluginHostServiceServer("local.plugin.one", store, nil)
second := newPluginHostServiceServer("local.plugin.two", store, nil)
ctx := context.Background()
_, err := first.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "shared", Value: []byte("first")})
require.NoError(t, err)
// 另一个插件即便使用完全相同的 namespace/key 也读不到,因为 pluginKey 由宿主注入。
got, err := second.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "shared"})
require.NoError(t, err)
assert.False(t, got.Found)
got, err = first.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "shared"})
require.NoError(t, err)
assert.True(t, got.Found)
assert.Equal(t, []byte("first"), got.Value)
}
func TestPluginHostServiceServer_ListPrefixAndLimit(t *testing.T) {
store := newFakePluginKVStore()
server := newPluginHostServiceServer("local.example.plugin", store, nil)
ctx := context.Background()
for _, key := range []string{"account-1", "account-2", "account-3", "other-1"} {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: key, Value: []byte("x")})
require.NoError(t, err)
}
resp, err := server.KVList(ctx, &pluginv1.KVListRequest{Namespace: "state", KeyPrefix: "account-"})
require.NoError(t, err)
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3"}, resp.Keys)
resp, err = server.KVList(ctx, &pluginv1.KVListRequest{Namespace: "state", KeyPrefix: "account-", Limit: 2})
require.NoError(t, err)
assert.Len(t, resp.Keys, 2)
}
func TestPluginHostServiceServer_Validation(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
ctx := context.Background()
oversized := make([]byte, pluginKVMaxValueBytes+1)
cases := []struct {
name string
call func() error
}{
{"empty namespace", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "", Key: "k"})
return err
}},
{"namespace with colon", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "a:b", Key: "k"})
return err
}},
{"key with glob", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "a*"})
return err
}},
{"empty key", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: ""})
return err
}},
{"oversized value", func() error {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: oversized})
return err
}},
{"negative ttl", func() error {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: -1})
return err
}},
{"ttl over max", func() error {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: int64(pluginKVMaxTTL/time.Second) + 1})
return err
}},
{"ttl overflow", func() error {
// 溢出用例:seconds*time.Second 会回绕成负值,必须仍判为超限而非放行。
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: math.MaxInt64})
return err
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
})
}
}
func TestPluginHostServiceServer_UnavailableWhenNoStore(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", nil, nil)
_, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
// pluginKey 为空同样视为不可用,避免键前缀塌缩导致的跨插件泄漏。
blank := newPluginHostServiceServer("", newFakePluginKVStore(), nil)
_, err = blank.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
// pluginKey 含 ':' 会破坏内部键结构,必须判定为不可用而非放行。
tainted := newPluginHostServiceServer("bad:key", newFakePluginKVStore(), nil)
_, err = tainted.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
}
// pluginKey 的长度上限必须覆盖清单 id 的 maxLength(160),否则长 id 的合法插件会被
// 静默剥夺宿主服务能力。
func TestPluginHostServiceServer_PluginKeyLengthBoundary(t *testing.T) {
ctx := context.Background()
maxLen := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen), newFakePluginKVStore(), nil)
_, err := maxLen.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
require.NoError(t, err)
tooLong := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen+1), newFakePluginKVStore(), nil)
_, err = tooLong.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
}
type fakeAccountDirectory struct {
ids []int64
identity *PluginOutboundIdentity
lastReq int64
}
func (f *fakeAccountDirectory) ListPluginAccounts(_ context.Context, _, _ string) ([]int64, error) {
return f.ids, nil
}
func (f *fakeAccountDirectory) ResolvePluginOutboundIdentity(_ context.Context, accountID int64) (*PluginOutboundIdentity, error) {
f.lastReq = accountID
if f.identity == nil || f.identity.AccountID != accountID {
return nil, nil
}
return f.identity, nil
}
func TestPluginHostServiceServer_AccountDirectory(t *testing.T) {
ctx := context.Background()
dir := &fakeAccountDirectory{
ids: []int64{3, 7},
identity: &PluginOutboundIdentity{
AccountID: 7, Platform: "openai", AccountType: "oauth", ProxyURL: "http://p:1",
Token: "tok", Headers: http.Header{"Originator": {"codex-tui"}},
},
}
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), dir)
list, err := server.ListAccounts(ctx, &pluginv1.ListAccountsRequest{Platform: "openai", AccountType: "oauth"})
require.NoError(t, err)
assert.Equal(t, []int64{3, 7}, list.AccountIds)
resolved, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 7})
require.NoError(t, err)
require.True(t, resolved.Found)
assert.Equal(t, "tok", resolved.Token)
assert.Equal(t, "http://p:1", resolved.ProxyUrl)
assert.Equal(t, "codex-tui", resolved.Headers["Originator"].Values[0])
// unknown account → found=false, no error
miss, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 99})
require.NoError(t, err)
assert.False(t, miss.Found)
// invalid account id → InvalidArgument
_, err = server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 0})
assert.Equal(t, codes.InvalidArgument, status.Code(err))
}
// 无目录时账号目录 RPC 必须返回 Unavailable(KV 仍可用),保证未授权插件拿不到凭据。
func TestPluginHostServiceServer_DirectoryUnavailableWithoutDirectory(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
_, err := server.ListAccounts(context.Background(), &pluginv1.ListAccountsRequest{})
assert.Equal(t, codes.Unavailable, status.Code(err))
_, err = server.ResolveOutboundIdentity(context.Background(), &pluginv1.ResolveOutboundIdentityRequest{AccountId: 1})
assert.Equal(t, codes.Unavailable, status.Code(err))
// KV 仍可用
_, kvErr := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.NoError(t, kvErr)
}
func TestPluginDeclaresOpenAIOAuthCapability(t *testing.T) {
match := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}}
if !pluginDeclaresOpenAIOAuthCapability(match) {
t.Fatal("matching capability must be recognized")
}
wrongType := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: "apikey"}}}
if pluginDeclaresOpenAIOAuthCapability(wrongType) {
t.Fatal("wrong account_type must not match")
}
empty := PluginManifest{}
if pluginDeclaresOpenAIOAuthCapability(empty) {
t.Fatal("no capability must not match")
}
}
// buildHostServices 只对声明了 OpenAI OAuth 能力的插件注入账号目录。
func TestBuildHostServicesGatesDirectoryByCapability(t *testing.T) {
dir := &fakeAccountDirectory{}
m := &PluginManager{kvStore: newFakePluginKVStore(), accountDirectory: dir}
authorized := &PluginInstallation{PluginKey: "p.authorized", Manifest: PluginManifest{
Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}},
}}
srv, ok := m.buildHostServices(authorized).(*pluginHostServiceServer)
require.True(t, ok)
require.NotNil(t, srv.directory)
unauthorized := &PluginInstallation{PluginKey: "p.other", Manifest: PluginManifest{
Capabilities: []PluginCapability{{ID: "some.other.capability", Platform: "x", AccountType: "y"}},
}}
srv2, ok := m.buildHostServices(unauthorized).(*pluginHostServiceServer)
require.True(t, ok)
require.Nil(t, srv2.directory, "unauthorized plugin must not receive the account directory")
require.NotNil(t, srv2.store, "but KV remains available")
}