package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "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) } if clonedItem.RequiresSudo { t.Fatalf("builtin uptime clone should not require sudo: %#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 TestActionGroupItemVariablesCreateAndPatch(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": "Vars", "schedule": map[string]any{ "enabled": false, "kind": "interval", "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 addLocalBody, _ := json.Marshal(map[string]any{ "source": "local", "name": "Count", "kind": "shell", "body": "echo 3", "env": []any{}, "set_variable": "UpdateCount", "run_if": "", }) addLocalReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", bytes.NewReader(addLocalBody), ), cookie, ) addLocalRec := httptest.NewRecorder() router.ServeHTTP(addLocalRec, addLocalReq) if addLocalRec.Code != http.StatusOK { t.Fatalf("add local status=%d body=%s", addLocalRec.Code, addLocalRec.Body.String()) } var withLocal actionGroupResponse if err := json.Unmarshal(addLocalRec.Body.Bytes(), &withLocal); err != nil { t.Fatalf("decode local: %v", err) } if withLocal.Group.Items[0].SetVariable != "UpdateCount" { t.Fatalf("set_variable = %#v", withLocal.Group.Items[0]) } localID := withLocal.Group.Items[0].ID addLibBody, _ := json.Marshal(map[string]any{ "source": "library", "library_action_id": settings.BuiltinActionUptimeID, "set_variable": "$Bad-Name", }) badLibReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", bytes.NewReader(addLibBody), ), cookie, ) badLibRec := httptest.NewRecorder() router.ServeHTTP(badLibRec, badLibReq) if badLibRec.Code != http.StatusBadRequest { t.Fatalf("expected bad variable name, got %d body=%s", badLibRec.Code, badLibRec.Body.String()) } addLibOKBody, _ := json.Marshal(map[string]any{ "source": "library", "library_action_id": settings.BuiltinActionUptimeID, }) addLibReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", bytes.NewReader(addLibOKBody), ), cookie, ) addLibRec := httptest.NewRecorder() router.ServeHTTP(addLibRec, addLibReq) if addLibRec.Code != http.StatusOK { t.Fatalf("add library status=%d body=%s", addLibRec.Code, addLibRec.Body.String()) } var withLib actionGroupResponse _ = json.Unmarshal(addLibRec.Body.Bytes(), &withLib) var libID string for _, item := range withLib.Group.Items { if item.Source == settings.ActionItemSourceLibrary { libID = item.ID break } } if libID == "" { t.Fatal("library item missing") } patchLibBody, _ := json.Marshal(map[string]any{ "run_if": "if $UpdateCount >= 1", "set_variable": "UptimeOut", }) patchLibReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID, bytes.NewReader(patchLibBody), ), cookie, ) patchLibRec := httptest.NewRecorder() router.ServeHTTP(patchLibRec, patchLibReq) if patchLibRec.Code != http.StatusOK { t.Fatalf("patch library vars status=%d body=%s", patchLibRec.Code, patchLibRec.Body.String()) } var patchedLib actionGroupResponse _ = json.Unmarshal(patchLibRec.Body.Bytes(), &patchedLib) var libItem settings.NodeActionItem for _, item := range patchedLib.Group.Items { if item.ID == libID { libItem = item break } } if libItem.RunIf != "if $UpdateCount >= 1" || libItem.SetVariable != "UptimeOut" { t.Fatalf("library vars = %#v", libItem) } badBodyPatch, _ := json.Marshal(map[string]any{ "body": "echo no", }) badBodyReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID, bytes.NewReader(badBodyPatch), ), cookie, ) badBodyRec := httptest.NewRecorder() router.ServeHTTP(badBodyRec, badBodyReq) if badBodyRec.Code != http.StatusBadRequest { t.Fatalf("expected clone-required for body edit, got %d", badBodyRec.Code) } badCondBody, _ := json.Marshal(map[string]any{ "run_if": "not valid", }) badCondReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID, bytes.NewReader(badCondBody), ), cookie, ) badCondRec := httptest.NewRecorder() router.ServeHTTP(badCondRec, badCondReq) if badCondRec.Code != http.StatusBadRequest { t.Fatalf("expected bad run_if, got %d body=%s", badCondRec.Code, badCondRec.Body.String()) } sudoLocalBody, _ := json.Marshal(map[string]any{ "requires_sudo": true, }) sudoLocalReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID, bytes.NewReader(sudoLocalBody), ), cookie, ) sudoLocalRec := httptest.NewRecorder() router.ServeHTTP(sudoLocalRec, sudoLocalReq) if sudoLocalRec.Code != http.StatusOK { t.Fatalf("patch requires_sudo status=%d body=%s", sudoLocalRec.Code, sudoLocalRec.Body.String()) } var withSudo actionGroupResponse _ = json.Unmarshal(sudoLocalRec.Body.Bytes(), &withSudo) foundSudo := false for _, item := range withSudo.Group.Items { if item.ID == localID { foundSudo = true if !item.RequiresSudo { t.Fatalf("expected requires_sudo on local item, got %#v", item) } } } if !foundSudo { t.Fatal("local item missing after sudo patch") } } 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()) } } func TestActionGroupRunRequiresActionsExecute(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) } // Create a group while still admin, then strip execute (+ jobs.run so // migration does not re-grant actions.execute). router := NewRouter(configDir) createBody, _ := json.Marshal(map[string]any{ "name": "RunAuth", "schedule": map[string]any{ "enabled": false, "kind": "interval", "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 _ = json.Unmarshal(createRec.Body.Bytes(), &created) payload, err := settings.LoadSettings(configDir) if err != nil { t.Fatalf("LoadSettings: %v", err) } payload.Groups[0].Permissions = []string{"nodes.read", "actions.create", "actions.update"} if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) } runReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+created.Group.ID+"/run", nil, ), cookie, ) runRec := httptest.NewRecorder() router.ServeHTTP(runRec, runReq) if runRec.Code != http.StatusForbidden { t.Fatalf("expected run forbidden without actions.execute, got %d body=%s", runRec.Code, runRec.Body.String()) } if !strings.Contains(runRec.Body.String(), "actions.execute") { t.Fatalf("expected actions.execute in error, got %s", runRec.Body.String()) } } func TestActionGroupCloneCopiesRequiresSudo(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) createActionBody, _ := json.Marshal(map[string]any{ "name": "Restart svc", "description": "", "kind": "shell", "body": "/usr/bin/systemctl restart myservice", "env": []any{}, "requires_sudo": true, }) createActionReq := withSession( httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createActionBody)), cookie, ) createActionRec := httptest.NewRecorder() router.ServeHTTP(createActionRec, createActionReq) if createActionRec.Code != http.StatusOK { t.Fatalf("create action status=%d body=%s", createActionRec.Code, createActionRec.Body.String()) } var actionsPayload actionsResponseTest if err := json.Unmarshal(createActionRec.Body.Bytes(), &actionsPayload); err != nil { t.Fatalf("decode actions: %v", err) } var libraryID string for _, action := range actionsPayload.Actions { if action.Name == "Restart svc" { libraryID = action.ID if !action.RequiresSudo { t.Fatalf("library action missing requires_sudo: %#v", action) } break } } if libraryID == "" { t.Fatal("library action not found") } createGroupBody, _ := json.Marshal(map[string]any{ "name": "SudoGrp", "schedule": map[string]any{ "enabled": false, "kind": "interval", "times": []any{}, }, }) createGroupReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups", bytes.NewReader(createGroupBody), ), cookie, ) createGroupRec := httptest.NewRecorder() router.ServeHTTP(createGroupRec, createGroupReq) if createGroupRec.Code != http.StatusOK { t.Fatalf("create group status=%d", createGroupRec.Code) } var createdGroup actionGroupResponse _ = json.Unmarshal(createGroupRec.Body.Bytes(), &createdGroup) addItemBody, _ := json.Marshal(map[string]any{ "source": "library", "library_action_id": libraryID, }) addItemReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items", bytes.NewReader(addItemBody), ), cookie, ) addItemRec := httptest.NewRecorder() router.ServeHTTP(addItemRec, addItemReq) if addItemRec.Code != http.StatusOK { t.Fatalf("add item status=%d body=%s", addItemRec.Code, addItemRec.Body.String()) } var withItem actionGroupResponse _ = json.Unmarshal(addItemRec.Body.Bytes(), &withItem) itemID := withItem.Group.Items[0].ID cloneReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/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) clonedItem := cloned.Group.Items[0] if clonedItem.Source != settings.ActionItemSourceLocal || !clonedItem.RequiresSudo { t.Fatalf("cloned item should be local with requires_sudo: %#v", clonedItem) } if clonedItem.Body != "/usr/bin/systemctl restart myservice" { t.Fatalf("cloned body = %q", clonedItem.Body) } }