// 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 . package mcp import ( "encoding/json" "testing" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTokenAuthorizes_PermissionPresent(t *testing.T) { token := &models.APIToken{ APIPermissions: models.APIPermissions{ "projects": []string{"read_one", "read_all"}, }, } r := &Resource{Name: "projects"} assert.True(t, tokenAuthorizes(token, r.Name, OpReadOne)) assert.True(t, tokenAuthorizes(token, r.Name, OpReadAll)) } func TestTokenAuthorizes_PermissionAbsent(t *testing.T) { token := &models.APIToken{ APIPermissions: models.APIPermissions{ "projects": []string{"read_one"}, }, } r := &Resource{Name: "projects"} assert.False(t, tokenAuthorizes(token, r.Name, OpCreate)) assert.False(t, tokenAuthorizes(token, r.Name, OpUpdate)) assert.False(t, tokenAuthorizes(token, r.Name, OpDelete)) } func TestTokenAuthorizes_NoGroup(t *testing.T) { token := &models.APIToken{ APIPermissions: models.APIPermissions{ "mcp": []string{"access"}, }, } assert.False(t, tokenAuthorizes(token, "projects", OpReadOne)) assert.False(t, tokenAuthorizes(token, "projects", OpCreate)) } func TestTokenAuthorizes_NilPermissionsMap(t *testing.T) { // A token with nil APIPermissions should never authorize anything. token := &models.APIToken{APIPermissions: nil} assert.False(t, tokenAuthorizes(token, "projects", OpReadOne)) } func TestTokenAuthorizes_NilToken(t *testing.T) { // Defensive: a nil token (should never happen in practice because the // entry handler always sets one) must not panic. assert.False(t, tokenAuthorizes(nil, "projects", OpReadOne)) } func TestTokenAuthorizes_FullScopes(t *testing.T) { token := &models.APIToken{ APIPermissions: models.APIPermissions{ "projects": []string{"create", "read_one", "read_all", "update", "delete"}, }, } for _, op := range AllOps() { assert.Truef(t, tokenAuthorizes(token, "projects", op), "op %s should be authorized", op.ToolSuffix()) } } func TestDispatchScopeDenied(t *testing.T) { resetRegistry(t) installStubCRUD(t) tracker := &stubTracker{} require.NoError(t, Register(Resource{ Name: "stubs", EmptyStruct: tracker.empty, Ops: OpCreate | OpReadOne, Inputs: map[Op]any{ OpCreate: &stubInput{}, OpReadOne: &stubInput{}, }, })) // Token has read_one but not create. token := &models.APIToken{ APIPermissions: models.APIPermissions{ "stubs": []string{"read_one"}, }, } ctx := WithToken(newAuthedCtx(t), token) _, err := Dispatch(ctx, "stubs_create", json.RawMessage(`{"title":"x"}`)) require.Error(t, err) require.ErrorIs(t, err, ErrScopeDenied) // The denied call must not have invoked Do*. assert.Nil(t, tracker.last, "Do* must not run for a denied scope") } func TestDispatchScopeDenied_NoTokenInContext(t *testing.T) { // Without a token in context, the scope check has nothing to authorize // against. The dispatcher should treat a missing token as denied // (defensive — the entry handler always sets one in production). resetRegistry(t) installStubCRUD(t) tracker := &stubTracker{} require.NoError(t, Register(Resource{ Name: "stubs", EmptyStruct: tracker.empty, Ops: OpReadOne, Inputs: map[Op]any{OpReadOne: &stubInput{}}, })) // User in context but no token — the scope check must still deny. u := &user.User{ID: 42} ctx := WithUser(t.Context(), u) _, err := Dispatch(ctx, "stubs_read_one", json.RawMessage(`{"id":1}`)) require.Error(t, err) require.ErrorIs(t, err, ErrScopeDenied) assert.Nil(t, tracker.last) } func TestDispatchTypedScopeDenied(t *testing.T) { // DispatchTyped is the path AddTool handlers take; the same scope check // must apply there. resetRegistry(t) installStubCRUD(t) tracker := &stubTracker{} require.NoError(t, Register(Resource{ Name: "stubs", EmptyStruct: tracker.empty, Ops: OpDelete, Inputs: map[Op]any{OpDelete: &stubInput{}}, })) token := &models.APIToken{ APIPermissions: models.APIPermissions{ "stubs": []string{"read_one"}, // delete not allowed }, } ctx := WithToken(newAuthedCtx(t), token) _, err := DispatchTyped(ctx, "stubs_delete", &stubInput{ID: 1}) require.Error(t, err) require.ErrorIs(t, err, ErrScopeDenied) assert.Nil(t, tracker.last) } func TestDispatchScopeAllowed(t *testing.T) { // Positive control: with the right scope, dispatch reaches the stub. resetRegistry(t) installStubCRUD(t) tracker := &stubTracker{} require.NoError(t, Register(Resource{ Name: "stubs", EmptyStruct: tracker.empty, Ops: OpReadOne, Inputs: map[Op]any{OpReadOne: &stubInput{}}, })) token := &models.APIToken{ APIPermissions: models.APIPermissions{ "stubs": []string{"read_one"}, }, } ctx := WithToken(newAuthedCtx(t), token) _, err := Dispatch(ctx, "stubs_read_one", json.RawMessage(`{"id":1}`)) require.NoError(t, err) require.NotNil(t, tracker.last) assert.Equal(t, "ReadOne", tracker.last.called) }