Skip to content
Draft
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
1 change: 1 addition & 0 deletions internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type AppsClient interface {
GetPresignedS3PostParams(ctx context.Context, token string, appID string) (GenerateS3PresignedPostResult, error)
Host() string
Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error)
IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error)
RequestAppApproval(ctx context.Context, token string, appID string, teamID string, reason string, scopes string, outgoingDomains []string) (AppsApprovalsRequestsCreateResult, error)
SetHost(host string)
UninstallApp(ctx context.Context, token string, appID, teamID string) error
Expand Down
16 changes: 14 additions & 2 deletions internal/api/icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import (

const (
appIconMethod = "apps.hosted.icon"
// AppIconSetMethod is the API method for setting app icons for non-hosted apps.
AppIconSetMethod = "apps.icon.set"
)

// IconResult details to be saved
Expand All @@ -48,6 +50,16 @@ type iconResponse struct {

// Icon updates a Slack App's icon
func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconMethod, "file")
}

// IconSet sets a Slack App's icon using the apps.icon.set API method.
func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
return c.uploadIcon(ctx, fs, token, appID, iconFilePath, AppIconSetMethod, "icon")
}

// uploadIcon uploads an icon to the given API method.
func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath, apiMethod, fileFieldName string) (IconResult, error) {
var (
iconBytes []byte
err error
Expand Down Expand Up @@ -81,7 +93,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa

var part io.Writer
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", iconStat.Name()))
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name()))
h.Set("Content-Type", http.DetectContentType(iconBytes))
part, err = writer.CreatePart(h)
if err != nil {
Expand All @@ -101,7 +113,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa
writer.Close()

var sURL *url.URL
sURL, err = url.Parse(c.host + "/api/" + appIconMethod)
sURL, err = url.Parse(c.host + "/api/" + apiMethod)
if err != nil {
return IconResult{}, err
}
Expand Down
7 changes: 6 additions & 1 deletion internal/api/icon_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import (
)

func (m *APIMock) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
args := m.Called(ctx, fs, token, iconFilePath)
args := m.Called(ctx, fs, token, appID, iconFilePath)
return args.Get(0).(IconResult), args.Error(1)
}

func (m *APIMock) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
args := m.Called(ctx, fs, token, appID, iconFilePath)
return args.Get(0).(IconResult), args.Error(1)
}
72 changes: 72 additions & 0 deletions internal/api/icon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,78 @@ func TestClient_IconErrorWrongFile(t *testing.T) {
require.Contains(t, err.Error(), "unknown format")
}

func TestClient_IconSetErrorIfMissingArgs(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
})
defer teardown()
_, err := c.IconSet(ctx, fs, "token", "", "")
require.Error(t, err)
require.Contains(t, err.Error(), "missing required args")
}

func TestClient_IconSetErrorNoFile(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
})
defer teardown()
_, err := c.IconSet(ctx, fs, "token", "12345", imgFile)
require.Error(t, err)
require.Contains(t, err.Error(), "file does not exist")
}

func TestClient_IconSetErrorResponse(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()

myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})
for x := range 100 {
for y := range 100 {
c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255}
myimage.Set(x, y, c)
}
}
myfile, _ := fs.Create(imgFile)
err := png.Encode(myfile, myimage)
require.NoError(t, err)
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
Response: `{"ok":false,"error":"invalid_app"}`,
})
defer teardown()
_, err = c.IconSet(ctx, fs, "token", "12345", imgFile)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid_app")
}

func TestClient_IconSetSuccess(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()

myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})

for x := range 100 {
for y := range 100 {
c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255}
myimage.Set(x, y, c)
}
}
myfile, _ := fs.Create(imgFile)
err := png.Encode(myfile, myimage)
require.NoError(t, err)
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
Response: `{"ok":true}`,
})
defer teardown()
_, err = c.IconSet(ctx, fs, "token", "12345", imgFile)
require.NoError(t, err)
}

func TestClient_IconSuccess(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
Expand Down
4 changes: 4 additions & 0 deletions internal/experiment/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const (
// Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes.
Sandboxes Experiment = "sandboxes"

// SetIcon experiment enables icon upload for non-hosted apps.
SetIcon Experiment = "set-icon"

// Templates experiment brings more agent templates to the create command.
Templates Experiment = "templates"
)
Expand All @@ -49,6 +52,7 @@ var AllExperiments = []Experiment{
Lipgloss,
Placeholder,
Sandboxes,
SetIcon,
Templates,
}

Expand Down
44 changes: 20 additions & 24 deletions internal/pkg/apps/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/opentracing/opentracing-go"
"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/pkg/manifest"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
Expand Down Expand Up @@ -521,30 +522,25 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran
return app, result, installState, err
}

//
// TODO: Currently, cannot update the icon if app is not hosted.
//
// upload icon, default to icon.png
// var iconPath = slackYaml.Icon
// if iconPath == "" {
// if _, err := os.Stat("icon.png"); !os.IsNotExist(err) {
// iconPath = "icon.png"
// }
// }
// if iconPath != "" {
// clients.IO.PrintDebug(ctx, "uploading icon")
// err = updateIcon(ctx, clients, iconPath, env.AppID, token)
// if err != nil {
// clients.IO.PrintError(ctx, "An error occurred updating the Icon", err)
// }
// // Save a md5 hash of the icon in environments.yaml
// var iconHash string
// iconHash, err = getIconHash(iconPath)
// if err != nil {
// return env, api.DeveloperAppInstallResult{}, err
// }
// env.IconHash = iconHash
// }
// upload icon for non-hosted apps (gated behind set-icon experiment)
if clients.Config.WithExperimentOn(experiment.SetIcon) {
var iconPath = slackManifest.Icon
if iconPath == "" {
if _, err := os.Stat("icon.png"); !os.IsNotExist(err) {
iconPath = "icon.png"
}
}
if iconPath != "" {
clients.IO.PrintDebug(ctx, "uploading icon")
_, iconErr := clients.API().IconSet(ctx, clients.Fs, token, app.AppID, iconPath)
if iconErr != nil {
clients.IO.PrintDebug(ctx, "icon error: %s", iconErr)
_, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Error updating app icon: %s", iconErr)))
} else {
_, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Updated app icon: %s", iconPath)))
}
}
}

// update config with latest yaml hash
// env.Hash = slackYaml.Hash
Expand Down
Loading