Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions platform/internal/handlers/handlers_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,16 @@ func TestWorkspaceUpdate_ParentID(t *testing.T) {
// #125 guard: handler now verifies the workspace exists before applying
// the UPDATE. Each PATCH test must mock the EXISTS probe first.
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
WithArgs("ws-child").
WithArgs("dddddddd-0001-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET parent_id").
WithArgs("ws-child", "ws-parent").
WithArgs("dddddddd-0001-0000-0000-000000000000", "dddddddd-0002-0000-0000-000000000000").
WillReturnResult(sqlmock.NewResult(0, 1))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-child"}}
body := `{"parent_id":"ws-parent"}`
c.Params = gin.Params{{Key: "id", Value: "dddddddd-0001-0000-0000-000000000000"}}
body := `{"parent_id":"dddddddd-0002-0000-0000-000000000000"}`
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-child", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")

Expand All @@ -154,15 +154,15 @@ func TestWorkspaceUpdate_NameOnly(t *testing.T) {
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())

mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
WithArgs("ws-rename").
WithArgs("dddddddd-0003-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET name").
WithArgs("ws-rename", "New Name").
WithArgs("dddddddd-0003-0000-0000-000000000000", "New Name").
WillReturnResult(sqlmock.NewResult(0, 1))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-rename"}}
c.Params = gin.Params{{Key: "id", Value: "dddddddd-0003-0000-0000-000000000000"}}
body := `{"name":"New Name"}`
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-rename", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
Expand Down Expand Up @@ -604,15 +604,15 @@ func TestCheckAccess_ParentChildAllowed(t *testing.T) {
handler := NewDiscoveryHandler()

mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-parent").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-parent", nil))
WithArgs("dddddddd-0002-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("dddddddd-0002-0000-0000-000000000000", nil))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
WithArgs("ws-kid").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-kid", "ws-parent"))
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-kid", "dddddddd-0002-0000-0000-000000000000"))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"caller_id":"ws-parent","target_id":"ws-kid"}`
body := `{"caller_id":"dddddddd-0002-0000-0000-000000000000","target_id":"ws-kid"}`
c.Request = httptest.NewRequest("POST", "/registry/check-access", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")

Expand Down Expand Up @@ -826,23 +826,23 @@ func TestRestart_ParentPaused(t *testing.T) {

// Workspace lookup succeeds
mock.ExpectQuery("SELECT status, name, tier").
WithArgs("ws-child").
WithArgs("dddddddd-0001-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
AddRow("offline", "Child Agent", 1, "langgraph"))

// isParentPaused: get parent_id
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
WithArgs("ws-child").
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow("ws-parent"))
WithArgs("dddddddd-0001-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow("dddddddd-0002-0000-0000-000000000000"))

// isParentPaused: check parent status
mock.ExpectQuery("SELECT status, name FROM workspaces WHERE id").
WithArgs("ws-parent").
WithArgs("dddddddd-0002-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows([]string{"status", "name"}).AddRow("paused", "Parent Agent"))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-child"}}
c.Params = gin.Params{{Key: "id", Value: "dddddddd-0001-0000-0000-000000000000"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-child/restart", nil)

handler.Restart(c)
Expand Down
162 changes: 154 additions & 8 deletions platform/internal/handlers/handlers_extended_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ import (
// ---------- TestWorkspaceDelete (Extended) ----------

func TestExtended_WorkspaceDelete(t *testing.T) {
const wsDelID = "aaaaaaaa-0000-0000-0000-000000000001"
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")

// Expect children query — no children
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
WithArgs("ws-del").
WithArgs(wsDelID).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))

// #73: batch UPDATE happens BEFORE any container teardown.
Expand All @@ -40,8 +41,8 @@ func TestExtended_WorkspaceDelete(t *testing.T) {

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-del"}}
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-del?confirm=true", nil)
c.Params = gin.Params{{Key: "id", Value: wsDelID}}
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsDelID+"?confirm=true", nil)

handler.Delete(c)

Expand All @@ -68,32 +69,33 @@ func TestExtended_WorkspaceDelete(t *testing.T) {
// ---------- TestWorkspaceUpdate (Extended) ----------

func TestExtended_WorkspaceUpdate(t *testing.T) {
const wsUpdID = "aaaaaaaa-0000-0000-0000-000000000002"
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")

// #120 fix: existence check runs first — workspace must be found before updates proceed.
mock.ExpectQuery("SELECT EXISTS").
WithArgs("ws-upd").
WithArgs(wsUpdID).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))

// Expect name update
mock.ExpectExec("UPDATE workspaces SET name").
WithArgs("ws-upd", "New Name").
WithArgs(wsUpdID, "New Name").
WillReturnResult(sqlmock.NewResult(0, 1))

// Expect canvas position upsert (x and y both provided)
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs("ws-upd", float64(150), float64(250)).
WithArgs(wsUpdID, float64(150), float64(250)).
WillReturnResult(sqlmock.NewResult(0, 1))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-upd"}}
c.Params = gin.Params{{Key: "id", Value: wsUpdID}}

body := `{"name":"New Name","x":150,"y":250}`
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-upd", bytes.NewBufferString(body))
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsUpdID, bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")

handler.Update(c)
Expand Down Expand Up @@ -638,3 +640,147 @@ func TestExtended_ConfigPatch(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}

// ─── #687 UUID validation ──────────────────────────────────────────────────

func TestGet_InvalidUUID_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")

for _, badID := range []string{"not-a-uuid", "ws-123", "../etc/passwd", "123"} {
t.Run(badID, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: badID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+badID, nil)
handler.Get(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Get(%q): want 400, got %d", badID, w.Code)
}
})
}
}

func TestUpdate_InvalidUUID_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")

for _, badID := range []string{"not-a-uuid", "ws-upd", "../../secret"} {
t.Run(badID, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: badID}}
body := `{"name":"x"}`
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+badID, bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Update(%q): want 400, got %d", badID, w.Code)
}
})
}
}

func TestDelete_InvalidUUID_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")

for _, badID := range []string{"not-a-uuid", "ws-del", "foobar"} {
t.Run(badID, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: badID}}
c.Request = httptest.NewRequest("DELETE", "/workspaces/"+badID+"?confirm=true", nil)
handler.Delete(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Delete(%q): want 400, got %d", badID, w.Code)
}
})
}
}

// ─── #685/#688 field validation ───────────────────────────────────────────

func TestValidateWorkspaceFields_Lengths(t *testing.T) {
long256 := string(make([]byte, 256))
long1001 := string(make([]byte, 1001))
long101 := string(make([]byte, 101))

cases := []struct {
label string
name, role, model, runtime string
wantErr bool
}{
{"ok", "ok", "ok role", "gpt-4", "langgraph", false},
{"name_too_long", long256, "", "", "", true},
{"role_too_long", "", long1001, "", "", true},
{"model_too_long", "", "", long101, "", true},
{"runtime_too_long", "", "", "", long101, true},
{"name_newline", "bad\nname", "", "", "", true},
{"role_cr", "", "bad\rrole", "", "", true},
{"model_newline", "", "", "bad\nmodel", "", true},
{"runtime_newline", "", "", "", "bad\nruntime", true},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
err := validateWorkspaceFields(tc.name, tc.role, tc.model, tc.runtime)
if tc.wantErr && err == nil {
t.Errorf("want error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("want nil, got %v", err)
}
})
}
}

func TestCreate_FieldValidation_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")

cases := []struct{ label, body string }{
{"name_newline", `{"name":"bad\nname"}`},
{"role_cr", `{"name":"ok","role":"bad\rrole"}`},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Create(%s): want 400, got %d: %s", tc.label, w.Code, w.Body.String())
}
})
}
}

func TestUpdate_FieldValidation_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")

validID := "bbbbbbbb-0000-0000-0000-000000000001"
cases := []struct{ label, body string }{
{"name_newline", `{"name":"bad\nname"}`},
{"role_cr", `{"name":"ok","role":"bad\rrole"}`},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: validID}}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+validID, bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Update(%s): want 400, got %d: %s", tc.label, w.Code, w.Body.String())
}
})
}
}
6 changes: 3 additions & 3 deletions platform/internal/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1011,16 +1011,16 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
"budget_limit", "monthly_spend",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("ws-task").
WithArgs("dddddddd-0004-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"ws-task", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
nil, 2, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
nil, int64(0),
))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-task"}}
c.Params = gin.Params{{Key: "id", Value: "dddddddd-0004-0000-0000-000000000000"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-task", nil)

handler.Get(c)
Expand Down
Loading
Loading