diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 000000000..f904dd452 --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,16 @@ +filename: "{{.InterfaceName}}.go" +dir: tests/mocks +outpkg: mocks + +# Fix deprecation warnings: +issue-845-fix: True +resolve-type-alias: False + +packages: + github.com/getAlby/hub/service: + interfaces: + Service: + + github.com/getAlby/hub/lnclient: + interfaces: + LNClient: diff --git a/README.md b/README.md index a1af8712d..383637759 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ _If you get a blank screen, try running in your normal terminal (outside of vsco $ go test ./... -run TestHandleGetInfoEvent +#### Mocking + +We use [testify/mock](https://github.com/stretchr/testify) to facilitate mocking in tests. Instead of writing mocks manually, we generate them using [vektra/mockery](https://github.com/vektra/mockery). To regenerate them, [install mockery](https://vektra.github.io/mockery/latest/installation) and run it in the project's root directory: + + $ mockery + +Mockery loads its configuration from the .mockery.yaml file in the root directory of this project. To add mocks for new interfaces, add them to the configuration file and run mockery. + ### Profiling The application supports both the Go pprof library and the DataDog profiler. diff --git a/api/api.go b/api/api.go index c81d0cb93..2ef4b30fd 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" "io" "net/http" @@ -1068,6 +1069,93 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) { return &HealthResponse{Alarms: alarms}, nil } +func (api *api) GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, errors.New("LNClient not started") + } + + allCommandDefs := lnClient.GetCustomNodeCommandDefinitions() + commandDefs := make([]CustomNodeCommandDef, 0, len(allCommandDefs)) + for _, commandDef := range allCommandDefs { + argDefs := make([]CustomNodeCommandArgDef, 0, len(commandDef.Args)) + for _, argDef := range commandDef.Args { + argDefs = append(argDefs, CustomNodeCommandArgDef{ + Name: argDef.Name, + Description: argDef.Description, + }) + } + commandDefs = append(commandDefs, CustomNodeCommandDef{ + Name: commandDef.Name, + Description: commandDef.Description, + Args: argDefs, + }) + } + + return &CustomNodeCommandsResponse{Commands: commandDefs}, nil +} + +func (api *api) ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) { + lnClient := api.svc.GetLNClient() + if lnClient == nil { + return nil, errors.New("LNClient not started") + } + + // Split command line into arguments. Command name must be the first argument. + parsedArgs, err := utils.ParseCommandLine(command) + if err != nil { + return nil, fmt.Errorf("failed to parse node command: %w", err) + } else if len(parsedArgs) == 0 { + return nil, errors.New("no command provided") + } + + // Look up the requested command definition. + allCommandDefs := lnClient.GetCustomNodeCommandDefinitions() + commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.CustomNodeCommandDef) bool { + return def.Name == parsedArgs[0] + }) + if commandDefIdx < 0 { + return nil, fmt.Errorf("unknown command: %q", parsedArgs[0]) + } + + // Build flag set. + commandDef := allCommandDefs[commandDefIdx] + flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError) + for _, argDef := range commandDef.Args { + flagSet.String(argDef.Name, "", argDef.Description) + } + + if err = flagSet.Parse(parsedArgs[1:]); err != nil { + return nil, fmt.Errorf("failed to parse command arguments: %w", err) + } + + // Collect flags that have been set. + argValues := make(map[string]string) + flagSet.Visit(func(f *flag.Flag) { + argValues[f.Name] = f.Value.String() + }) + + reqArgs := make([]lnclient.CustomNodeCommandArg, 0, len(argValues)) + for _, argDef := range commandDef.Args { + if argValue, ok := argValues[argDef.Name]; ok { + reqArgs = append(reqArgs, lnclient.CustomNodeCommandArg{ + Name: argDef.Name, + Value: argValue, + }) + } + } + + nodeResp, err := lnClient.ExecuteCustomNodeCommand(ctx, &lnclient.CustomNodeCommandRequest{ + Name: commandDef.Name, + Args: reqArgs, + }) + if err != nil { + return nil, fmt.Errorf("node failed to execute custom command: %w", err) + } + + return nodeResp.Response, nil +} + func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) { var expiresAt *time.Time if expiresAtString != "" { diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 000000000..0b876706e --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,228 @@ +package api + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/getAlby/hub/lnclient" + "github.com/getAlby/hub/service" + "github.com/getAlby/hub/tests/mocks" +) + +func TestGetCustomNodeCommandDefinitions(t *testing.T) { + lnClient := mocks.NewMockLNClient(t) + svc := mocks.NewMockService(t) + + mockLNCommandDefs := []lnclient.CustomNodeCommandDef{ + { + Name: "no_args", + Description: "command without args", + Args: nil, + }, + { + Name: "with_args", + Description: "command with args", + Args: []lnclient.CustomNodeCommandArgDef{ + {Name: "arg1", Description: "first argument"}, + {Name: "arg2", Description: "second argument"}, + }, + }, + } + + expectedCommands := []CustomNodeCommandDef{ + { + Name: "no_args", + Description: "command without args", + Args: []CustomNodeCommandArgDef{}, + }, + { + Name: "with_args", + Description: "command with args", + Args: []CustomNodeCommandArgDef{ + {Name: "arg1", Description: "first argument"}, + {Name: "arg2", Description: "second argument"}, + }, + }, + } + + lnClient.On("GetCustomNodeCommandDefinitions").Return(mockLNCommandDefs) + svc.On("GetLNClient").Return(lnClient) + + theAPI := instantiateAPIWithService(svc) + + commands, err := theAPI.GetCustomNodeCommands() + require.NoError(t, err) + require.NotNil(t, commands) + require.ElementsMatch(t, expectedCommands, commands.Commands) +} + +func TestExecuteCustomNodeCommand(t *testing.T) { + type testCase struct { + name string + apiCommandLine string + lnSupportedCommands []lnclient.CustomNodeCommandDef + lnExpectedCommandReq *lnclient.CustomNodeCommandRequest + lnResponse *lnclient.CustomNodeCommandResponse + lnError error + apiExpectedResponse interface{} + apiExpectedErr string + } + + // Successful execution of a command without args. + testCaseOkNoArgs := testCase{ + name: "command without args", + apiCommandLine: "test_command", + lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}}, + lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{}}, + lnResponse: &lnclient.CustomNodeCommandResponse{Response: "ok"}, + lnError: nil, + apiExpectedResponse: "ok", + apiExpectedErr: "", + } + + // Successful execution of a command with args. The command line contains + // different arg value styles: with '=' and with space. + testCaseOkWithArgs := testCase{ + name: "command with args", + apiCommandLine: "test_command --arg1=foo --arg2 bar", + lnSupportedCommands: []lnclient.CustomNodeCommandDef{ + { + Name: "test_command", + Args: []lnclient.CustomNodeCommandArgDef{ + {Name: "arg1", Description: "argument one"}, + {Name: "arg2", Description: "argument two"}, + }, + }, + }, + lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{ + {Name: "arg1", Value: "foo"}, + {Name: "arg2", Value: "bar"}, + }}, + lnResponse: &lnclient.CustomNodeCommandResponse{Response: "ok"}, + lnError: nil, + apiExpectedResponse: "ok", + apiExpectedErr: "", + } + + // Successful execution of a command with a possible but unset arg. + testCaseOkWithUnsetArg := testCase{ + name: "command with unset arg", + apiCommandLine: "test_command", + lnSupportedCommands: []lnclient.CustomNodeCommandDef{ + {Name: "test_command", Args: []lnclient.CustomNodeCommandArgDef{{Name: "arg1", Description: "argument one"}}}, + }, + lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{}}, + lnResponse: &lnclient.CustomNodeCommandResponse{Response: "ok"}, + lnError: nil, + apiExpectedResponse: "ok", + apiExpectedErr: "", + } + + // Error: command line is empty. + testCaseErrEmptyCommand := testCase{ + name: "empty command", + apiCommandLine: "", + lnSupportedCommands: nil, + lnExpectedCommandReq: nil, + lnResponse: nil, + lnError: nil, + apiExpectedResponse: nil, + apiExpectedErr: "no command provided", + } + + // Error: command line is malformed, i.e. non-parseable. + testCaseErrMalformedCommand := testCase{ + name: "command with unclosed quote", + apiCommandLine: "test_command\"", + lnSupportedCommands: nil, + lnExpectedCommandReq: nil, + lnResponse: nil, + lnError: nil, + apiExpectedResponse: nil, + apiExpectedErr: "failed to parse node command", + } + + // Error: node does not support this command. + testCaseErrUnknownCommand := testCase{ + name: "unknown command", + apiCommandLine: "test_command_unknown", + lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}}, + lnExpectedCommandReq: nil, + lnResponse: nil, + lnError: nil, + apiExpectedResponse: nil, + apiExpectedErr: "unknown command", + } + + // Error: unsupported command argument. + testCaseErrUnknownArg := testCase{ + name: "unknown argument", + apiCommandLine: "test_command --unknown=fail", + lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}}, + lnExpectedCommandReq: nil, + lnResponse: nil, + lnError: nil, + apiExpectedResponse: nil, + apiExpectedErr: "flag provided but not defined: -unknown", + } + + // Error: the command is valid but the node fails to execute it. + testCaseErrNodeFailed := testCase{ + name: "node failed to execute command", + apiCommandLine: "test_command", + lnSupportedCommands: []lnclient.CustomNodeCommandDef{{Name: "test_command"}}, + lnExpectedCommandReq: &lnclient.CustomNodeCommandRequest{Name: "test_command", Args: []lnclient.CustomNodeCommandArg{}}, + lnResponse: nil, + lnError: fmt.Errorf("utter failure"), + apiExpectedResponse: nil, + apiExpectedErr: "utter failure", + } + + testCases := []testCase{ + testCaseOkNoArgs, + testCaseOkWithArgs, + testCaseOkWithUnsetArg, + testCaseErrEmptyCommand, + testCaseErrMalformedCommand, + testCaseErrUnknownCommand, + testCaseErrUnknownArg, + testCaseErrNodeFailed, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lnClient := mocks.NewMockLNClient(t) + svc := mocks.NewMockService(t) + + if tc.lnSupportedCommands != nil { + lnClient.On("GetCustomNodeCommandDefinitions").Return(tc.lnSupportedCommands) + } + + if tc.lnExpectedCommandReq != nil { + lnClient.On("ExecuteCustomNodeCommand", mock.Anything, tc.lnExpectedCommandReq).Return(tc.lnResponse, tc.lnError) + } + + svc.On("GetLNClient").Return(lnClient) + + theAPI := instantiateAPIWithService(svc) + + response, err := theAPI.ExecuteCustomNodeCommand(context.TODO(), tc.apiCommandLine) + require.Equal(t, tc.apiExpectedResponse, response) + if tc.apiExpectedErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.apiExpectedErr) + } + }) + } +} + +// instantiateAPIWithService is a helper function that returns a partially +// constructed API instance. It is only suitable for the simplest of test cases. +func instantiateAPIWithService(s service.Service) *api { + return &api{svc: s} +} diff --git a/api/models.go b/api/models.go index 73593f906..8f68613ce 100644 --- a/api/models.go +++ b/api/models.go @@ -57,6 +57,8 @@ type API interface { MigrateNodeStorage(ctx context.Context, to string) error GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) Health(ctx context.Context) (*HealthResponse, error) + GetCustomNodeCommands() (*CustomNodeCommandsResponse, error) + ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) } type App struct { @@ -392,3 +394,22 @@ func NewHealthAlarm(kind HealthAlarmKind, rawDetails any) HealthAlarm { type HealthResponse struct { Alarms []HealthAlarm `json:"alarms,omitempty"` } + +type CustomNodeCommandArgDef struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type CustomNodeCommandDef struct { + Name string `json:"name"` + Description string `json:"description"` + Args []CustomNodeCommandArgDef `json:"args"` +} + +type CustomNodeCommandsResponse struct { + Commands []CustomNodeCommandDef `json:"commands"` +} + +type ExecuteCustomNodeCommandRequest struct { + Command string `json:"command"` +} diff --git a/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx b/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx new file mode 100644 index 000000000..d174c2f9b --- /dev/null +++ b/frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "src/components/ui/alert-dialog"; +import { Textarea } from "src/components/ui/textarea"; +import { useToast } from "src/components/ui/use-toast"; +import { useInfo } from "src/hooks/useInfo"; +import { request } from "src/utils/request"; + +type ExecuteCustomNodeCommandDialogContentProps = { + availableCommands: string; + setCommandResponse: (response: string) => void; +}; + +export function ExecuteCustomNodeCommandDialogContent({ + setCommandResponse, + availableCommands, +}: ExecuteCustomNodeCommandDialogContentProps) { + const { mutate: reloadInfo } = useInfo(); + const { toast } = useToast(); + const [command, setCommand] = React.useState(); + + let parsedAvailableCommands = availableCommands; + try { + parsedAvailableCommands = JSON.stringify( + JSON.parse(availableCommands).commands, + null, + 2 + ); + } catch (error) { + // ignore unexpected json + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + try { + if (!command) { + throw new Error("No command set"); + } + const result = await request("/api/command", { + method: "POST", + body: JSON.stringify({ command }), + headers: { + "Content-Type": "application/json", + }, + }); + await reloadInfo(); + + const parsedResponse = JSON.stringify(result); + setCommandResponse(parsedResponse); + + toast({ title: "Command executed", description: parsedResponse }); + } catch (error) { + console.error(error); + toast({ + variant: "destructive", + title: "Something went wrong: " + error, + }); + } + } + + return ( + +
+ + Execute Custom Node Command + +