Files
qiuxsgit 16ec75c7a3 feat(auth): 为 HTTP API 和 MCP 接口增加可选 Token 鉴权 (#803)
为 /api/v1/* 与 /mcp 增加可选的静态 Bearer Token 鉴权,默认关闭以保持向后兼容。

- 通过 AUTH_TOKEN 环境变量或 -token 启动参数配置,非空参数优先
- /health 与 CORS OPTIONS 预检保持公开
- scheme 大小写不敏感,仅对 credentials 做常量时间比较
- Token 不出现在日志、错误响应与 -h 帮助输出中
- 补充中英文 README、docs/API.md 与 Docker 文档
2026-08-13 11:42:30 +08:00

159 lines
5.3 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMCPStatelessSinglePost 固定「/mcp 接受不带 initialize 握手的单次 POST」这一契约。
//
// 契约由 routes.go 里一行 Stateless 支撑,丢掉它编译和其他单测都不会报错。
func TestMCPStatelessSinglePost(t *testing.T) {
router := setupRoutes(NewAppServer(NewXiaohongshuService(), ""))
server := httptest.NewServer(router)
defer server.Close()
post := func(t *testing.T, body string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, server.URL+"/mcp", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
// 只用 tools/list:它不碰浏览器,而契约丢失时它恰好就是失败点——
// 有状态模式下无 session 调用 tools/list 会被回以「握手前不允许调用」。
resp := post(t, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var result struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
require.Nil(t, result.Error, "无握手的 tools/list 不应报错")
assert.NotEmpty(t, result.Result.Tools, "应返回已注册的工具")
}
// TestNotificationToolsRegistered 固定通知相关工具已注册到 MCP。
//
// 三个工具的注册各是 registerTools 里一段独立代码,漏掉任何一个编译都不会报错,
// 只有真正调用时才会发现工具不存在。
func TestNotificationToolsRegistered(t *testing.T) {
router := setupRoutes(NewAppServer(NewXiaohongshuService(), ""))
server := httptest.NewServer(router)
defer server.Close()
req, err := http.NewRequest(http.MethodPost, server.URL+"/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var result struct {
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
names := make(map[string]bool, len(result.Result.Tools))
for _, tool := range result.Result.Tools {
names[tool.Name] = true
}
for _, want := range []string{"get_unread_count", "list_notifications", "reply_notification", "like_notification"} {
assert.True(t, names[want], "工具 %s 应已注册", want)
}
}
// TestNotificationRoutesRegistered 固定通知的 HTTP 路由存在。
//
// 读路由表而不是发请求:这些 handler 会真的起浏览器访问小红书,
// 单测里不能碰。
func TestNotificationRoutesRegistered(t *testing.T) {
router := setupRoutes(NewAppServer(NewXiaohongshuService(), ""))
registered := make(map[string]bool)
for _, r := range router.Routes() {
registered[r.Method+" "+r.Path] = true
}
// 列表接口两个参数都可选,GET 也要能进
for _, want := range []string{
"GET /api/v1/notifications/unread",
"GET /api/v1/notifications/list",
"POST /api/v1/notifications/list",
"POST /api/v1/notifications/reply",
"POST /api/v1/notifications/like",
} {
assert.True(t, registered[want], "路由 %s 应已注册", want)
}
}
func TestProtectedRoutesRequireBearerToken(t *testing.T) {
router := setupRoutes(NewAppServer(NewXiaohongshuService(), "secret-token"))
tests := []struct {
name string
method string
path string
wantStatus int
}{
{name: "health remains public", method: http.MethodGet, path: "/health", wantStatus: http.StatusOK},
{name: "MCP requires token", method: http.MethodPost, path: "/mcp", wantStatus: http.StatusUnauthorized},
{name: "MCP child path requires token", method: http.MethodPost, path: "/mcp/child", wantStatus: http.StatusUnauthorized},
{name: "HTTP API requires token", method: http.MethodGet, path: "/api/v1/notifications/unread", wantStatus: http.StatusUnauthorized},
{name: "CORS preflight remains public", method: http.MethodOptions, path: "/mcp", wantStatus: http.StatusNoContent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, tt.path, nil)
router.ServeHTTP(recorder, request)
assert.Equal(t, tt.wantStatus, recorder.Code)
})
}
}
func TestMCPAcceptsConfiguredBearerToken(t *testing.T) {
router := setupRoutes(NewAppServer(NewXiaohongshuService(), "secret-token"))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
request.Header.Set("Authorization", "Bearer secret-token")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
router.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
}