Files

196 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_HyphenatedGroupKey(t *testing.T) {
// The frontend snake_cases payloads, so a hyphenated slug must still match.
token := &models.APIToken{
APIPermissions: models.APIPermissions{
"time-entries": []string{"read_all"},
},
}
assert.True(t, tokenAuthorizes(token, "time_entries", OpReadAll))
assert.False(t, tokenAuthorizes(token, "time_entries", 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: the entry handler always sets a token, but a nil 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.Permission())
}
}
func TestDispatchScopeDenied(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
Model: tracker.empty,
Ops: OpCreate | OpReadOne,
}))
// 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)
// Register reflects the model once, so an instance exists; what matters is that no CRUD method ran.
assert.Empty(t, tracker.last.called, "Do* must not run for a denied scope")
}
func TestDispatchScopeDenied_NoTokenInContext(t *testing.T) {
// A missing token must be treated as denied, not as an unrestricted call.
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
Model: tracker.empty,
Ops: OpReadOne,
}))
// 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.Empty(t, tracker.last.called)
}
func TestDispatchDeleteScopeDenied(t *testing.T) {
// Delete is the most destructive op; the scope check must gate it too.
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
Model: tracker.empty,
Ops: OpDelete,
}))
token := &models.APIToken{
APIPermissions: models.APIPermissions{
"stubs": []string{"read_one"}, // delete not allowed
},
}
ctx := WithToken(newAuthedCtx(t), token)
_, err := Dispatch(ctx, "stubs_delete", json.RawMessage(`{"id":1}`))
require.Error(t, err)
require.ErrorIs(t, err, ErrScopeDenied)
assert.Empty(t, tracker.last.called)
}
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",
Model: tracker.empty,
Ops: OpReadOne,
}))
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)
}