195 lines
5.7 KiB
Go
195 lines
5.7 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 (
|
|
"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)
|
|
}
|