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.
233 lines
7.6 KiB
Go
233 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
|
)
|
|
|
|
type actionsResponseTest struct {
|
|
Actions []settings.Action `json:"actions"`
|
|
}
|
|
|
|
func TestActionsGetSeedsBuiltins(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
var payload actionsResponseTest
|
|
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(payload.Actions) != 3 {
|
|
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
|
|
}
|
|
for _, action := range payload.Actions {
|
|
if !action.Builtin {
|
|
t.Fatalf("expected builtin, got %#v", action)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestActionsCreatePatchDelete(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
createBody := []byte(`{
|
|
"name": "Echo host",
|
|
"description": "Print the target IP",
|
|
"kind": "shell",
|
|
"body": "echo {{node.ip}}",
|
|
"env": [{"name": "APP_HOME", "value": "/opt/app"}],
|
|
"requires_sudo": true
|
|
}`)
|
|
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)
|
|
}
|
|
if len(created.Actions) != 4 {
|
|
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
|
|
}
|
|
custom := created.Actions[3]
|
|
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
|
t.Fatalf("custom = %#v", custom)
|
|
}
|
|
if !custom.RequiresSudo {
|
|
t.Fatalf("expected requires_sudo true, got %#v", custom)
|
|
}
|
|
|
|
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}","requires_sudo":false}`)
|
|
patchReq := withSession(
|
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
|
cookie,
|
|
)
|
|
patchRec := httptest.NewRecorder()
|
|
router.ServeHTTP(patchRec, patchReq)
|
|
if patchRec.Code != http.StatusOK {
|
|
t.Fatalf("patch status %d body=%s", patchRec.Code, patchRec.Body.String())
|
|
}
|
|
|
|
var patched actionsResponseTest
|
|
if err := json.NewDecoder(patchRec.Body).Decode(&patched); err != nil {
|
|
t.Fatalf("decode patch: %v", err)
|
|
}
|
|
found := false
|
|
for _, action := range patched.Actions {
|
|
if action.ID == custom.ID {
|
|
found = true
|
|
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
|
t.Fatalf("patched = %#v", action)
|
|
}
|
|
if action.RequiresSudo {
|
|
t.Fatalf("expected requires_sudo false after patch, got %#v", action)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("patched action missing")
|
|
}
|
|
|
|
deleteReq := withSession(
|
|
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+custom.ID, nil),
|
|
cookie,
|
|
)
|
|
deleteRec := httptest.NewRecorder()
|
|
router.ServeHTTP(deleteRec, deleteReq)
|
|
if deleteRec.Code != http.StatusOK {
|
|
t.Fatalf("delete 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)
|
|
}
|
|
if len(deleted.Actions) != 3 {
|
|
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
|
|
}
|
|
}
|
|
|
|
func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
|
getRec := httptest.NewRecorder()
|
|
router.ServeHTTP(getRec, getReq)
|
|
var listed actionsResponseTest
|
|
if err := json.NewDecoder(getRec.Body).Decode(&listed); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
builtinID := listed.Actions[0].ID
|
|
|
|
patchBody := []byte(`{"name":"Nope"}`)
|
|
patchReq := withSession(
|
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(patchBody)),
|
|
cookie,
|
|
)
|
|
patchRec := httptest.NewRecorder()
|
|
router.ServeHTTP(patchRec, patchReq)
|
|
if patchRec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
|
|
}
|
|
|
|
deleteReq := withSession(
|
|
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
|
|
cookie,
|
|
)
|
|
deleteRec := httptest.NewRecorder()
|
|
router.ServeHTTP(deleteRec, deleteReq)
|
|
if deleteRec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
|
|
}
|
|
|
|
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
|
|
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
|
|
badRec := httptest.NewRecorder()
|
|
router.ServeHTTP(badRec, badReq)
|
|
if badRec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
|
|
}
|
|
|
|
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
|
|
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
|
|
kindRec := httptest.NewRecorder()
|
|
router.ServeHTTP(kindRec, kindReq)
|
|
if kindRec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
|
|
}
|
|
}
|
|
|
|
func TestActionsMutationsRequirePermissions(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
// Strip action permissions from Administrators.
|
|
settingsPayload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadSettings: %v", err)
|
|
}
|
|
for index := range settingsPayload.Groups {
|
|
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
|
|
continue
|
|
}
|
|
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
|
|
for _, permission := range settingsPayload.Groups[index].Permissions {
|
|
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
|
|
continue
|
|
}
|
|
filtered = append(filtered, permission)
|
|
}
|
|
settingsPayload.Groups[index].Permissions = filtered
|
|
}
|
|
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
|
|
t.Fatalf("SaveSettings: %v", err)
|
|
}
|
|
|
|
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
|
|
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
|
createRec := httptest.NewRecorder()
|
|
router.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
|
|
}
|
|
|
|
patchReq := withSession(
|
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
|
|
cookie,
|
|
)
|
|
patchRec := httptest.NewRecorder()
|
|
router.ServeHTTP(patchRec, patchReq)
|
|
if patchRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected patch 403, got %d", patchRec.Code)
|
|
}
|
|
|
|
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
|
|
deleteRec := httptest.NewRecorder()
|
|
router.ServeHTTP(deleteRec, deleteReq)
|
|
if deleteRec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
|
|
}
|
|
}
|