Files
kandev/apps/backend/internal/backendapp/canvas_edit_test.go
T
2026-09-11 02:51:46 +01:00

344 lines
13 KiB
Go

package backendapp
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/canvas"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/webapp"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/worktree/copyfiles"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func TestCanvasEditTargetDoesNotAuthorizeAnotherCanvas(t *testing.T) {
target := canvasEditSessionTarget{
Origin: canvasEditOrigin,
TaskID: "task-edit",
CanvasID: "canvas-a",
ReleaseID: "release-a",
}
if !canvasEditTargetMatches(target, "task-edit", "canvas-a") {
t.Fatal("expected the trusted target to authorize its canvas")
}
if canvasEditTargetMatches(target, "task-edit", "canvas-b") {
t.Fatal("trusted edit target authorized a different canvas")
}
if canvasEditTargetMatches(target, "other-task", "canvas-a") {
t.Fatal("trusted edit target authorized a different task")
}
}
func TestTaskCanvasAuthorizationUsesTaskIdentityNotCreatorSession(t *testing.T) {
task := &models.Task{ID: "task-current", WorkspaceID: "workspace-a"}
item := &canvas.Canvas{
ID: "canvas-task",
WorkspaceID: task.WorkspaceID,
TaskID: task.ID,
ScopeKind: canvas.ScopeTask,
CreatedBySessionID: "replacement-session-source",
}
if !taskCanvasMatchesTask(item, task) {
t.Fatal("same-task replacement session was denied by creator-session provenance")
}
item.TaskID = "other-task"
if taskCanvasMatchesTask(item, task) {
t.Fatal("canvas from another task was authorized")
}
item.TaskID = task.ID
item.WorkspaceID = "other-workspace"
if taskCanvasMatchesTask(item, task) {
t.Fatal("canvas from another workspace was authorized")
}
}
func TestCanvasEditSourceEntriesUseAssignedCanvasRoot(t *testing.T) {
entries := canvasEditSourceEntries("canvas-a", map[string][]byte{
"manifest.yaml": []byte("manifest"),
"index.html": []byte("index"),
})
got := make(map[string][]byte, len(entries))
for _, entry := range entries {
got[entry.RelPath] = entry.Content
if entry.Mode.Perm() != 0o600 {
t.Errorf("entry %q mode = %o, want 600", entry.RelPath, entry.Mode.Perm())
}
}
if string(got[".kandev/canvases/canvas-a/manifest.yaml"]) != "manifest" {
t.Fatalf("manifest was not rooted in the assigned canvas directory: %#v", got)
}
if string(got[".kandev/canvases/canvas-a/index.html"]) != "index" {
t.Fatalf("index was not rooted in the assigned canvas directory: %#v", got)
}
}
func TestRuntimeBindingPermissionsIntersectCurrentGrants(t *testing.T) {
declared := &manifest.Manifest{Capabilities: manifest.Capabilities{
APIRead: []string{"tasks", "workflows"},
APIWrite: []string{"messages"},
State: true,
}}
grants := []instances.Grant{
{PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace},
{PermissionKind: "api_write", Resource: "messages", ScopeCeiling: instances.ScopeTask},
}
got := runtimeGrantedPermissions(declared, instances.ScopeWorkspace, grants)
if len(got) != 1 || got[0] != "api_read:tasks" {
t.Fatalf("runtime permissions = %#v, want only declared permissions covered by grants", got)
}
}
func TestRuntimeNetworkOriginsUseSelectedWebAppDeclaration(t *testing.T) {
app := manifest.WebApp{NetworkOrigins: []string{
"https://api.example.com",
"https://unused.example.com",
}}
grants := []instances.Grant{
{PermissionKind: "network", NetworkOrigin: "https://api.example.com", ScopeCeiling: instances.ScopeTask},
{PermissionKind: "network", NetworkOrigin: "https://unrelated.example.com", ScopeCeiling: instances.ScopeTask},
}
got := runtimeNetworkOrigins(app, instances.ScopeTask, grants)
if len(got) != 1 || got[0] != "https://api.example.com" {
t.Fatalf("runtime network origins = %#v, want only the declared granted origin", got)
}
}
func TestCanvasEditLaunchFailureDeletesEphemeralTask(t *testing.T) {
tasks, canvasReader, releases, artifacts := newCanvasEditTestDependencies()
launcher := &fakeCanvasEditLauncher{launchErr: errors.New("agent unavailable")}
service := newCanvasEditService(
tasks, canvasReader, releases, artifacts, launcher,
&fakeCanvasEditSessionStore{}, &fakeCanvasEditExecutionResolver{},
)
if _, err := service.start(context.Background(), "canvas-a", "change the title"); err == nil {
t.Fatal("expected launch failure")
}
if len(tasks.deleted) != 1 || tasks.deleted[0] != "task-edit" {
t.Fatalf("deleted tasks = %#v, want the newly-created ephemeral task", tasks.deleted)
}
}
func TestCanvasEditMaterializesSourceAndDispatchesPrompt(t *testing.T) {
tasks, canvasReader, releases, artifacts := newCanvasEditTestDependencies()
launcher := &fakeCanvasEditLauncher{
launchResponse: &orchestrator.LaunchSessionResponse{
Success: true,
TaskID: "task-edit",
SessionID: "session-edit",
AgentExecutionID: "execution-edit",
},
}
sessions := &fakeCanvasEditSessionStore{}
copier := &fakeCanvasEditAgentCtl{}
resolver := &fakeCanvasEditExecutionResolver{client: copier}
service := newCanvasEditService(tasks, canvasReader, releases, artifacts, launcher, sessions, resolver)
result, err := service.start(context.Background(), "canvas-a", "change the title")
if err != nil {
t.Fatalf("start: %v", err)
}
if result.TaskID != "task-edit" || result.SessionID != "session-edit" || result.CanvasID != "canvas-a" {
t.Fatalf("result = %#v, want task/session/canvas IDs", result)
}
if tasks.createRequest == nil || !tasks.createRequest.IsEphemeral || tasks.createRequest.Origin != canvasEditOrigin {
t.Fatalf("create request = %#v, want ephemeral canvas-edit task", tasks.createRequest)
}
if tasks.createRequest.Metadata["origin"] != canvasEditOrigin || tasks.createRequest.Metadata["canvas_id"] != "canvas-a" {
t.Fatalf("task metadata = %#v, want canvas edit origin and canvas", tasks.createRequest.Metadata)
}
if launcher.launchRequest == nil {
t.Fatal("launch request was not recorded")
}
if launcher.launchRequest.AgentProfileID != "profile-default" || launcher.launchRequest.ExecutorID != "executor-default" {
t.Fatalf("launch defaults = profile %q executor %q", launcher.launchRequest.AgentProfileID, launcher.launchRequest.ExecutorID)
}
if launcher.prompt == "" || !containsCanvasEditText(launcher.prompt, "change the title") {
t.Fatalf("prompt = %q, want requested edit", launcher.prompt)
}
if !containsCanvasEditText(launcher.prompt, "api_read:tasks") || !containsCanvasEditText(launcher.prompt, "scope workspace") {
t.Fatalf("prompt = %q, want the effective grant projection", launcher.prompt)
}
target, ok := sessions.values[canvasEditSessionTargetMetadataKey].(canvasEditSessionTarget)
if !ok || !canvasEditTargetMatches(target, "task-edit", "canvas-a") {
t.Fatalf("trusted target metadata = %#v", sessions.values[canvasEditSessionTargetMetadataKey])
}
if len(copier.entries) != 2 {
t.Fatalf("copied entries = %#v, want active release source", copier.entries)
}
for _, entry := range copier.entries {
if entry.RelPath != ".kandev/canvases/canvas-a/index.html" && entry.RelPath != ".kandev/canvases/canvas-a/manifest.yaml" {
t.Fatalf("copied entry %q escaped assigned canvas root", entry.RelPath)
}
}
}
func TestCanvasEditMaterializationFailureDeletesEphemeralTask(t *testing.T) {
tasks, canvasReader, releases, artifacts := newCanvasEditTestDependencies()
launcher := &fakeCanvasEditLauncher{
launchResponse: &orchestrator.LaunchSessionResponse{
Success: true,
TaskID: "task-edit",
SessionID: "session-edit",
AgentExecutionID: "execution-edit",
},
}
resolver := &fakeCanvasEditExecutionResolver{client: &fakeCanvasEditAgentCtl{err: errors.New("copy failed")}}
service := newCanvasEditService(
tasks, canvasReader, releases, artifacts, launcher,
&fakeCanvasEditSessionStore{}, resolver,
)
if _, err := service.start(context.Background(), "canvas-a", "change the title"); err == nil {
t.Fatal("expected source materialization failure")
}
if len(tasks.deleted) != 1 || tasks.deleted[0] != "task-edit" {
t.Fatalf("deleted tasks = %#v, want the newly-created ephemeral task", tasks.deleted)
}
}
func containsCanvasEditText(value, want string) bool {
return len(value) >= len(want) && (value == want || len(value) > len(want) && stringContains(value, want))
}
func stringContains(value, want string) bool {
for i := 0; i+len(want) <= len(value); i++ {
if value[i:i+len(want)] == want {
return true
}
}
return false
}
func newCanvasEditTestDependencies() (*fakeCanvasEditTaskStore, *fakeCanvasEditCanvasStore, *fakeCanvasEditReleaseStore, *fakeCanvasEditArtifactStore) {
return &fakeCanvasEditTaskStore{
workspace: &models.Workspace{
ID: "workspace-a",
DefaultAgentProfileID: stringPointer("profile-default"),
DefaultExecutorID: stringPointer("executor-default"),
},
}, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{
ID: "canvas-a",
PluginInstanceID: "instance-a",
WorkspaceID: "workspace-a",
ScopeKind: canvas.ScopeWorkspace,
Status: instances.StatusActive,
ActiveReleaseID: "release-a",
ActiveReleaseStatus: instances.ValidationValid,
EffectiveGrants: []canvas.GrantProjection{{
PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace,
}},
}}, &fakeCanvasEditReleaseStore{release: instances.Release{
ID: "release-a",
InstanceID: "instance-a",
PackageDigest: "digest-a",
ArtifactPath: "releases/digest-a",
ValidationStatus: instances.ValidationValid,
}}, &fakeCanvasEditArtifactStore{files: map[string][]byte{
"manifest.yaml": []byte("manifest"),
"index.html": []byte("index"),
}}
}
func stringPointer(value string) *string { return &value }
type fakeCanvasEditTaskStore struct {
workspace *models.Workspace
createRequest *taskservice.CreateTaskRequest
deleted []string
}
func (f *fakeCanvasEditTaskStore) AuthorizeWorkspaceAccess(context.Context, string) error { return nil }
func (f *fakeCanvasEditTaskStore) GetWorkspace(context.Context, string) (*models.Workspace, error) {
return f.workspace, nil
}
func (f *fakeCanvasEditTaskStore) CreateTask(_ context.Context, request *taskservice.CreateTaskRequest) (taskservice.CreateTaskResult, error) {
f.createRequest = request
return taskservice.CreateTaskResult{
Task: &models.Task{ID: "task-edit", WorkspaceID: "workspace-a"},
Outcome: taskservice.CreateTaskOutcomeCreated,
}, nil
}
func (f *fakeCanvasEditTaskStore) DeleteTask(_ context.Context, taskID string) error {
f.deleted = append(f.deleted, taskID)
return nil
}
type fakeCanvasEditCanvasStore struct{ canvas *canvas.Canvas }
func (f *fakeCanvasEditCanvasStore) Get(context.Context, string) (*canvas.Canvas, error) {
return f.canvas, nil
}
type fakeCanvasEditReleaseStore struct{ release instances.Release }
func (f *fakeCanvasEditReleaseStore) GetRelease(context.Context, string) (instances.Release, error) {
return f.release, nil
}
type fakeCanvasEditArtifactStore struct{ files map[string][]byte }
func (f *fakeCanvasEditArtifactStore) ReadFiles(webapp.Artifact) (map[string][]byte, error) {
return f.files, nil
}
type fakeCanvasEditLauncher struct {
launchErr error
launchResponse *orchestrator.LaunchSessionResponse
launchRequest *orchestrator.LaunchSessionRequest
prompt string
}
func (f *fakeCanvasEditLauncher) LaunchSession(_ context.Context, request *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) {
f.launchRequest = request
return f.launchResponse, f.launchErr
}
func (f *fakeCanvasEditLauncher) PromptTask(_ context.Context, _, _, prompt, _ string, _ bool, _ []v1.MessageAttachment, _ bool) (*orchestrator.PromptResult, error) {
f.prompt = prompt
return &orchestrator.PromptResult{}, nil
}
type fakeCanvasEditSessionStore struct{ values map[string]interface{} }
func (f *fakeCanvasEditSessionStore) SetSessionMetadataKey(_ context.Context, _, key string, value interface{}) error {
if f.values == nil {
f.values = make(map[string]interface{})
}
f.values[key] = value
return nil
}
type fakeCanvasEditExecutionResolver struct{ client canvasEditAgentCtl }
func (f *fakeCanvasEditExecutionResolver) ResolveAgentCtl(string) (canvasEditAgentCtl, error) {
if f.client == nil {
return nil, errors.New("agentctl unavailable")
}
return f.client, nil
}
type fakeCanvasEditAgentCtl struct {
entries []copyfiles.Entry
err error
}
func (f *fakeCanvasEditAgentCtl) CopyFiles(_ context.Context, _ string, entries []copyfiles.Entry) (canvasCopyFilesResult, error) {
f.entries = append([]copyfiles.Entry(nil), entries...)
if f.err != nil {
return canvasCopyFilesResult{}, f.err
}
return canvasCopyFilesResult{Present: true}, nil
}