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
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
19 changes: 16 additions & 3 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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔬 issue: This outputs a warning to verbose logs and it's not clear what I might not be including?

uploading icon
icon error: https://slack.com/api/apps.icon.set error: missing_arguments
   Error updating app icon: https://slack.com/api/apps.icon.set error: missing_arguments
   Finished in 0.7s

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you uploading through the manifest or is the image just in the root of the project?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@srtaalej I saved this file to the root of the project 🌲 ✨

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "file" fielf for the api request had the wrong name! can you try again ?

)

// IconResult details to be saved
Expand All @@ -46,8 +48,19 @@ type iconResponse struct {
IconResult
}

// Icon updates a Slack App's icon
// Icon updates a hosted Slack app icon
// DEPRECATED: Prefer "IconSet" instead
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, "file")
}

// 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 +94,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()))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name()))
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, iconStat.Name()))

🌚 suggestion: Let's revert the addition of fileFieldName here and in arguments too if this is static?

h.Set("Content-Type", http.DetectContentType(iconBytes))
part, err = writer.CreatePart(h)
if err != nil {
Expand All @@ -101,7 +114,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)
}
260 changes: 208 additions & 52 deletions internal/api/icon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,65 +28,221 @@ import (

var imgFile = ".assets/icon.png"

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

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

func TestClient_IconErrorWrongFile(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
err := afero.WriteFile(fs, "test.txt", []byte("this is a text file"), 0666)
require.NoError(t, err)
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: appIconMethod,
})
defer teardown()
_, err = c.Icon(ctx, fs, "token", "12345", "test.txt")
require.Error(t, err)
require.Contains(t, err.Error(), "unknown format")
}

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

func createTestPNG(t *testing.T, fs afero.Fs, path string) {
t.Helper()
myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})

// This loop just fills the image with random data
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)
myfile, err := fs.Create(path)
require.NoError(t, err)
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: appIconMethod,
Response: `{"ok":true}`,
})
defer teardown()
_, err = c.Icon(ctx, fs, "token", "12345", imgFile)
err = png.Encode(myfile, myimage)
require.NoError(t, err)
}

func TestClient_Icon(t *testing.T) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 praise: Thank you for updating existing tests to a familiar format!

tests := map[string]struct {
setupFs func(t *testing.T, fs afero.Fs)
appID string
filePath string
response string
expectErr bool
errContains string
}{
"returns error when args are missing": {
appID: "",
filePath: "",
expectErr: true,
errContains: "missing required args",
},
"returns error when file does not exist": {
appID: "12345",
filePath: imgFile,
expectErr: true,
errContains: "file does not exist",
},
"returns error for non-image file": {
setupFs: func(t *testing.T, fs afero.Fs) {
err := afero.WriteFile(fs, "test.txt", []byte("this is a text file"), 0666)
require.NoError(t, err)
},
appID: "12345",
filePath: "test.txt",
expectErr: true,
errContains: "unknown format",
},
"succeeds with valid PNG": {
setupFs: func(t *testing.T, fs afero.Fs) {
createTestPNG(t, fs, imgFile)
},
appID: "12345",
filePath: imgFile,
response: `{"ok":true}`,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
if tc.setupFs != nil {
tc.setupFs(t, fs)
}
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: appIconMethod,
Response: tc.response,
})
defer teardown()
_, err := c.Icon(ctx, fs, "token", tc.appID, tc.filePath)
if tc.expectErr {
require.Error(t, err)
if tc.errContains != "" {
require.Contains(t, err.Error(), tc.errContains)
}
} else {
require.NoError(t, err)
}
})
}
}

func TestClient_IconSet(t *testing.T) {
tests := map[string]struct {
setupFs func(t *testing.T, fs afero.Fs)
appID string
filePath string
response string
expectErr bool
errContains string
}{
"returns error when args are missing": {
appID: "",
filePath: "",
expectErr: true,
errContains: "missing required args",
},
"returns error when file does not exist": {
appID: "12345",
filePath: imgFile,
expectErr: true,
errContains: "file does not exist",
},
"returns error for empty file": {
setupFs: func(t *testing.T, fs afero.Fs) {
err := afero.WriteFile(fs, imgFile, []byte{}, 0666)
require.NoError(t, err)
},
appID: "12345",
filePath: imgFile,
expectErr: true,
},
"returns error for unsupported format": {
setupFs: func(t *testing.T, fs afero.Fs) {
svgContent := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="50"/></svg>`)
err := afero.WriteFile(fs, "icon.svg", svgContent, 0666)
require.NoError(t, err)
},
appID: "12345",
filePath: "icon.svg",
expectErr: true,
errContains: "unknown format",
},
"returns error for non-image content with .png extension": {
setupFs: func(t *testing.T, fs afero.Fs) {
err := afero.WriteFile(fs, "fake.png", []byte("this is not a png"), 0666)
require.NoError(t, err)
},
appID: "12345",
filePath: "fake.png",
expectErr: true,
errContains: "unknown format",
},
"returns error for truncated PNG": {
setupFs: func(t *testing.T, fs afero.Fs) {
// Valid PNG signature followed by incomplete IHDR chunk
truncatedPNG := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}
err := afero.WriteFile(fs, "truncated.png", truncatedPNG, 0666)
require.NoError(t, err)
},
appID: "12345",
filePath: "truncated.png",
expectErr: true,
},
"returns error from API response": {
setupFs: func(t *testing.T, fs afero.Fs) {
createTestPNG(t, fs, imgFile)
},
appID: "12345",
filePath: imgFile,
response: `{"ok":false,"error":"invalid_app"}`,
expectErr: true,
errContains: "invalid_app",
},
"succeeds with valid PNG": {
setupFs: func(t *testing.T, fs afero.Fs) {
createTestPNG(t, fs, imgFile)
},
appID: "12345",
filePath: imgFile,
response: `{"ok":true}`,
},
// cutter.Crop with Ratio mode floors a 1x1 image to 0x0, causing png.Encode to fail.
// May need a follow-up to add a minimum dimension check before cropping.
"returns error for 1x1 pixel image": {
setupFs: func(t *testing.T, fs afero.Fs) {
myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{1, 1}})
myimage.Set(0, 0, color.RGBA{255, 0, 0, 255})
myfile, err := fs.Create(imgFile)
require.NoError(t, err)
err = png.Encode(myfile, myimage)
require.NoError(t, err)
},
appID: "12345",
filePath: imgFile,
expectErr: true,
},
"succeeds with non-square image": {
setupFs: func(t *testing.T, fs afero.Fs) {
myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{2000, 100}})
for x := range 2000 {
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, err := fs.Create(imgFile)
require.NoError(t, err)
err = png.Encode(myfile, myimage)
require.NoError(t, err)
},
appID: "12345",
filePath: imgFile,
response: `{"ok":true}`,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
if tc.setupFs != nil {
tc.setupFs(t, fs)
}
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: appIconSetMethod,
Response: tc.response,
})
defer teardown()
_, err := c.IconSet(ctx, fs, "token", tc.appID, tc.filePath)
if tc.expectErr {
require.Error(t, err)
if tc.errContains != "" {
require.Contains(t, err.Error(), tc.errContains)
}
} else {
require.NoError(t, err)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/experiment/experiment.go
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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"
)

// AllExperiments is a list of all available experiments that can be enabled
Expand All @@ -46,6 +49,7 @@ var AllExperiments = []Experiment{
Lipgloss,
Placeholder,
Sandboxes,
SetIcon,
}

// EnabledExperiments is a list of experiments that are permanently enabled
Expand Down
Loading
Loading