Files
kandev/apps/backend/internal/mcp/handlers/task_change_link_test.go
T
Alassane 262ecdf270 feat(mcp): handle task change links (#3506)
Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com>
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
2026-09-14 15:00:35 +01:00

169 lines
7.7 KiB
Go

package handlers
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/mcp/scope"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateTaskChangeLinkRequiresProviderRepositoryAndNumber(t *testing.T) {
valid, err := validateTaskChangeLink("GitLab", "repo-1", 42)
if err != nil || valid.Provider != "gitlab" {
t.Fatalf("validateTaskChangeLink() = %#v, %v", valid, err)
}
for _, input := range []TaskChangeLink{
{Provider: "", RepositoryID: "repo-1", Number: 1},
{Provider: "github", RepositoryID: "", Number: 1},
{Provider: "github", RepositoryID: "repo-1", Number: 0},
{Provider: "other", RepositoryID: "repo-1", Number: 1},
} {
if _, err := validateTaskChangeLink(input.Provider, input.RepositoryID, input.Number); err == nil {
t.Fatalf("validateTaskChangeLink(%#v) succeeded", input)
}
}
}
func TestTaskChangeLinkRequestRejectsCrossWorkspaceCaller(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-target", Name: "Target", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-caller", Name: "Caller", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-target", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-caller", WorkspaceID: "ws-caller", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-caller", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}
func TestTaskChangeLinkRequestRejectsMissingCallerTask(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-target", Name: "Target", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-target", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestTaskChangeLinkRequestParsesReplacementIdentity(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-1", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
msg := makeWSMessage(t, ws.ActionMCPReplaceTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "gitlab", "repository_id": "repo-new", "number": 42,
"old_provider": "github", "old_repository_id": "repo-old", "old_number": 7,
})
req, resp, err := h.taskChangeLinkRequest(ctx, msg, true)
require.NoError(t, err)
require.Nil(t, resp)
assert.Equal(t, TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-new", Number: 42}, req.Link)
require.NotNil(t, req.Old)
assert.Equal(t, TaskChangeLink{Provider: "github", RepositoryID: "repo-old", Number: 7}, *req.Old)
}
func TestTaskChangeLinkRequestRejectsInvalidReplacementIdentity(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-1", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
msg := makeWSMessage(t, ws.ActionMCPReplaceTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "gitlab", "repository_id": "repo-new", "number": 42,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, true)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestTaskChangeLinkRequestRejectsPayloadCallerMismatch(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
for _, task := range []*models.Task{
{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
{ID: "task-other", WorkspaceID: "ws-1", Title: "Other", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
} {
require.NoError(t, repo.CreateTask(ctx, task))
}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-1", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
h := &Handlers{taskSvc: svc}
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-other",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}
func TestTaskChangeLinkRequestRequiresTrustedPrincipal(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
for _, task := range []*models.Task{
{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
} {
require.NoError(t, repo.CreateTask(ctx, task))
}
h := &Handlers{taskSvc: svc}
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}