vikunja/pkg/modules/mcp/dispatcher_test.go

342 lines
11 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"
"errors"
"testing"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"code.vikunja.io/api/pkg/web/handler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
// stubCObject is a test double for handler.CObject that records which method
// was invoked by the dispatcher. Each instance must be checked individually,
// because handler.Do* runs against a fresh EmptyStruct() per call.
type stubCObject struct {
ID int64 `json:"id"`
Title string
// called records the most recent CRUD method invoked on this instance.
called string
// returnErr is returned from the next CRUD method invoked. Permission
// checks always allow access; failure scenarios are exercised by the
// model layer in the integration tests.
returnErr error
}
func (s *stubCObject) CanRead(_ *xorm.Session, _ web.Auth) (bool, int, error) {
return true, 0, nil
}
func (s *stubCObject) CanDelete(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
func (s *stubCObject) CanUpdate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
func (s *stubCObject) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
func (s *stubCObject) Create(_ *xorm.Session, _ web.Auth) error {
s.called = "Create"
return s.returnErr
}
func (s *stubCObject) ReadOne(_ *xorm.Session, _ web.Auth) error {
s.called = "ReadOne"
return s.returnErr
}
func (s *stubCObject) ReadAll(_ *xorm.Session, _ web.Auth, search string, page, perPage int) (any, int, int64, error) {
s.called = "ReadAll"
return []string{search}, page, int64(perPage), s.returnErr
}
func (s *stubCObject) Update(_ *xorm.Session, _ web.Auth) error {
s.called = "Update"
return s.returnErr
}
func (s *stubCObject) Delete(_ *xorm.Session, _ web.Auth) error {
s.called = "Delete"
return s.returnErr
}
// stubTracker tracks the *last* instance handed out by EmptyStruct so the
// test can inspect which method was invoked after the dispatcher has run.
type stubTracker struct {
last *stubCObject
nextErr error
}
func (s *stubTracker) empty() handler.CObject {
o := &stubCObject{returnErr: s.nextErr}
s.last = o
return o
}
// stubInput is the wrapper type used by the dispatcher tests for every op.
// In the real registry each op has its own wrapper type; for testing the
// dispatcher we only need something that unmarshal+ApplyTo work against.
type stubInput struct {
ID int64 `json:"id"`
Title string `json:"title"`
Search string `json:"search,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
}
// ApplyTo copies wrapper fields onto the model. This is the seam Task 4 will
// fill in for real resources; for now the dispatcher tests provide their own
// implementation via the inputAdapter interface so we can verify dispatch
// without depending on the (still-absent) per-resource adapter.
func (i *stubInput) ApplyTo(dst handler.CObject) error {
s, ok := dst.(*stubCObject)
if !ok {
return errors.New("stubInput: unexpected target type")
}
s.ID = i.ID
s.Title = i.Title
return nil
}
// ReadAllParams exposes the pagination fields to the dispatcher. The real
// wrappers in Task 4 follow the same shape; the dispatcher reads these
// without depending on the concrete struct.
func (i *stubInput) ReadAllParams() (string, int, int) {
return i.Search, i.Page, i.PerPage
}
// newAuthedCtx returns a context with a test user and an API token that
// authorizes every (resource, op) on the "stubs" resource — sufficient for
// the dispatcher's wiring tests. Scope-denied scenarios are covered in
// scope_test.go with explicitly narrower tokens.
func newAuthedCtx(t *testing.T) context.Context {
t.Helper()
u := &user.User{ID: 42}
token := &models.APIToken{
APIPermissions: models.APIPermissions{
"stubs": []string{"create", "read_one", "read_all", "update", "delete"},
},
}
ctx := WithUser(context.Background(), u)
return WithToken(ctx, token)
}
// installStubCRUD swaps the dispatcher's Do* function set with test doubles
// that drive the model's CRUD methods directly (no xorm session). It
// returns a teardown that restores the original handler.Do* set. Tests
// that need to verify dispatch routing without standing up the DB should
// call this at the top.
func installStubCRUD(t *testing.T) {
t.Helper()
saved := crud
crud = crudFuncs{
doCreate: func(_ context.Context, obj handler.CObject, a web.Auth) error {
return obj.Create(nil, a)
},
doReadOne: func(_ context.Context, obj handler.CObject, a web.Auth) (int, error) {
return 0, obj.ReadOne(nil, a)
},
doReadAll: func(_ context.Context, obj handler.CObject, a web.Auth, search string, page, perPage int) (any, int, int64, error) {
return obj.ReadAll(nil, a, search, page, perPage)
},
doUpdate: func(_ context.Context, obj handler.CObject, a web.Auth) error {
return obj.Update(nil, a)
},
doDelete: func(_ context.Context, obj handler.CObject, a web.Auth) error {
return obj.Delete(nil, a)
},
}
t.Cleanup(func() { crud = saved })
}
func TestDispatchToolNotFound(t *testing.T) {
resetRegistry(t)
_, err := Dispatch(newAuthedCtx(t), "missing_tool", json.RawMessage(`{}`))
require.Error(t, err)
assert.ErrorIs(t, err, ErrToolNotFound)
}
func TestDispatchNoUser(t *testing.T) {
resetRegistry(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
// Attach an authorising token but no user — the scope check passes,
// the user lookup inside dispatchPrepared fails. Ordering matters: the
// scope check runs first so callers without a token never reach the
// user check.
token := &models.APIToken{
APIPermissions: models.APIPermissions{
"stubs": []string{"read_one"},
},
}
ctx := WithToken(context.Background(), token)
_, err := Dispatch(ctx, "stubs_read_one", json.RawMessage(`{"id":1}`))
require.Error(t, err)
assert.ErrorIs(t, err, ErrNoUserInContext)
}
func TestDispatchCallsCreate(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpCreate,
Inputs: map[Op]any{OpCreate: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_create", json.RawMessage(`{"title":"hello"}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "Create", tracker.last.called)
assert.Equal(t, "hello", tracker.last.Title)
}
func TestDispatchCallsReadOne(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
out, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{"id":7}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "ReadOne", tracker.last.called)
assert.Equal(t, int64(7), tracker.last.ID)
// ReadOne returns the (now-populated) model directly.
assert.Same(t, tracker.last, out)
}
func TestDispatchCallsReadAll(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadAll,
Inputs: map[Op]any{OpReadAll: &stubInput{}},
}))
out, err := Dispatch(newAuthedCtx(t), "stubs_read_all", json.RawMessage(`{"search":"foo","page":2,"per_page":50}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "ReadAll", tracker.last.called)
// The stub's ReadAll echoes the search/page/per_page so we can confirm
// the dispatcher threaded the wrapper's pagination fields through.
assert.Equal(t, []string{"foo"}, out)
}
func TestDispatchCallsUpdate(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpUpdate,
Inputs: map[Op]any{OpUpdate: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_update", json.RawMessage(`{"id":3,"title":"new"}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "Update", tracker.last.called)
assert.Equal(t, int64(3), tracker.last.ID)
assert.Equal(t, "new", tracker.last.Title)
}
func TestDispatchCallsDelete(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpDelete,
Inputs: map[Op]any{OpDelete: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_delete", json.RawMessage(`{"id":9}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "Delete", tracker.last.called)
assert.Equal(t, int64(9), tracker.last.ID)
}
func TestDispatchModelErrorPropagates(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
wantErr := errors.New("simulated model error")
tracker := &stubTracker{nextErr: wantErr}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{"id":1}`))
require.Error(t, err)
assert.ErrorIs(t, err, wantErr)
}
func TestDispatchInvalidJSON(t *testing.T) {
resetRegistry(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{not json`))
require.Error(t, err)
}
func TestDispatchUnsupportedOpForResource(t *testing.T) {
resetRegistry(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne, // only read_one is registered
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
// stubs_create was never registered, so it must be tool-not-found.
_, err := Dispatch(newAuthedCtx(t), "stubs_create", json.RawMessage(`{}`))
require.Error(t, err)
assert.ErrorIs(t, err, ErrToolNotFound)
}