package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) func seedNodeForActions(t *testing.T, configDir string) settings.Node { t.Helper() node := settings.Node{ ID: "11111111-1111-4111-8111-111111111111", Kind: settings.NodeKindContainer, Name: "box-1", HostIP: "127.0.0.1", Username: "root", GroupName: settings.AdministratorsGroupName, PublicKey: "ssh-ed25519 AAAA", KeyAlgo: "ed25519", CreatedAt: time.Now().UTC(), } if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil { t.Fatalf("SaveNodes: %v", err) } return node } func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) node := seedNodeForActions(t, configDir) if _, err := settings.LoadActionsOrSeed(configDir); err != nil { t.Fatalf("LoadActionsOrSeed: %v", err) } router := NewRouter(configDir) createBody, _ := json.Marshal(map[string]any{ "name": "Nightly", "schedule": map[string]any{ "enabled": false, "kind": "interval", "every_seconds": 3600, "times": []any{}, }, }) createReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups", bytes.NewReader(createBody), ), cookie, ) createRec := httptest.NewRecorder() router.ServeHTTP(createRec, createReq) if createRec.Code != http.StatusOK { t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String()) } var created actionGroupResponse if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { t.Fatalf("decode create: %v", err) } groupID := created.Group.ID addLibBody, _ := json.Marshal(map[string]any{ "source": "library", "library_action_id": settings.BuiltinActionUptimeID, }) addReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", bytes.NewReader(addLibBody), ), cookie, ) addRec := httptest.NewRecorder() router.ServeHTTP(addRec, addReq) if addRec.Code != http.StatusOK { t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String()) } var withItem actionGroupResponse if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil { t.Fatalf("decode add: %v", err) } if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary { t.Fatalf("items = %#v", withItem.Group.Items) } itemID := withItem.Group.Items[0].ID addCustomBody, _ := json.Marshal(map[string]any{ "source": "local", "name": "Echo", "kind": "shell", "body": "echo hi", "env": []any{}, }) addCustomReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", bytes.NewReader(addCustomBody), ), cookie, ) addCustomRec := httptest.NewRecorder() router.ServeHTTP(addCustomRec, addCustomReq) if addCustomRec.Code != http.StatusOK { t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String()) } var withTwo actionGroupResponse _ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo) if len(withTwo.Group.Items) != 2 { t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items)) } customID := withTwo.Group.Items[1].ID orderBody, _ := json.Marshal(map[string]any{ "item_ids": []string{customID, itemID}, }) orderReq := withSession( httptest.NewRequest( http.MethodPut, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order", bytes.NewReader(orderBody), ), cookie, ) orderRec := httptest.NewRecorder() router.ServeHTTP(orderRec, orderReq) if orderRec.Code != http.StatusOK { t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String()) } var reordered actionGroupResponse _ = json.Unmarshal(orderRec.Body.Bytes(), &reordered) if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID { t.Fatalf("order = %#v", reordered.Group.Items) } cloneReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone", nil, ), cookie, ) cloneRec := httptest.NewRecorder() router.ServeHTTP(cloneRec, cloneReq) if cloneRec.Code != http.StatusOK { t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String()) } var cloned actionGroupResponse _ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned) var clonedItem settings.NodeActionItem for _, item := range cloned.Group.Items { if item.ID == itemID { clonedItem = item break } } if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" { t.Fatalf("cloned item = %#v", clonedItem) } // Forbidden without actions.create for a limited user session is covered below via groups. listReq := withSession( httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil), cookie, ) listRec := httptest.NewRecorder() router.ServeHTTP(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("list status=%d", listRec.Code) } logsReq := withSession( httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil), cookie, ) logsRec := httptest.NewRecorder() router.ServeHTTP(logsRec, logsReq) if logsRec.Code != http.StatusOK { t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String()) } } func TestActionGroupsForbiddenWithoutPermission(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) node := seedNodeForActions(t, configDir) // Downgrade Administrators permissions to nodes.read only. payload, err := settings.LoadSettings(configDir) if err != nil { t.Fatalf("LoadSettings: %v", err) } payload.Groups[0].Permissions = []string{"nodes.read"} if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) } router := NewRouter(configDir) body, _ := json.Marshal(map[string]any{"name": "Nope"}) req := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups", bytes.NewReader(body), ), cookie, ) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String()) } }