Files
kandev/apps/backend/internal/office/runtime/handler_test.go
T
2026-05-16 20:07:57 +01:00

329 lines
9.4 KiB
Go

package runtime
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/agents"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
)
type handlerHarness struct {
router *gin.Engine
token string
comments *handlerCommentWriter
status *recordingTaskStatusUpdater
runEvents *recordingRunEvents
agentSvc *agents.AgentService
repository *sqlite.Repository
}
type handlerCommentWriter struct {
comments []*models.TaskComment
}
func (r *handlerCommentWriter) CreateComment(_ context.Context, comment *models.TaskComment) error {
r.comments = append(r.comments, comment)
return nil
}
type recordingTaskStatusUpdater struct {
updates []TaskStatusUpdate
}
func (r *recordingTaskStatusUpdater) UpdateTaskStatusAsAgent(_ context.Context, update TaskStatusUpdate) error {
r.updates = append(r.updates, update)
return nil
}
type handlerTaskCreator struct{}
func (r *handlerTaskCreator) CreateOfficeSubtaskAsAgent(
_ context.Context,
_ string,
_ string,
_ string,
_ string,
_ string,
) (string, error) {
return "task-created", nil
}
type recordingRunEvent struct {
runID string
eventType string
level string
payload map[string]interface{}
}
type recordingRunEvents struct {
events []recordingRunEvent
}
func (r *recordingRunEvents) AppendRunEvent(
_ context.Context,
runID string,
eventType string,
level string,
payload map[string]interface{},
) {
r.events = append(r.events, recordingRunEvent{
runID: runID,
eventType: eventType,
level: level,
payload: payload,
})
}
func TestRuntimeHandler_PostCommentUsesRuntimeToken(t *testing.T) {
h := newRuntimeHandlerHarness(t, Capabilities{
CanPostComments: true,
}.WithTaskScope("task-1"))
resp := h.request(t, http.MethodPost, "/runtime/comments", map[string]string{
"body": "runtime comment",
})
if resp.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String())
}
if len(h.comments.comments) != 1 {
t.Fatalf("comments = %d, want 1", len(h.comments.comments))
}
comment := h.comments.comments[0]
if comment.TaskID != "task-1" || comment.AuthorID != "agent-1" || comment.AuthorType != "agent" {
t.Fatalf("comment identity = %#v", comment)
}
assertActionRunEvent(t, h.runEvents, "post_comment", "task", "task-1")
}
func TestRuntimeHandler_WriteAndReadMemoryUsesRuntimeNamespace(t *testing.T) {
h := newRuntimeHandlerHarness(t, Capabilities{
CanReadMemory: true,
CanWriteMemory: true,
}.WithTaskScope("task-1"))
path := "/runtime/memory/workspaces/ws-1/memory/agents/agent-1/knowledge/runtime-note"
put := h.request(t, http.MethodPut, path, map[string]string{"content": "remember this"})
if put.Code != http.StatusOK {
t.Fatalf("put status = %d, want %d; body=%s", put.Code, http.StatusOK, put.Body.String())
}
get := h.request(t, http.MethodGet, path, nil)
if get.Code != http.StatusOK {
t.Fatalf("get status = %d, want %d; body=%s", get.Code, http.StatusOK, get.Body.String())
}
var body struct {
Memory models.AgentMemory `json:"memory"`
}
if err := json.Unmarshal(get.Body.Bytes(), &body); err != nil {
t.Fatalf("decode memory: %v", err)
}
if body.Memory.Content != "remember this" || body.Memory.Layer != "knowledge" {
t.Fatalf("unexpected memory: %+v", body.Memory)
}
assertActionRunEvent(t, h.runEvents, "write_memory", "memory",
"/workspaces/ws-1/memory/agents/agent-1/knowledge/runtime-note")
}
func TestRuntimeHandler_CreateAgentBindsSnakeCasePayload(t *testing.T) {
h := newRuntimeHandlerHarness(t, Capabilities{
CanCreateAgents: true,
}.WithTaskScope("task-1"))
resp := h.request(t, http.MethodPost, "/runtime/agents", map[string]interface{}{
"name": "Runtime Worker",
"role": "worker",
"desired_skills": `["runtime-skill"]`,
"executor_preference": `{"type":"local_pc"}`,
"max_concurrent_sessions": 2,
})
if resp.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String())
}
var body struct {
Agent models.AgentInstance `json:"agent"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("decode agent: %v", err)
}
if body.Agent.DesiredSkills != `["runtime-skill"]` ||
body.Agent.ExecutorPreference != `{"type":"local_pc"}` ||
body.Agent.MaxConcurrentSessions != 2 {
t.Fatalf("snake-case fields did not bind: %+v", body.Agent)
}
assertActionRunEvent(t, h.runEvents, "create_agent", "agent", body.Agent.ID)
}
func TestRuntimeHandler_DeniesOutOfScopeStatusUpdateAndLogsRunEvent(t *testing.T) {
h := newRuntimeHandlerHarness(t, Capabilities{
CanUpdateTaskStatus: true,
}.WithTaskScope("task-1"))
resp := h.request(t, http.MethodPost, "/runtime/tasks/task-2/status", map[string]string{
"status": "in_review",
})
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusForbidden, resp.Body.String())
}
if len(h.status.updates) != 0 {
t.Fatalf("status updates = %d, want 0", len(h.status.updates))
}
assertDeniedRunEvent(t, h.runEvents, "update_task_status", "task", "task-2")
}
func TestRuntimeHandler_DeniesMissingCapabilityAndLogsRunEvent(t *testing.T) {
h := newRuntimeHandlerHarness(t, Capabilities{}.WithTaskScope("task-1"))
resp := h.request(t, http.MethodPost, "/runtime/tasks/task-1/status", map[string]string{
"status": "in_review",
})
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusForbidden, resp.Body.String())
}
if len(h.status.updates) != 0 {
t.Fatalf("status updates = %d, want 0", len(h.status.updates))
}
assertDeniedRunEvent(t, h.runEvents, "update_task_status", "task", "task-1")
}
func newRuntimeHandlerHarness(t *testing.T, caps Capabilities) *handlerHarness {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if _, _, err := settingsstore.Provide(db, db, nil); err != nil {
t.Fatalf("settings store init: %v", err)
}
repo, err := sqlite.NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("new repo: %v", err)
}
agentSvc := agents.NewAgentService(repo, logger.Default(), nil)
agentSvc.SetAuth(agents.NewAgentAuth("runtime-handler-test-key"))
agent := &models.AgentInstance{
ID: "agent-1",
WorkspaceID: "ws-1",
Name: "CEO",
Role: models.AgentRoleCEO,
}
if err := repo.CreateAgentInstance(context.Background(), agent); err != nil {
t.Fatalf("create agent: %v", err)
}
capabilityJSON, err := MarshalCapabilities(caps)
if err != nil {
t.Fatalf("marshal capabilities: %v", err)
}
token, err := agentSvc.MintRuntimeJWT("agent-1", "task-1", "ws-1", "run-1", "sess-1", capabilityJSON)
if err != nil {
t.Fatalf("mint runtime token: %v", err)
}
comments := &handlerCommentWriter{}
status := &recordingTaskStatusUpdater{}
runEvents := &recordingRunEvents{}
router := gin.New()
RegisterRoutes(router.Group(""), NewHandler(
agentSvc,
NewActions(ActionDependencies{
Comments: comments,
Tasks: &handlerTaskCreator{},
TaskStatus: status,
Agents: agentSvc,
}),
nil,
runEvents,
))
return &handlerHarness{
router: router,
token: token,
comments: comments,
status: status,
runEvents: runEvents,
agentSvc: agentSvc,
repository: repo,
}
}
func (h *handlerHarness) request(
t *testing.T,
method string,
path string,
payload interface{},
) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+h.token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.router.ServeHTTP(resp, req)
return resp
}
func assertActionRunEvent(
t *testing.T,
runEvents *recordingRunEvents,
action string,
targetType string,
targetID string,
) {
t.Helper()
if len(runEvents.events) != 1 {
t.Fatalf("run events = %d, want 1", len(runEvents.events))
}
event := runEvents.events[0]
if event.runID != "run-1" || event.eventType != "runtime.action" || event.level != "info" {
t.Fatalf("event identity = %#v", event)
}
if event.payload["action"] != action ||
event.payload["target_type"] != targetType ||
event.payload["target_id"] != targetID ||
event.payload["agent_id"] != "agent-1" ||
event.payload["session_id"] != "sess-1" {
t.Fatalf("event payload = %#v", event.payload)
}
}
func assertDeniedRunEvent(
t *testing.T,
runEvents *recordingRunEvents,
action string,
targetType string,
targetID string,
) {
t.Helper()
if len(runEvents.events) != 1 {
t.Fatalf("run events = %d, want 1", len(runEvents.events))
}
event := runEvents.events[0]
if event.runID != "run-1" || event.eventType != "runtime.denied" || event.level != "warn" {
t.Fatalf("event identity = %#v", event)
}
if event.payload["action"] != action ||
event.payload["target_type"] != targetType ||
event.payload["target_id"] != targetID ||
event.payload["agent_id"] != "agent-1" ||
event.payload["session_id"] != "sess-1" {
t.Fatalf("event payload = %#v", event.payload)
}
}