Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -168,6 +169,9 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
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(
|
||||
@@ -191,6 +195,218 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -222,3 +438,178 @@ func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user