Files
opsxdb/server/internal/aitools/runtime_test.go
T
logan 0b68c8344d feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio
- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认)
- 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go
- 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码)
- Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
2026-10-15 11:14:08 +08:00

230 lines
6.2 KiB
Go

package aitools
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"opsxdb/server/internal/db"
)
func TestIsReadOnlySQL(t *testing.T) {
okCases := []string{
"SELECT 1",
"SHOW TABLES",
"EXPLAIN SELECT * FROM t",
"WITH c AS (SELECT 1 AS x) SELECT * FROM c",
"PRAGMA table_info(t)",
"DESCRIBE t",
}
for _, sql := range okCases {
if ok, reason := IsReadOnlySQL(sql); !ok {
t.Fatalf("expected read-only ok for %q: %s", sql, reason)
}
}
bad := []string{
"DELETE FROM t",
"DELETE FROM t WHERE id=1",
"DROP TABLE t",
"INSERT INTO t VALUES (1)",
"UPDATE t SET a=1",
"CREATE TABLE t (id INT)",
"ALTER TABLE t ADD c INT",
"TRUNCATE TABLE t",
"WITH c AS (SELECT 1) INSERT INTO t SELECT * FROM c",
}
for _, sql := range bad {
if ok, _ := IsReadOnlySQL(sql); ok {
t.Fatalf("expected reject for %q", sql)
}
}
}
func TestCheckRedisReadOnly(t *testing.T) {
if _, err := CheckRedisReadOnly("GET foo"); err != nil {
t.Fatal(err)
}
if _, err := CheckRedisReadOnly("TYPE foo"); err != nil {
t.Fatal(err)
}
if _, err := CheckRedisReadOnly("SCAN 0 MATCH * COUNT 10"); err != nil {
t.Fatal(err)
}
for _, cmd := range []string{"FLUSHDB", "FLUSHALL", "KEYS *", "SET a 1", "DEL a"} {
if _, err := CheckRedisReadOnly(cmd); err == nil {
t.Fatalf("expected deny for %q", cmd)
}
}
}
func TestWhitelistEmptyRejects(t *testing.T) {
rt := NewRuntime(DefaultPolicy()) // empty whitelist
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{{ID: "c1", Name: "n", Driver: "sqlite"}}, nil
}
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Driver: "sqlite"}, nil
}
var audits []AuditEntry
rt.Audit = func(e AuditEntry) { audits = append(audits, e) }
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "SELECT 1",
})
if err == nil || !strings.Contains(err.Error(), "whitelist empty") {
t.Fatalf("expected whitelist empty error, got %v", err)
}
}
func TestDangerousSQLBlocked(t *testing.T) {
rt := NewRuntime(Policy{
AllowedConnectionIDs: []string{"c1"},
Mode: ModeReadOnly,
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
}
called := false
rt.ExecSQL = func(ctx context.Context, c db.ConnReq) db.ExecResult {
called = true
return db.ExecResult{}
}
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "c1",
"sql": "DROP TABLE users",
})
if err == nil {
t.Fatal("expected DROP blocked")
}
if called {
t.Fatal("ExecSQL must not be called for dangerous/write SQL")
}
}
func TestDecodeSecretOx1(t *testing.T) {
plain := "p@ss词"
enc := "ox1." + base64.StdEncoding.EncodeToString([]byte(plain))
if got := DecodeSecret(enc); got != plain {
t.Fatalf("got %q want %q", got, plain)
}
if DecodeSecret("plain") != "plain" {
t.Fatal("passthrough")
}
}
func TestSQLiteListAndExec(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "t.db")
if err := os.WriteFile(dbPath, []byte{}, 0600); err != nil {
t.Fatal(err)
}
init := db.ExecSQL(context.Background(), db.ConnReq{
Driver: "sqlite", Database: dbPath,
SQL: "CREATE TABLE person (id INTEGER PRIMARY KEY, name TEXT)",
})
if init.Error != "" {
t.Fatal(init.Error)
}
_ = db.ExecSQL(context.Background(), db.ConnReq{
Driver: "sqlite", Database: dbPath,
SQL: "INSERT INTO person (name) VALUES ('a')", ConfirmDanger: true,
})
rt := NewRuntime(Policy{
AllowedConnectionIDs: []string{"sqlite1"},
Mode: ModeReadOnly,
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*SavedConnection, error) {
return &SavedConnection{ID: id, Name: "local", Driver: "sqlite", Database: dbPath}, nil
}
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{{ID: "sqlite1", Name: "local", Driver: "sqlite", Database: dbPath}}, nil
}
out, err := rt.Call(context.Background(), ToolListTables, map[string]any{"connection_id": "sqlite1"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
tables := m["tables"].([]string)
found := false
for _, tb := range tables {
if tb == "person" {
found = true
}
}
if !found {
t.Fatalf("tables=%v", tables)
}
desc, err := rt.Call(context.Background(), ToolDescribeTable, map[string]any{
"connection_id": "sqlite1", "table": "person",
})
if err != nil {
t.Fatal(err)
}
_ = desc
q, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "sqlite1",
"sql": "SELECT name FROM person",
"limit": 10,
})
if err != nil {
t.Fatal(err)
}
qm := q.(map[string]any)
if qm["rowCount"].(int) < 1 {
t.Fatalf("rowCount=%v", qm["rowCount"])
}
// write rejected
if _, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
"connection_id": "sqlite1",
"sql": "INSERT INTO person (name) VALUES ('b')",
}); err == nil {
t.Fatal("insert must be rejected")
}
}
func TestListConnectionsFiltersWhitelist(t *testing.T) {
rt := NewRuntime(Policy{AllowedConnectionIDs: []string{"a"}, Mode: ModeReadOnly, DefaultLimit: 100, MaxLimit: 500})
rt.LoadConns = func() ([]SavedConnection, error) {
return []SavedConnection{
{ID: "a", Name: "A", Driver: "mysql", Host: "h", Port: 3306, Password: "secret"},
{ID: "b", Name: "B", Driver: "mysql", Host: "h2", Port: 3306},
}, nil
}
out, err := rt.Call(context.Background(), ToolListConnections, nil)
if err != nil {
t.Fatal(err)
}
conns := out.(map[string]any)["connections"].([]ConnectionSummary)
if len(conns) != 1 || conns[0].ID != "a" {
t.Fatalf("%+v", conns)
}
// ensure no password field leaked via JSON
raw := ResultJSON(out)
if strings.Contains(raw, "secret") || strings.Contains(raw, "password") {
t.Fatalf("password leaked: %s", raw)
}
}
func TestToolNamesStable(t *testing.T) {
names := ToolNames()
if len(names) != 7 {
t.Fatalf("want 7 tools, got %d", len(names))
}
if names[0] != ToolListConnections || names[len(names)-1] != ToolExecuteRedisCommand {
t.Fatalf("%v", names)
}
}