Require confirmation and block deleting library actions still referenced by node groups.

This commit is contained in:
2026-07-20 21:00:24 +02:00
parent d188ca41b8
commit be255341e5
5 changed files with 217 additions and 0 deletions
@@ -257,3 +257,147 @@ func TestActionsMutationsRequirePermissions(t *testing.T) {
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
}
}
func TestActionsDeleteBlockedWhenLibraryReferenced(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{"name":"Echo host","kind":"shell","body":"echo hi"}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
}
var created actionsResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
customID := ""
for _, action := range created.Actions {
if !action.Builtin {
customID = action.ID
break
}
}
if customID == "" {
t.Fatal("custom action missing")
}
groupBody, _ := json.Marshal(map[string]any{
"name": "Nightly",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"every_seconds": 3600,
"times": []any{},
},
})
groupReq := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups", bytes.NewReader(groupBody)),
cookie,
)
groupRec := httptest.NewRecorder()
router.ServeHTTP(groupRec, groupReq)
if groupRec.Code != http.StatusOK {
t.Fatalf("create group status=%d body=%s", groupRec.Code, groupRec.Body.String())
}
var groupResp actionGroupResponse
if err := json.Unmarshal(groupRec.Body.Bytes(), &groupResp); err != nil {
t.Fatalf("decode group: %v", err)
}
groupID := groupResp.Group.ID
addBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": customID,
})
addReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addBody),
),
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)
}
itemID := withItem.Group.Items[0].ID
blockedReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+customID, nil),
cookie,
)
blockedRec := httptest.NewRecorder()
router.ServeHTTP(blockedRec, blockedReq)
if blockedRec.Code != http.StatusConflict {
t.Fatalf("expected delete conflict %d, got %d body=%s", http.StatusConflict, blockedRec.Code, blockedRec.Body.String())
}
var blockedErr apiErrorResponse
if err := json.NewDecoder(blockedRec.Body).Decode(&blockedErr); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if blockedErr.Error != errActionInUse.Error() {
t.Fatalf("conflict error = %q, want %q", blockedErr.Error, errActionInUse.Error())
}
listReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
var listed actionsResponseTest
if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list: %v", err)
}
stillPresent := false
for _, action := range listed.Actions {
if action.ID == customID {
stillPresent = true
break
}
}
if !stillPresent {
t.Fatal("action should remain after blocked delete")
}
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())
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+customID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete after clone status %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
var deleted actionsResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
t.Fatalf("decode delete: %v", err)
}
for _, action := range deleted.Actions {
if action.ID == customID {
t.Fatal("custom action should be gone after delete")
}
}
}