From 854d67a03b28b3208a589241bdd826688c6f72e0 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Wed, 19 Aug 2026 16:03:38 +0300 Subject: [PATCH 1/5] Export and import applications --- .../applications/application_service.go | 34 ++++++ .../applications/application_service_test.go | 111 ++++++++++++++++++ .../mocks/application_service_mock.go | 30 +++++ 3 files changed, 175 insertions(+) diff --git a/apptrust/service/applications/application_service.go b/apptrust/service/applications/application_service.go index d80777e..413354b 100644 --- a/apptrust/service/applications/application_service.go +++ b/apptrust/service/applications/application_service.go @@ -3,6 +3,7 @@ package applications //go:generate ${PROJECT_DIR}/scripts/mockgen.sh ${GOFILE} import ( + "encoding/json" "fmt" "net/http" @@ -17,6 +18,8 @@ type ApplicationService interface { CreateApplication(ctx service.Context, requestBody *model.AppDescriptor) ([]byte, error) UpdateApplication(ctx service.Context, requestBody *model.AppDescriptor) ([]byte, error) DeleteApplication(ctx service.Context, applicationKey string) error + ExportApplication(ctx service.Context, applicationKey string) ([]byte, error) + ImportApplication(ctx service.Context, envelope []byte) ([]byte, error) } type applicationService struct{} @@ -71,3 +74,34 @@ func (as *applicationService) DeleteApplication(ctx service.Context, application log.Info(fmt.Sprintf("Application \"%s\" deleted successfully.", applicationKey)) return nil } + +func (as *applicationService) ExportApplication(ctx service.Context, applicationKey string) ([]byte, error) { + endpoint := fmt.Sprintf("/v1/applications/%s/export", applicationKey) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, map[string]any{}, nil) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, errorutils.CheckErrorf("failed to export application. Status code: %d.\n%s", + response.StatusCode, responseBody) + } + + log.Info(fmt.Sprintf("Application \"%s\" exported successfully.", applicationKey)) + return responseBody, nil +} + +func (as *applicationService) ImportApplication(ctx service.Context, envelope []byte) ([]byte, error) { + response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/import", json.RawMessage(envelope), nil) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, errorutils.CheckErrorf("failed to import application. Status code: %d.\n%s", + response.StatusCode, responseBody) + } + + log.Info("Application imported successfully.") + return responseBody, nil +} diff --git a/apptrust/service/applications/application_service_test.go b/apptrust/service/applications/application_service_test.go index 340ccc2..884fcc0 100644 --- a/apptrust/service/applications/application_service_test.go +++ b/apptrust/service/applications/application_service_test.go @@ -65,3 +65,114 @@ func TestApplicationService_CreateApplication(t *testing.T) { }) } } + +func TestApplicationService_ExportApplication(t *testing.T) { + tests := []struct { + name string + mockResponse *http.Response + mockBody []byte + mockError error + expectedError string + }{ + { + name: "Export application successful", + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockBody: []byte(`{"applicationKey":"app-123"}`), + mockError: nil, + expectedError: "", + }, + { + name: "Export application failed with non-200 status code", + mockResponse: &http.Response{StatusCode: http.StatusForbidden}, + mockBody: []byte("export forbidden"), + mockError: nil, + expectedError: "failed to export application. Status code: 403.\nexport forbidden", + }, + { + name: "Export application failed with error", + mockResponse: nil, + mockBody: nil, + mockError: errors.New("http error"), + expectedError: "http error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post("/v1/applications/app-123/export", gomock.Any(), nil).Return(tt.mockResponse, tt.mockBody, tt.mockError) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + as := NewApplicationService() + body, err := as.ExportApplication(mockCtx, "app-123") + + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.mockBody, body) + } + }) + } +} + +func TestApplicationService_ImportApplication(t *testing.T) { + envelope := []byte(`{"applicationKey":"app-123","schemaVersion":"1"}`) + + tests := []struct { + name string + mockResponse *http.Response + mockBody []byte + mockError error + expectedError string + }{ + { + name: "Import application successful", + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockBody: []byte(`{"message":"imported"}`), + mockError: nil, + expectedError: "", + }, + { + name: "Import application failed with non-200 status code", + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockBody: []byte("invalid envelope"), + mockError: nil, + expectedError: "failed to import application. Status code: 400.\ninvalid envelope", + }, + { + name: "Import application failed with error", + mockResponse: nil, + mockBody: nil, + mockError: errors.New("http error"), + expectedError: "http error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post("/v1/applications/import", gomock.Any(), nil).Return(tt.mockResponse, tt.mockBody, tt.mockError) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + as := NewApplicationService() + _, err := as.ImportApplication(mockCtx, envelope) + + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apptrust/service/applications/mocks/application_service_mock.go b/apptrust/service/applications/mocks/application_service_mock.go index 733c943..5516b8d 100644 --- a/apptrust/service/applications/mocks/application_service_mock.go +++ b/apptrust/service/applications/mocks/application_service_mock.go @@ -70,6 +70,36 @@ func (mr *MockApplicationServiceMockRecorder) DeleteApplication(ctx, application return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplication", reflect.TypeOf((*MockApplicationService)(nil).DeleteApplication), ctx, applicationKey) } +// ExportApplication mocks base method. +func (m *MockApplicationService) ExportApplication(ctx service.Context, applicationKey string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExportApplication", ctx, applicationKey) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExportApplication indicates an expected call of ExportApplication. +func (mr *MockApplicationServiceMockRecorder) ExportApplication(ctx, applicationKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExportApplication", reflect.TypeOf((*MockApplicationService)(nil).ExportApplication), ctx, applicationKey) +} + +// ImportApplication mocks base method. +func (m *MockApplicationService) ImportApplication(ctx service.Context, envelope []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ImportApplication", ctx, envelope) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ImportApplication indicates an expected call of ImportApplication. +func (mr *MockApplicationServiceMockRecorder) ImportApplication(ctx, envelope any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportApplication", reflect.TypeOf((*MockApplicationService)(nil).ImportApplication), ctx, envelope) +} + // UpdateApplication mocks base method. func (m *MockApplicationService) UpdateApplication(ctx service.Context, requestBody *model.AppDescriptor) ([]byte, error) { m.ctrl.T.Helper() From 0a3e719207f039ad1f1db437c3a40e1b087a91d3 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 20 Aug 2026 13:58:48 +0300 Subject: [PATCH 2/5] Export and import applications --- .../commands/application/export_app_cmd.go | 138 ++++++++++++++++++ .../application/export_app_cmd_test.go | 104 +++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 apptrust/commands/application/export_app_cmd.go create mode 100644 apptrust/commands/application/export_app_cmd_test.go diff --git a/apptrust/commands/application/export_app_cmd.go b/apptrust/commands/application/export_app_cmd.go new file mode 100644 index 0000000..dba1306 --- /dev/null +++ b/apptrust/commands/application/export_app_cmd.go @@ -0,0 +1,138 @@ +package application + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/jfrog/jfrog-cli-application/apptrust/app" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/applications" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type exportAppCommand struct { + serverDetails *coreConfig.ServerDetails + applicationService applications.ApplicationService + applicationKey string + targetPath string +} + +func (eac *exportAppCommand) Run() error { + ctx, err := service.NewContext(*eac.serverDetails) + if err != nil { + return err + } + + envelope, err := eac.applicationService.ExportApplication(ctx, eac.applicationKey) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(eac.targetPath), 0o755); err != nil { + return errorutils.CheckError(err) + } + if err := os.WriteFile(eac.targetPath, envelope, 0o644); err != nil { + return errorutils.CheckError(err) + } + + log.Info(fmt.Sprintf("Application export written to %s", eac.targetPath)) + return nil +} + +func (eac *exportAppCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return eac.serverDetails, nil +} + +func (eac *exportAppCommand) CommandName() string { + return commands.AppExport +} + +func (eac *exportAppCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) < 1 || len(ctx.Arguments) > 2 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + eac.applicationKey = ctx.Arguments[0] + target := "" + if len(ctx.Arguments) == 2 { + target = ctx.Arguments[1] + } + eac.targetPath = resolveExportTargetPath(eac.applicationKey, target) + + var err error + eac.serverDetails, err = utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + + return commonCLiCommands.Exec(eac) +} + +func resolveExportTargetPath(applicationKey, target string) string { + if target == "" { + target = "./" + } + + // If the target ends with a slash, treat it as a directory and append the default filename + dir, fileName := fileutils.GetLocalPathAndFile(applicationKey+".json", "", target, true, false) + return filepath.Join(dir, fileName) +} + +func GetExportAppCommand(appContext app.Context) components.Command { + cmd := &exportAppCommand{ + applicationService: appContext.GetApplicationService(), + } + return components.Command{ + Name: commands.AppExport, + Description: "Export an application to a local JSON file.", + AIDescription: `Export an application's AppTrust metadata (descriptor, owners, labels, monitor policy, package bindings) to a local JSON file for air-gap transfer. + +When to use: +- Copy application metadata between disconnected AppTrust instances. + +Prerequisites: +- The application must exist. +- Configured server and project-admin permission on the application's project. +- Export is not available on Edge nodes. + +Common patterns: + $ jf apptrust app-export my-app + $ jf apptrust app-export my-app ./exports/ + $ jf apptrust app-export my-app ./my-app.json + $ jf at app-export my-app ./exports/ --server-id=my-server + +Gotchas: +- This exports application metadata only, not versions or artifacts. +- Trailing slash on target = directory ({application-key}.json is written inside); no slash = rename to that file. +- An existing file at the target path is overwritten without a prompt. + +Related: jf apptrust app-import, jf apptrust app-create, jf apptrust app-delete`, + Category: common.CategoryApplication, + Aliases: []string{"aexp"}, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The key of the application to export.", + Optional: false, + }, + { + Name: "target pattern", + Description: "Local filesystem target path. If it ends with a slash, it is assumed to be a directory and {application-key}.json is written into it. If there is no terminal slash, the target path is assumed to be a file to which the export file should be renamed.", + Optional: true, + }, + }, + Flags: commands.GetCommandFlags(commands.AppExport), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/apptrust/commands/application/export_app_cmd_test.go b/apptrust/commands/application/export_app_cmd_test.go new file mode 100644 index 0000000..4963943 --- /dev/null +++ b/apptrust/commands/application/export_app_cmd_test.go @@ -0,0 +1,104 @@ +package application + +import ( + "errors" + "flag" + "os" + "path/filepath" + "testing" + + mockapps "github.com/jfrog/jfrog-cli-application/apptrust/service/applications/mocks" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" + "go.uber.org/mock/gomock" +) + +func TestExportAppCommand_Run(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + appKey := "app-key" + targetPath := filepath.Join(t.TempDir(), "app.json") + compactJSON := []byte(`{"applicationKey":"app-key","schemaVersion":1}`) + + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().ExportApplication(gomock.Any(), appKey).Return(compactJSON, nil).Times(1) + + cmd := &exportAppCommand{ + applicationService: mockAppService, + serverDetails: serverDetails, + applicationKey: appKey, + targetPath: targetPath, + } + + err := cmd.Run() + assert.NoError(t, err) + + got, err := os.ReadFile(targetPath) + require.NoError(t, err) + assert.Equal(t, compactJSON, got) +} + +func TestExportAppCommand_Run_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + appKey := "app-key" + + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().ExportApplication(gomock.Any(), appKey).Return(nil, errors.New("export error")).Times(1) + + cmd := &exportAppCommand{ + applicationService: mockAppService, + serverDetails: serverDetails, + applicationKey: appKey, + targetPath: filepath.Join(t.TempDir(), "app.json"), + } + + err := cmd.Run() + assert.Error(t, err) + assert.Equal(t, "export error", err.Error()) +} + +func TestExportAppCommand_WrongNumberOfArguments(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + ctx := cli.NewContext(app, set, nil) + + mockAppService := mockapps.NewMockApplicationService(ctrl) + cmd := &exportAppCommand{ + applicationService: mockAppService, + } + + // Test with no arguments + context, err := components.ConvertContext(ctx) + assert.NoError(t, err) + + err = cmd.prepareAndRunCommand(context) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Wrong number of arguments") +} + +func TestResolveExportTargetPath(t *testing.T) { + tests := []struct { + name string + target string + want string + }{ + {name: "omitted defaults to current directory", target: "", want: "my-app.json"}, + {name: "trailing slash is a directory", target: "exports/", want: filepath.Join("exports", "my-app.json")}, + {name: "no trailing slash is a rename file", target: filepath.Join("a", "b"), want: filepath.Join("a", "b")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveExportTargetPath("my-app", tt.target)) + }) + } +} From a41119e4ff022411c5060de16b4fe0a5d878dcc5 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 20 Aug 2026 16:01:18 +0300 Subject: [PATCH 3/5] Export and import applications --- .../commands/application/export_app_cmd.go | 6 +- .../commands/application/import_app_cmd.go | 98 +++++++++++++++++++ .../application/import_app_cmd_test.go | 98 +++++++++++++++++++ .../applications/application_service.go | 12 +-- .../applications/application_service_test.go | 4 +- .../mocks/application_service_mock.go | 13 ++- 6 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 apptrust/commands/application/import_app_cmd.go create mode 100644 apptrust/commands/application/import_app_cmd_test.go diff --git a/apptrust/commands/application/export_app_cmd.go b/apptrust/commands/application/export_app_cmd.go index dba1306..54592b8 100644 --- a/apptrust/commands/application/export_app_cmd.go +++ b/apptrust/commands/application/export_app_cmd.go @@ -34,7 +34,7 @@ func (eac *exportAppCommand) Run() error { return err } - envelope, err := eac.applicationService.ExportApplication(ctx, eac.applicationKey) + applicationEnvelope, err := eac.applicationService.ExportApplication(ctx, eac.applicationKey) if err != nil { return err } @@ -42,7 +42,7 @@ func (eac *exportAppCommand) Run() error { if err := os.MkdirAll(filepath.Dir(eac.targetPath), 0o755); err != nil { return errorutils.CheckError(err) } - if err := os.WriteFile(eac.targetPath, envelope, 0o644); err != nil { + if err := os.WriteFile(eac.targetPath, applicationEnvelope, 0o644); err != nil { return errorutils.CheckError(err) } @@ -110,7 +110,7 @@ Common patterns: $ jf apptrust app-export my-app $ jf apptrust app-export my-app ./exports/ $ jf apptrust app-export my-app ./my-app.json - $ jf at app-export my-app ./exports/ --server-id=my-server + $ jf at aexp my-app ./exports/ --server-id=my-server Gotchas: - This exports application metadata only, not versions or artifacts. diff --git a/apptrust/commands/application/import_app_cmd.go b/apptrust/commands/application/import_app_cmd.go new file mode 100644 index 0000000..876d4bf --- /dev/null +++ b/apptrust/commands/application/import_app_cmd.go @@ -0,0 +1,98 @@ +package application + +import ( + "github.com/jfrog/jfrog-cli-application/apptrust/app" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/applications" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" +) + +type importAppCommand struct { + serverDetails *coreConfig.ServerDetails + applicationService applications.ApplicationService + applicationEnvelope []byte +} + +func (iac *importAppCommand) Run() error { + ctx, err := service.NewContext(*iac.serverDetails) + if err != nil { + return err + } + + return iac.applicationService.ImportApplication(ctx, iac.applicationEnvelope) +} + +func (iac *importAppCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return iac.serverDetails, nil +} + +func (iac *importAppCommand) CommandName() string { + return commands.AppImport +} + +func (iac *importAppCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) != 1 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + content, err := fileutils.ReadFile(ctx.Arguments[0]) + if errorutils.CheckError(err) != nil { + return err + } + iac.applicationEnvelope = content + + iac.serverDetails, err = utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + + return commonCLiCommands.Exec(iac) +} + +func GetImportAppCommand(appContext app.Context) components.Command { + cmd := &importAppCommand{ + applicationService: appContext.GetApplicationService(), + } + return components.Command{ + Name: commands.AppImport, + Description: "Import an application from a local file.", + AIDescription: `Import application metadata (descriptor, owners, labels, monitor policy, package bindings) from a local export file into AppTrust. + +When to use: +- Restore or copy application metadata onto an AppTrust instance after jf apptrust app-export. + +Prerequisites: +- A file produced by app-export. +- Configured server and project-admin permission on the project's key in the file. + +Common patterns: + $ jf apptrust app-import ./my-app + $ jf at aimp ./my-app.json --server-id=my-server + +Gotchas: +- This imports application metadata only, not versions or artifacts. +- Import upserts the application in place; a mismatched projectKey in the file is rejected by the server. + +Related: jf apptrust app-export, jf apptrust app-create, jf apptrust app-delete`, + Category: common.CategoryApplication, + Aliases: []string{"aimp"}, + Arguments: []components.Argument{ + { + Name: "path to file", + Description: "Local path to the application export file.", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.AppImport), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/apptrust/commands/application/import_app_cmd_test.go b/apptrust/commands/application/import_app_cmd_test.go new file mode 100644 index 0000000..2186405 --- /dev/null +++ b/apptrust/commands/application/import_app_cmd_test.go @@ -0,0 +1,98 @@ +package application + +import ( + "errors" + "flag" + "os" + "path/filepath" + "testing" + + mockapps "github.com/jfrog/jfrog-cli-application/apptrust/service/applications/mocks" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" + "go.uber.org/mock/gomock" +) + +func TestImportAppCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + content := []byte(`{"applicationKey":"app-key","schemaVersion":1}`) + filePath := filepath.Join(t.TempDir(), "export.json") + require.NoError(t, os.WriteFile(filePath, content, 0o600)) + + ctx := &components.Context{} + ctx.Arguments = []string{filePath} + ctx.AddStringFlag("url", "https://example.com") + + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().ImportApplication(gomock.Any(), content).Return(nil).Times(1) + + cmd := &importAppCommand{ + applicationService: mockAppService, + } + + err := cmd.prepareAndRunCommand(ctx) + assert.NoError(t, err) +} + +func TestImportAppCommand_Run_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationEnvelope := []byte(`{"applicationKey":"app-key"}`) + + mockAppService := mockapps.NewMockApplicationService(ctrl) + mockAppService.EXPECT().ImportApplication(gomock.Any(), applicationEnvelope).Return(errors.New("import error")).Times(1) + + cmd := &importAppCommand{ + applicationService: mockAppService, + serverDetails: serverDetails, + applicationEnvelope: applicationEnvelope, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Equal(t, "import error", err.Error()) +} + +func TestImportAppCommand_WrongNumberOfArguments(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + ctx := cli.NewContext(app, set, nil) + + mockAppService := mockapps.NewMockApplicationService(ctrl) + cmd := &importAppCommand{ + applicationService: mockAppService, + } + + // Test with no arguments + context, err := components.ConvertContext(ctx) + assert.NoError(t, err) + + err = cmd.prepareAndRunCommand(context) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Wrong number of arguments") +} + +func TestImportAppCommand_MissingFile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{filepath.Join(t.TempDir(), "does-not-exist.json")} + ctx.AddStringFlag("url", "https://example.com") + + cmd := &importAppCommand{ + applicationService: mockapps.NewMockApplicationService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) +} diff --git a/apptrust/service/applications/application_service.go b/apptrust/service/applications/application_service.go index 413354b..7fa67a2 100644 --- a/apptrust/service/applications/application_service.go +++ b/apptrust/service/applications/application_service.go @@ -19,7 +19,7 @@ type ApplicationService interface { UpdateApplication(ctx service.Context, requestBody *model.AppDescriptor) ([]byte, error) DeleteApplication(ctx service.Context, applicationKey string) error ExportApplication(ctx service.Context, applicationKey string) ([]byte, error) - ImportApplication(ctx service.Context, envelope []byte) ([]byte, error) + ImportApplication(ctx service.Context, applicationEnvelope []byte) error } type applicationService struct{} @@ -91,17 +91,17 @@ func (as *applicationService) ExportApplication(ctx service.Context, application return responseBody, nil } -func (as *applicationService) ImportApplication(ctx service.Context, envelope []byte) ([]byte, error) { - response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/import", json.RawMessage(envelope), nil) +func (as *applicationService) ImportApplication(ctx service.Context, applicationEnvelope []byte) error { + response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/import", json.RawMessage(applicationEnvelope), nil) if err != nil { - return nil, err + return err } if response.StatusCode != http.StatusOK { - return nil, errorutils.CheckErrorf("failed to import application. Status code: %d.\n%s", + return errorutils.CheckErrorf("failed to import application. Status code: %d.\n%s", response.StatusCode, responseBody) } log.Info("Application imported successfully.") - return responseBody, nil + return nil } diff --git a/apptrust/service/applications/application_service_test.go b/apptrust/service/applications/application_service_test.go index 884fcc0..66613a8 100644 --- a/apptrust/service/applications/application_service_test.go +++ b/apptrust/service/applications/application_service_test.go @@ -122,7 +122,7 @@ func TestApplicationService_ExportApplication(t *testing.T) { } func TestApplicationService_ImportApplication(t *testing.T) { - envelope := []byte(`{"applicationKey":"app-123","schemaVersion":"1"}`) + applicationEnvelope := []byte(`{"applicationKey":"app-123","schemaVersion":"1"}`) tests := []struct { name string @@ -166,7 +166,7 @@ func TestApplicationService_ImportApplication(t *testing.T) { mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) as := NewApplicationService() - _, err := as.ImportApplication(mockCtx, envelope) + err := as.ImportApplication(mockCtx, applicationEnvelope) if tt.expectedError != "" { assert.EqualError(t, err, tt.expectedError) diff --git a/apptrust/service/applications/mocks/application_service_mock.go b/apptrust/service/applications/mocks/application_service_mock.go index 5516b8d..482ba09 100644 --- a/apptrust/service/applications/mocks/application_service_mock.go +++ b/apptrust/service/applications/mocks/application_service_mock.go @@ -86,18 +86,17 @@ func (mr *MockApplicationServiceMockRecorder) ExportApplication(ctx, application } // ImportApplication mocks base method. -func (m *MockApplicationService) ImportApplication(ctx service.Context, envelope []byte) ([]byte, error) { +func (m *MockApplicationService) ImportApplication(ctx service.Context, applicationEnvelope []byte) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ImportApplication", ctx, envelope) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "ImportApplication", ctx, applicationEnvelope) + ret0, _ := ret[0].(error) + return ret0 } // ImportApplication indicates an expected call of ImportApplication. -func (mr *MockApplicationServiceMockRecorder) ImportApplication(ctx, envelope any) *gomock.Call { +func (mr *MockApplicationServiceMockRecorder) ImportApplication(ctx, applicationEnvelope any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportApplication", reflect.TypeOf((*MockApplicationService)(nil).ImportApplication), ctx, envelope) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportApplication", reflect.TypeOf((*MockApplicationService)(nil).ImportApplication), ctx, applicationEnvelope) } // UpdateApplication mocks base method. From a5d114e54c3de9399609f0b3446207b6ce5d550a Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 20 Aug 2026 16:02:11 +0300 Subject: [PATCH 4/5] Export and import applications --- apptrust/commands/flags.go | 16 ++++++++++++++++ cli/cli.go | 2 ++ 2 files changed, 18 insertions(+) diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index d0dd291..7e8180a 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -23,6 +23,8 @@ const ( AppCreate = "app-create" AppUpdate = "app-update" AppDelete = "app-delete" + AppExport = "app-export" + AppImport = "app-import" ) const ( @@ -308,6 +310,20 @@ var commandFlags = map[string][]string{ accessToken, serverId, }, + + AppExport: { + url, + user, + accessToken, + serverId, + }, + + AppImport: { + url, + user, + accessToken, + serverId, + }, } func GetCommandFlags(cmdKey string) []components.Flag { diff --git a/cli/cli.go b/cli/cli.go index f34f52e..1627198 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -55,6 +55,8 @@ Related: jf rt, jf release-bundle commands.`, application.GetCreateAppCommand(appContext), application.GetUpdateAppCommand(appContext), application.GetDeleteAppCommand(appContext), + application.GetExportAppCommand(appContext), + application.GetImportAppCommand(appContext), }, }, ) From 4647517830bdea778a1aef1bb7818211e690eea6 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 20 Aug 2026 16:19:05 +0300 Subject: [PATCH 5/5] Export and import applications --- e2e/application_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/e2e/application_test.go b/e2e/application_test.go index 7284b80..88b4bc0 100644 --- a/e2e/application_test.go +++ b/e2e/application_test.go @@ -3,6 +3,7 @@ package e2e import ( + "path/filepath" "strings" "testing" @@ -111,3 +112,30 @@ func TestDeleteApp(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 404, statusCode) } + +func TestExportImportApp(t *testing.T) { + projectKey := utils.GetTestProjectKey(t) + appKey := utils.GenerateUniqueKey("app-export") + utils.CreateBasicApplication(t, appKey) + + exportPath := filepath.Join(t.TempDir(), "export.json") + err := utils.AppTrustCli.Exec("app-export", appKey, exportPath) + assert.NoError(t, err) + + err = utils.AppTrustCli.Exec("app-delete", appKey) + assert.NoError(t, err) + + _, statusCode, err := utils.GetApplication(appKey) + assert.NoError(t, err) + assert.Equal(t, 404, statusCode) + + err = utils.AppTrustCli.Exec("app-import", exportPath) + assert.NoError(t, err) + defer utils.DeleteApplication(t, appKey) + + app, _, err := utils.GetApplication(appKey) + assert.NoError(t, err) + assert.Equal(t, appKey, app.ApplicationKey) + assert.Equal(t, appKey, app.ApplicationName) + assert.Equal(t, projectKey, app.ProjectKey) +}