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"}] }`) 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) } patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`) 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 !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) } }