package plugins import ( "bytes" "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/manifest" "github.com/kandev/kandev/internal/plugins/state" "github.com/kandev/kandev/internal/plugins/webapp" taskmodels "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) { svc := &Service{} binding := webapp.CapabilityBinding{ UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", ScopeKind: "task", DataScopeKind: "workspace", WorkspaceID: "workspace-1", TaskID: "task-1", Permissions: []string{"api_read:tasks", "state"}, } request := httptest.NewRequest(http.MethodGet, "/", nil) response := httptest.NewRecorder() svc.handleWebAppProtocol(response, request, "ignored", binding, "v1/context") if response.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } var body map[string]any if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } if _, ok := body["user_id"]; ok { t.Fatal("context exposed user_id") } if _, ok := body["token"]; ok { t.Fatal("context exposed token") } if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) || body["scope_kind"] != "task" || body["data_scope_kind"] != "workspace" { t.Fatalf("context = %#v", body) } } func TestWebAppProtocolCookieDoesNotExpandPermissions(t *testing.T) { svc := &Service{} binding := webapp.CapabilityBinding{ UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", Permissions: nil, } request := httptest.NewRequest(http.MethodGet, "/", nil) request.AddCookie(&http.Cookie{Name: "kandev_session", Value: "valid-session"}) response := httptest.NewRecorder() svc.handleWebAppProtocol(response, request, "ignored", binding, "v1/state") if response.Code != http.StatusForbidden || !containsBody(response, "plugin_permission_denied") { t.Fatalf("state request = %d %s, want the cookie to leave capability permissions unchanged", response.Code, response.Body.String()) } } func TestWebAppProtocolActionChecksCapability(t *testing.T) { baseBinding := webapp.CapabilityBinding{ UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", Permissions: nil, } denied := httptest.NewRecorder() svc := &Service{} svc.handleWebAppProtocol( denied, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)), "", baseBinding, "v1/actions/not-declared", ) if denied.Code != http.StatusForbidden || !containsBody(denied, "plugin_permission_denied") { t.Fatalf("undeclared action = %d %s, want permission denial", denied.Code, denied.Body.String()) } allowed := httptest.NewRecorder() baseBinding.Permissions = []string{"action:declared"} svc.handleWebAppProtocol( allowed, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)), "", baseBinding, "v1/actions/declared", ) if allowed.Code != http.StatusNotImplemented || !containsBody(allowed, "plugin_action_unavailable") { t.Fatalf("declared action = %d %s, want unavailable action response", allowed.Code, allowed.Body.String()) } } func TestWebAppProtocolStateUsesRevisionPreconditions(t *testing.T) { store := newWebAppProtocolStateStore(t) svc := &Service{instanceState: store} binding := webapp.CapabilityBinding{ UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", ScopeKind: "task", Permissions: []string{"state"}, } missing := httptest.NewRecorder() svc.handleWebAppProtocol(missing, httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(`{"enabled":true}`)), "", binding, "v1/state/preferences") if missing.Code != http.StatusPreconditionRequired || !containsBody(missing, "plugin_state_precondition_required") { t.Fatalf("missing precondition = %d %s", missing.Code, missing.Body.String()) } first := httptest.NewRecorder() initial := httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(`{"enabled":true}`)) initial.Header.Set("If-Match", `"0"`) svc.handleWebAppProtocol(first, initial, "", binding, "v1/state/preferences") if first.Code != http.StatusOK || !containsBody(first, `"revision":1`) { t.Fatalf("initial state write = %d %s", first.Code, first.Body.String()) } stale := httptest.NewRecorder() staleRequest := httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(`{"enabled":false}`)) staleRequest.Header.Set("If-Match", `"0"`) svc.handleWebAppProtocol(stale, staleRequest, "", binding, "v1/state/preferences") if stale.Code != http.StatusConflict || !containsBody(stale, `"current_revision":1`) || containsBody(stale, "enabled") { t.Fatalf("stale state write = %d %s", stale.Code, stale.Body.String()) } read := httptest.NewRecorder() svc.handleWebAppProtocol(read, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/state/preferences") if read.Code != http.StatusOK || !containsBody(read, `"enabled":true`) { t.Fatalf("state read = %d %s", read.Code, read.Body.String()) } } func TestValidateWebAppPermissionsRequiresCurrentScopedGrants(t *testing.T) { connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "protocol-grants.db")) if err != nil { t.Fatalf("open sqlite: %v", err) } connection.SetMaxOpenConns(1) pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3")) t.Cleanup(func() { _ = pool.Close() }) store, err := instances.NewStore(pool) if err != nil { t.Fatalf("new instance store: %v", err) } if err := store.Create(context.Background(), instances.Instance{ ID: "instance-1", PluginID: "plugin-1", SourceKind: instances.SourceLocalCanvas, ScopeKind: instances.ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: instances.StatusActive, CreatedAt: time.Now(), UpdatedAt: time.Now(), }); err != nil { t.Fatalf("create instance: %v", err) } svc := &Service{} svc.SetWebAppStorage(store, nil) binding := webapp.CapabilityBinding{ InstanceID: "instance-1", ScopeKind: instances.ScopeTask, Permissions: []string{"api_read:tasks", "state"}, } raw := json.RawMessage(`{"capabilities":{"api_read":["tasks"],"state":true}}`) if err := svc.validateWebAppPermissions(context.Background(), binding, raw); err == nil { t.Fatal("permission validation succeeded without grants") } for _, grant := range []instances.Grant{ {InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeTask, ApprovedBy: "user-1"}, {InstanceID: "instance-1", PermissionKind: "state", ScopeCeiling: instances.ScopeTask, ApprovedBy: "user-1"}, } { if err := store.AddGrant(context.Background(), grant); err != nil { t.Fatalf("add grant %s: %v", grant.PermissionKind, err) } } if err := svc.validateWebAppPermissions(context.Background(), binding, raw); err != nil { t.Fatalf("permission validation with grants: %v", err) } } func TestWebAppProtocolTaskWorkflowAndMessageRoutesUseHostAdapters(t *testing.T) { d := newTestDataHost(manifest.Capabilities{ APIRead: []string{"tasks", "workflows"}, APIWrite: []string{"tasks", "messages"}, }) task := &taskmodels.Task{ ID: "task-1", WorkspaceID: "workspace-1", WorkflowID: "workflow-1", WorkflowStepID: "step-1", Title: "Canvas task", State: v1.TaskStateTODO, } d.tasks.workspaces = []*taskmodels.Workspace{{ID: "workspace-1"}} d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"workspace-1": {task}} d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task} d.workflows.workflows = map[string][]*taskmodels.Workflow{"workspace-1": {{ID: "workflow-1", WorkspaceID: "workspace-1", Name: "Main"}}} d.steps.steps = map[string][]*wfmodels.WorkflowStep{"workflow-1": {{ID: "step-1", WorkflowID: "workflow-1", Name: "Doing", Position: 1}}} d.taskWriter.moveResult = &TaskMoveResult{ Task: &taskmodels.Task{ID: "task-1", WorkspaceID: "workspace-1", WorkflowID: "workflow-1", WorkflowStepID: "step-2", Title: "Moved"}, Transitioned: true, FromStepID: "step-1", } d.messenger.result = PluginMessageResult{SessionID: "session-1", Status: "queued"} svc := &Service{ taskData: d.tasks, workflows: d.workflows, workflowSteps: d.steps, taskWriter: d.taskWriter, messenger: d.messenger, taskStarter: d.starter, } binding := webapp.CapabilityBinding{ UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", ReleaseID: "release-1", WebAppKey: "board", Placement: "workspace-canvas", ScopeKind: instances.ScopeWorkspace, WorkspaceID: "workspace-1", Permissions: []string{"api_read:tasks", "api_read:workflows", "api_write:tasks", "api_write:messages"}, } list := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/?limit=10", nil) svc.handleWebAppProtocol(list, request, "", binding, "v1/data/tasks") if list.Code != http.StatusOK || !containsBody(list, `"workflow_step_id":"step-1"`) { t.Fatalf("task list = %d %s", list.Code, list.Body.String()) } steps := httptest.NewRecorder() svc.handleWebAppProtocol(steps, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/workflows/workflow-1/steps") if steps.Code != http.StatusOK || !containsBody(steps, `"id":"step-1"`) { t.Fatalf("workflow steps = %d %s", steps.Code, steps.Body.String()) } patch := httptest.NewRecorder() patchRequest := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"workflow_step_id":"step-2"}`)) svc.handleWebAppProtocol(patch, patchRequest, "", binding, "v1/data/tasks/task-1") if patch.Code != http.StatusOK || d.taskWriter.moveCalls != 1 || d.taskWriter.lastMove.TaskID != "task-1" || d.taskWriter.lastMove.WorkflowStepID != "step-2" { t.Fatalf("task patch = %d %s, move input = %+v", patch.Code, patch.Body.String(), d.taskWriter.lastMove) } if d.taskWriter.updateCalls != 0 { t.Fatalf("workflow-step patch used UpdateTask %d times", d.taskWriter.updateCalls) } message := httptest.NewRecorder() messageRequest := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"text":"continue","session_id":"session-1"}`)) svc.handleWebAppProtocol(message, messageRequest, "", binding, "v1/data/tasks/task-1/messages") if message.Code != http.StatusAccepted || d.messenger.lastText != "continue" || d.messenger.lastSource != "plugin:plugin-1" { t.Fatalf("message send = %d %s, messenger = %+v", message.Code, message.Body.String(), d.messenger) } } func newWebAppProtocolStateStore(t *testing.T) *state.InstanceStore { t.Helper() connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "protocol-state.db")) if err != nil { t.Fatalf("open sqlite: %v", err) } connection.SetMaxOpenConns(1) pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3")) t.Cleanup(func() { _ = pool.Close() }) store, err := state.NewInstanceStore(pool) if err != nil { t.Fatalf("new instance state store: %v", err) } return store } func containsBody(response *httptest.ResponseRecorder, value string) bool { return bytes.Contains(response.Body.Bytes(), []byte(value)) }