mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
209 lines
8.2 KiB
Go
209 lines
8.2 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/modules/humabridge"
|
|
apiv2 "code.vikunja.io/api/pkg/routes/api/v2"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newTestCaller(t *testing.T) (*Module, context.Context) {
|
|
t.Helper()
|
|
return newTestCallerFor(t, newTestModule(t))
|
|
}
|
|
func newTestCallerFor(t *testing.T, m *Module) (*Module, context.Context) {
|
|
t.Helper()
|
|
m.authorize = func(*models.APIToken, string, string) bool { return true }
|
|
caller := httptest.NewRequest(http.MethodPost, "/api/v2/mcp", nil)
|
|
caller.Header.Set("Authorization", "Bearer tk_test")
|
|
caller.RemoteAddr = "203.0.113.9:4242"
|
|
ec := echo.New().NewContext(caller, httptest.NewRecorder())
|
|
ec.Set("api_token", &models.APIToken{ID: 1})
|
|
return m, context.WithValue(context.Background(), humabridge.EchoContextKey, ec)
|
|
}
|
|
func echoResult(t *testing.T, res any) map[string]any {
|
|
t.Helper()
|
|
m, ok := res.(map[string]any)
|
|
require.True(t, ok, "%T", res)
|
|
return m
|
|
}
|
|
func TestCallTool_ListForwardsAuthAndFormat(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
res, err := m.callTool(ctx, "things_list", json.RawMessage(`{"q":"x","expand":["a","b"]}`))
|
|
require.NoError(t, err)
|
|
out := echoResult(t, res)
|
|
assert.Equal(t, "Bearer tk_test", out["auth"])
|
|
q := out["query"].(map[string]any)
|
|
assert.Equal(t, "x", q["q"])
|
|
assert.Empty(t, q["format"])
|
|
assert.Equal(t, "a|b", q["expand"])
|
|
res, err = m.callTool(ctx, "things_list", json.RawMessage(`{"format":"markdown"}`))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "markdown", echoResult(t, res)["query"].(map[string]any)["format"])
|
|
}
|
|
func TestCallTool_NullParamIsOmitted(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
res, err := m.callTool(ctx, "things_list", json.RawMessage(`{"q":"x","expand":null}`))
|
|
require.NoError(t, err)
|
|
q := echoResult(t, res)["query"].(map[string]any)
|
|
assert.Equal(t, "x", q["q"])
|
|
assert.Empty(t, q["expand"])
|
|
res, err = m.callTool(ctx, "things_list", json.RawMessage(`{"q":null}`))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, echoResult(t, res)["query"].(map[string]any)["q"])
|
|
}
|
|
func TestCallTool_NullBodyFieldClearsIt(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
res, err := m.callTool(ctx, "things_update", json.RawMessage(`{"id":7,"description":null}`))
|
|
require.NoError(t, err)
|
|
body := echoResult(t, res)["body"].(map[string]any)
|
|
assert.Equal(t, "stored", body["title"])
|
|
assert.Empty(t, body["description"])
|
|
}
|
|
func TestCallTool_CreateSendsJSONBody(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
res, err := m.callTool(ctx, "things_create", json.RawMessage(`{"title":"hi","done":true}`))
|
|
require.NoError(t, err)
|
|
out := echoResult(t, res)
|
|
assert.Equal(t, "application/json", out["content_type"])
|
|
assert.Equal(t, "hi", out["body"].(map[string]any)["title"])
|
|
}
|
|
func TestCallTool_UpdateIsMergePatch(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
res, err := m.callTool(ctx, "things_update", json.RawMessage(`{"id":7,"done":true}`))
|
|
require.NoError(t, err)
|
|
out := echoResult(t, res)
|
|
assert.Equal(t, http.MethodPut, out["method"])
|
|
body := out["body"].(map[string]any)
|
|
assert.Equal(t, "stored", body["title"])
|
|
assert.Equal(t, "keep me", body["description"])
|
|
assert.Equal(t, true, body["done"])
|
|
}
|
|
func TestCallTool_DeleteReturnsOK(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
res, err := m.callTool(ctx, "things_delete", json.RawMessage(`{"id":1}`))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]any{"ok": true}, res)
|
|
}
|
|
func TestCallTool_HTTPErrorIsToolError(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
_, err := m.callTool(ctx, "things_read", json.RawMessage(`{"id":404}`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "404 Not Found")
|
|
assert.Contains(t, err.Error(), "no such thing")
|
|
}
|
|
func TestCallTool_UnauthorizedMentionsScopes(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
_, err := m.callTool(ctx, "things_read", json.RawMessage(`{"id":401}`))
|
|
require.Error(t, err)
|
|
assert.Equal(t, "401 Unauthorized: Unauthorized — "+scopeHint, err.Error())
|
|
}
|
|
func TestCallTool_UnknownArgumentRejectedBeforeDispatch(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
_, err := m.callTool(ctx, "things_read", json.RawMessage(`{"id":1,"bogus":1}`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "bogus")
|
|
}
|
|
func TestCallTool_ScopeDenied(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
m.authorize = func(*models.APIToken, string, string) bool { return false }
|
|
_, err := m.callTool(ctx, "things_read", json.RawMessage(`{"id":1}`))
|
|
require.ErrorIs(t, err, errScopeDenied)
|
|
}
|
|
func TestCallTool_UnknownTool(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
_, err := m.callTool(ctx, "nope", nil)
|
|
require.ErrorIs(t, err, errToolNotFound)
|
|
}
|
|
func TestCallTool_ClientAddress(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
caller := humabridge.EchoContextFrom(ctx).Request()
|
|
caller.Host = "vikunja.example.com"
|
|
caller.Header.Add("X-Forwarded-For", "203.0.113.9")
|
|
caller.Header.Add("X-Forwarded-For", "198.51.100.4")
|
|
caller.Header.Set("X-Request-Id", "req-1")
|
|
caller.Header.Set("Accept-Language", "de")
|
|
caller.Header.Set("X-Forwarded-Proto", "https")
|
|
tl := m.index["things_read"]
|
|
req, err := tl.newRequest(ctx, humabridge.EchoContextFrom(ctx), map[string]json.RawMessage{"id": json.RawMessage(`1`)})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, caller.RemoteAddr, req.RemoteAddr)
|
|
assert.Equal(t, "vikunja.example.com", req.Host)
|
|
assert.Equal(t, []string{
|
|
"203.0.113.9",
|
|
"198.51.100.4",
|
|
}, req.Header.Values("X-Forwarded-For"))
|
|
assert.Equal(t, "req-1", req.Header.Get("X-Request-Id"))
|
|
assert.Equal(t, "vikunja.example.com", req.Header.Get("X-Forwarded-Host"))
|
|
assert.Empty(t, req.Header.Get("Accept-Language"))
|
|
assert.Empty(t, req.Header.Get("X-Forwarded-Proto"))
|
|
}
|
|
func TestCallTool_UsesTheResponseRequestID(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
ec := humabridge.EchoContextFrom(ctx)
|
|
ec.Response().Header().Set(echo.HeaderXRequestID, "generated-1")
|
|
ec.Request().Header.Set(echo.HeaderXRequestID, "from-client")
|
|
tl := m.index["things_read"]
|
|
req, err := tl.newRequest(ctx, ec, map[string]json.RawMessage{"id": json.RawMessage(`1`)})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "generated-1", req.Header.Get(echo.HeaderXRequestID))
|
|
}
|
|
func TestCallTool_KeepsTheForwardedHost(t *testing.T) {
|
|
m, ctx := newTestCaller(t)
|
|
caller := humabridge.EchoContextFrom(ctx).Request()
|
|
caller.Host = "internal:3456"
|
|
caller.Header.Set("X-Forwarded-Host", "vikunja.example.com")
|
|
tl := m.index["things_read"]
|
|
req, err := tl.newRequest(ctx, humabridge.EchoContextFrom(ctx), map[string]json.RawMessage{"id": json.RawMessage(`1`)})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "vikunja.example.com", req.Header.Get("X-Forwarded-Host"))
|
|
}
|
|
|
|
func TestNewRequest_PatchSendsFormatAsAHeader(t *testing.T) {
|
|
api := newRichTextAPI(t)
|
|
spec := specFor(t, api, http.MethodPatch, "/notes/{id}")
|
|
tl := &tool{
|
|
op: api.OpenAPI().Paths["/notes/{id}"].Patch,
|
|
spec: spec,
|
|
contentType: "application/merge-patch+json",
|
|
}
|
|
_, ctx := newTestCaller(t)
|
|
req, err := tl.newRequest(ctx, humabridge.EchoContextFrom(ctx), map[string]json.RawMessage{
|
|
"id": json.RawMessage(`5`),
|
|
"format": json.RawMessage(`"markdown"`),
|
|
"description": json.RawMessage(`"**bold**"`),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "markdown", req.Header.Get(apiv2.RichTextFormatHeader))
|
|
assert.Equal(t, "/notes/5", req.URL.String())
|
|
body, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"description":"**bold**"}`, string(body))
|
|
}
|