Files
HomeAgent/third_party/homeagent-sdk/example/qq/plugin_test.go
JianFeeeee 5160d8d4d1 chore(sdk-mirror): qq 插件 1.4.1(输出工具不再受当前会话身份限制)
镜像 SDK 仓的插件修复:被子的中断唤醒的一轮里,父带齐 meta 调 output_send__qq 也被
「可信 QQ 会话身份不完整」拒掉;输出改为先放行(目标由 meta 决定),读取类工具仍限当前会话。
2026-09-13 16:03:28 +08:00

238 lines
7.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 main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func newPermissionTestPlugin(t *testing.T) *Plugin {
t.Helper()
instance, err := NewPluginFactory("qq", nil)
if err != nil {
t.Fatal(err)
}
return instance.(*Plugin)
}
func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext {
return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}}
}
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("owner call rejected: %s", *ctx.Response)
}
}
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
p := newPermissionTestPlugin(t)
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
p.auth = qqAuthContext{active: true, userID: 10001}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") {
t.Fatalf("expected private-resource denial, got %#v", ctx.Response)
}
}
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") {
t.Fatalf("cross-group history not rejected: %#v", ctx.Response)
}
ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("current-group history rejected: %s", *ctx.Response)
}
}
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "来自未知事件(message_id=404)",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
}
}
func TestDuplicateQQOutputIsStopped(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxDuplicateSend = 1
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
ctx := toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("first send rejected: %s", *ctx.Response)
}
ctx = toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") {
t.Fatalf("duplicate send not stopped: %#v", ctx.Response)
}
}
func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
var path string
var request map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`))
}))
defer server.Close()
p := newPermissionTestPlugin(t)
p.napcatURL = server.URL
p.httpClient = server.Client()
_, err := p.handleChannelOutput(map[string]interface{}{
"payload": "hello",
"type": "text",
"meta": `{"group_id":20002,"user_id":10001}`,
})
if err != nil {
t.Fatal(err)
}
if path != "/send_group_msg" {
t.Fatalf("path=%q, want /send_group_msg", path)
}
segments, ok := request["message"].([]interface{})
if !ok || len(segments) < 2 {
t.Fatalf("message is not a segment array: %#v", request["message"])
}
mention, _ := segments[0].(map[string]interface{})
data, _ := mention["data"].(map[string]interface{})
if mention["type"] != "at" || data["qq"] != "10001" {
t.Fatalf("leading mention=%#v", mention)
}
}
// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。
// 现在只拦参数完全相同的重复调用。
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。
for i := 0; i < 5; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": fmt.Sprintf("message-%d", i),
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
for i := 0; i < 50; i++ {
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestZeroLimitsMeanUnlimited(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxQQOutputCalls = 0
p.maxDuplicateSend = 0
p.maxQQToolCalls = 0
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
for i := 0; i < 30; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "same-content",
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response)
}
}
}
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
// (报「可信 QQ 会话身份不完整」)。
//
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true}
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "带齐 meta 的主动发送",
"type": "text",
"meta": `{"user_id":2198972886}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
}
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
if err := p.beforeToolcall(ctx2); err != nil {
t.Fatal(err)
}
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
}
}