Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewClient(
os types.Os,
) *Client {
return &Client{
Manifest: NewManifestClient(apiClient, config),
Manifest: NewManifestClient(apiClient, config, fs),
AppClientInterface: NewAppClient(config, fs, os),
}
}
Expand Down
35 changes: 33 additions & 2 deletions internal/app/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,23 @@ package app
import (
"context"
"encoding/json"
"path/filepath"
"strings"

"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/hooks"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
)

const manifestFileName = "manifest.json"

// ManifestClient can manage the state of the project's app manifest file
type ManifestClient struct {
apiClient api.APIInterface
fs afero.Fs
domainAuthTokens string
Env map[string]string
}
Expand Down Expand Up @@ -59,17 +64,44 @@ func SetManifestEnvTeamVars(manifestEnv map[string]string, appTeamDomain string,
func NewManifestClient(
apiClient api.APIInterface,
config *config.Config,
fs afero.Fs,
) *ManifestClient {
client := &ManifestClient{
apiClient: apiClient,
fs: fs,
domainAuthTokens: config.DomainAuthTokens,
Env: config.ManifestEnv,
}
return client
}

// GetManifestLocal gathers manifest content from the "get-manifest" hook
// GetManifestLocal reads the local manifest, preferring the "get-manifest" hook
// when available. Falls back to reading manifest.json from the project root.
func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
if sdkConfig.Hooks.GetManifest.IsAvailable() {
return c.getManifestFromHook(ctx, sdkConfig, hookExecutor)
}
return c.getManifestFromFile(sdkConfig)
}

func (c *ManifestClient) getManifestFromFile(sdkConfig hooks.SDKCLIConfig) (types.SlackYaml, error) {
var sl types.SlackYaml
manifestPath := filepath.Join(sdkConfig.WorkingDirectory, manifestFileName)
data, err := afero.ReadFile(c.fs, manifestPath)
if err != nil {
return sl, slackerror.New("Failed to read manifest file").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
}
if err := json.Unmarshal(data, &sl); err != nil {
return sl, slackerror.New("Failed to parse manifest file").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
}
return sl, nil
}

func (c *ManifestClient) getManifestFromHook(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
var sl types.SlackYaml

if !sdkConfig.Hooks.GetManifest.IsAvailable() {
Expand Down Expand Up @@ -104,7 +136,6 @@ func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.S
if start != -1 {
slackManifestInfo = slackManifestInfo[start:]
} else {
// the app manifest has to be a json so needs to have the character `{`
return sl, slackerror.New("Invalid app manifest format, must be valid JSON").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
Expand Down
174 changes: 119 additions & 55 deletions internal/app/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/slackapi/slack-cli/internal/slackcontext"
"github.com/slackapi/slack-cli/internal/slackdeps"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -68,77 +69,140 @@ func Test_AppManifest_SetManifestEnvTeamVars(t *testing.T) {
}

func Test_AppManifest_GetManifestLocal(t *testing.T) {
tests := map[string]struct {
mockManifestInfo string
mockManifestErr error
expectedErr error
expectedManifest types.SlackYaml
t.Run("prefers hook over manifest.json when hook is available", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
configMock.DomainAuthTokens = "api.slack.com"
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: "echo manifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`{"display_information":{"name":"file-app"}}`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(`{"display_information":{"name":"hook-app"}}`, nil)
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.NoError(t, err)
assert.Equal(t, "hook-app", result.DisplayInformation.Name)
mockHookExecutor.AssertCalled(t, "Execute", mock.Anything, mock.Anything)
})

t.Run("falls back to manifest.json when no hook exists", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`{"display_information":{"name":"file-app"}}`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.NoError(t, err)
assert.Equal(t, "file-app", result.DisplayInformation.Name)
mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
})

t.Run("errors if no hook and no manifest.json", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

_, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.Error(t, err)
assert.Equal(t, slackerror.ErrInvalidManifest, err.(*slackerror.Error).Code)
})

t.Run("errors if manifest.json contains invalid JSON", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`not json`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

_, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.Error(t, err)
assert.Equal(t, slackerror.ErrInvalidManifest, err.(*slackerror.Error).Code)
})

hookTests := map[string]struct {
hookOutput string
hookErr error
expectedName string
expectedErr string
}{
"errors if no get-manifest hook exists": {
expectedErr: slackerror.New(slackerror.ErrSDKHookNotFound),
},
"returns an existing manifest without errors": {
mockManifestInfo: `{"display_information":{"name":"my-example-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-example-app",
},
},
},
"returns manifest from hook output": {
hookOutput: `{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"errors if the hook execution errors": {
mockManifestInfo: `{}`,
mockManifestErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"parses hook output with leading characters": {
hookOutput: `...{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"parses a manifest with random leading characters": {
mockManifestInfo: `...{"display_information":{"name":"my-showcased-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-showcased-app",
},
},
},
"errors if hook execution errors": {
hookOutput: `{}`,
hookErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.ErrInvalidManifest,
},
"errors if a manifest is not present in output": {
mockManifestInfo: `...unknown`,
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"errors if hook output has no JSON": {
hookOutput: `...unknown`,
expectedErr: slackerror.ErrInvalidManifest,
},
}
for name, tc := range tests {
for name, tc := range hookTests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
mockManifestEnv := map[string]string{"EXAMPLE": "12"}
mockSDKConfig := hooks.NewSDKConfigMock()
mockHookExecutor := &hooks.MockHookExecutor{}
if tc.mockManifestInfo != "" {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{
Name: "GetManifest",
Command: "cat manifest.json",
}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.mockManifestInfo, tc.mockManifestErr)
} else {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}
}
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
configMock.DomainAuthTokens = "api.slack.com"
configMock.ManifestEnv = mockManifestEnv
manifestClient := NewManifestClient(&api.APIMock{}, configMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: "generate-manifest"}

mockHookExecutor := &hooks.MockHookExecutor{}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.hookOutput, tc.hookErr)

manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

actualManifest, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != nil {
result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != "" {
require.Error(t, err)
assert.Equal(t,
tc.expectedErr.(*slackerror.Error).Code, err.(*slackerror.Error).Code)
assert.Equal(t, tc.expectedErr, err.(*slackerror.Error).Code)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedManifest, actualManifest)
assert.Equal(t, tc.expectedName, result.DisplayInformation.Name)
}
})
}
Expand Down Expand Up @@ -186,7 +250,7 @@ func Test_AppManifest_GetManifestRemote(t *testing.T) {
apic := &api.APIMock{}
apic.On("ExportAppManifest", mock.Anything, mock.Anything, mock.Anything).
Return(api.ExportAppResult{Manifest: tc.mockManifestResponse}, tc.mockManifestError)
manifestClient := NewManifestClient(apic, configMock)
manifestClient := NewManifestClient(apic, configMock, fsMock)

manifest, err := manifestClient.GetManifestRemote(ctx, tc.mockToken, tc.mockAppID)
if tc.expectedError != nil {
Expand Down
85 changes: 39 additions & 46 deletions internal/manifest/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,52 +173,45 @@ func Test_Sync(t *testing.T) {
assert.Equal(t, slackerror.ErrAppManifestUpdate, slackErr.Code)
})

t.Run("force flag merges all local and pushes to API", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceFlag = true
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
f.clientsMock.API.AssertCalled(t, "UpdateApp", mock.Anything, "xoxb-test", "A123", mock.Anything, true, true)
})

t.Run("force-remote flag merges all remote and pushes to API", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceRemoteFlag = true
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
// Verify remote value was used — the merged manifest should have "Remote" description
assert.Equal(t, "Remote", result.Merged.DisplayInformation.Description)
})
mergeStrategyTests := map[string]struct {
forceFlag bool
forceRemoteFlag bool
expectedDesc string
}{
"force flag merges all local": {
forceFlag: true,
expectedDesc: "Local",
},
"force-remote flag merges all remote": {
forceRemoteFlag: true,
expectedDesc: "Remote",
},
}
for name, tc := range mergeStrategyTests {
t.Run(name, func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceFlag = tc.forceFlag
f.clients.Config.ForceRemoteFlag = tc.forceRemoteFlag
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
assert.Equal(t, tc.expectedDesc, result.Merged.DisplayInformation.Description)
})
}

t.Run("API UpdateApp failure is propagated", func(t *testing.T) {
f := newSyncTestFixture(t)
Expand Down
Loading