diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 00000000..c979693d --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,66 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_Client_Host(t *testing.T) { + tests := map[string]struct { + host string + expected string + }{ + "returns the configured host": { + host: "https://slack.com", + expected: "https://slack.com", + }, + "returns empty when unset": { + host: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + c := &Client{host: tc.host} + assert.Equal(t, tc.expected, c.Host()) + }) + } +} + +func Test_Client_SetHost(t *testing.T) { + tests := map[string]struct { + initial string + newHost string + }{ + "sets a new host": { + initial: "", + newHost: "https://dev.slack.com", + }, + "overwrites existing host": { + initial: "https://slack.com", + newHost: "https://dev.slack.com", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + c := &Client{host: tc.initial} + c.SetHost(tc.newHost) + assert.Equal(t, tc.newHost, c.Host()) + }) + } +} diff --git a/internal/api/collaborators_test.go b/internal/api/collaborators_test.go new file mode 100644 index 00000000..d61c307b --- /dev/null +++ b/internal/api/collaborators_test.go @@ -0,0 +1,171 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/stretchr/testify/require" +) + +func Test_Client_AddCollaborator(t *testing.T) { + tests := map[string]struct { + response string + user types.SlackUser + expectErr string + }{ + "adds a collaborator by email": { + response: `{"ok":true}`, + user: types.SlackUser{Email: "user@example.com", PermissionType: "owner"}, + }, + "adds a collaborator by user ID": { + response: `{"ok":true}`, + user: types.SlackUser{ID: "U123", PermissionType: "owner"}, + }, + "returns error when user not found": { + response: `{"ok":false,"error":"user_not_found"}`, + user: types.SlackUser{Email: "bad@example.com"}, + expectErr: "user_not_found", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: collaboratorsAddMethod, + Response: tc.response, + }) + defer teardown() + err := c.AddCollaborator(ctx, "token", "A123", tc.user) + if tc.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_Client_ListCollaborators(t *testing.T) { + tests := map[string]struct { + response string + expectedCount int + expectedID string + expectErr string + }{ + "returns a list of collaborators": { + response: `{"ok":true,"owners":[{"user_id":"U123","username":"Test User"}]}`, + expectedCount: 1, + expectedID: "U123", + }, + "returns error when app not found": { + response: `{"ok":false,"error":"app_not_found"}`, + expectErr: "app_not_found", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: collaboratorsListMethod, + Response: tc.response, + }) + defer teardown() + users, err := c.ListCollaborators(ctx, "token", "A123") + if tc.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectErr) + } else { + require.NoError(t, err) + require.Len(t, users, tc.expectedCount) + require.Equal(t, tc.expectedID, users[0].ID) + } + }) + } +} + +func Test_Client_RemoveCollaborator(t *testing.T) { + tests := map[string]struct { + response string + user types.SlackUser + expectErr string + }{ + "removes a collaborator": { + response: `{"ok":true}`, + user: types.SlackUser{ID: "U123"}, + }, + "returns error when removing owner": { + response: `{"ok":false,"error":"cannot_remove_owner"}`, + user: types.SlackUser{Email: "owner@example.com"}, + expectErr: "cannot_remove_owner", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: collaboratorsRemoveMethod, + Response: tc.response, + }) + defer teardown() + warnings, err := c.RemoveCollaborator(ctx, "token", "A123", tc.user) + if tc.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectErr) + } else { + require.NoError(t, err) + require.Empty(t, warnings) + } + }) + } +} + +func Test_Client_UpdateCollaborator(t *testing.T) { + tests := map[string]struct { + response string + user types.SlackUser + expectErr string + }{ + "updates a collaborator": { + response: `{"ok":true}`, + user: types.SlackUser{ID: "U123", PermissionType: "collaborator"}, + }, + "returns error for invalid permission": { + response: `{"ok":false,"error":"invalid_permission"}`, + user: types.SlackUser{ID: "U123", PermissionType: "invalid"}, + expectErr: "invalid_permission", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: collaboratorsUpdateMethod, + Response: tc.response, + }) + defer teardown() + err := c.UpdateCollaborator(ctx, "token", "A123", tc.user) + if tc.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/archiveutil/archiveutil_test.go b/internal/archiveutil/archiveutil_test.go new file mode 100644 index 00000000..a37983d0 --- /dev/null +++ b/internal/archiveutil/archiveutil_test.go @@ -0,0 +1,159 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package archiveutil + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TODO: Refactor to use afero.Fs once ExtractAndWriteFile accepts it. Currently uses t.TempDir() which is safe. +func Test_ExtractAndWriteFile(t *testing.T) { + tests := map[string]struct { + fileName string + content string + isDir bool + }{ + "extracts a regular file": { + fileName: "hello.txt", + content: "hello world", + isDir: false, + }, + "creates a directory": { + fileName: "subdir/", + isDir: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + destDir := t.TempDir() + reader := strings.NewReader(tc.content) + + path, err := ExtractAndWriteFile(reader, destDir, tc.fileName, tc.isDir, 0644) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(path, destDir)) + + if tc.isDir { + info, err := os.Stat(path) + require.NoError(t, err) + assert.True(t, info.IsDir()) + } else { + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, tc.content, string(content)) + } + }) + } + + t.Run("rejects path traversal", func(t *testing.T) { + destDir := t.TempDir() + reader := strings.NewReader("malicious") + _, err := ExtractAndWriteFile(reader, destDir, "../../../etc/passwd", false, 0644) + assert.Error(t, err) + assert.Contains(t, err.Error(), "illegal file path") + }) +} + +// TODO: Refactor to use afero.Fs once Unzip accepts it. Currently uses t.TempDir() which is safe. +func Test_Unzip(t *testing.T) { + t.Run("extracts a zip archive", func(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + + // Create a zip file + zipPath := filepath.Join(srcDir, "test.zip") + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + + w := zip.NewWriter(zipFile) + f, err := w.Create("test.txt") + require.NoError(t, err) + _, err = f.Write([]byte("zip content")) + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + err = zipFile.Close() + require.NoError(t, err) + + files, err := Unzip(zipPath, destDir) + require.NoError(t, err) + assert.NotEmpty(t, files) + + content, err := os.ReadFile(filepath.Join(destDir, "test.txt")) + require.NoError(t, err) + assert.Equal(t, "zip content", string(content)) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + _, err := Unzip("/nonexistent.zip", t.TempDir()) + assert.Error(t, err) + }) +} + +// TODO: Refactor to use afero.Fs once UntarGzip accepts it. Currently uses t.TempDir() which is safe. +func Test_UntarGzip(t *testing.T) { + t.Run("extracts a tar.gz archive", func(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + + // Create a tar.gz file + tgzPath := filepath.Join(srcDir, "test.tar.gz") + tgzFile, err := os.Create(tgzPath) + require.NoError(t, err) + + gw := gzip.NewWriter(tgzFile) + tw := tar.NewWriter(gw) + + content := []byte("tar content") + hdr := &tar.Header{ + Name: "test.txt", + Mode: 0644, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + err = tw.WriteHeader(hdr) + require.NoError(t, err) + _, err = tw.Write(content) + require.NoError(t, err) + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + err = tgzFile.Close() + require.NoError(t, err) + + files, err := UntarGzip(tgzPath, destDir) + require.NoError(t, err) + assert.NotEmpty(t, files) + + data, err := os.ReadFile(filepath.Join(destDir, "test.txt")) + require.NoError(t, err) + assert.Equal(t, "tar content", string(data)) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + _, err := UntarGzip("/nonexistent.tar.gz", t.TempDir()) + assert.Error(t, err) + }) +} diff --git a/internal/cmdutil/flags_test.go b/internal/cmdutil/flags_test.go new file mode 100644 index 00000000..2189bc95 --- /dev/null +++ b/internal/cmdutil/flags_test.go @@ -0,0 +1,57 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func Test_IsFlagChanged(t *testing.T) { + tests := map[string]struct { + flag string + setFlag bool + expected bool + }{ + "returns true when flag is set": { + flag: "app-id", + setFlag: true, + expected: true, + }, + "returns false when flag exists but not set": { + flag: "app-id", + setFlag: false, + expected: false, + }, + "returns false when flag does not exist": { + flag: "nonexistent", + setFlag: false, + expected: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("app-id", "", "app ID") + if tc.setFlag && tc.flag == "app-id" { + _ = cmd.Flags().Set("app-id", "A12345") + } + result := IsFlagChanged(cmd, tc.flag) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/config/context_test.go b/internal/config/context_test.go new file mode 100644 index 00000000..66629b64 --- /dev/null +++ b/internal/config/context_test.go @@ -0,0 +1,152 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_Context_Token(t *testing.T) { + tests := map[string]struct { + token string + expected string + }{ + "set and get a token": { + token: "xoxb-test-token", + expected: "xoxb-test-token", + }, + "set and get an empty token": { + token: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := SetContextToken(context.Background(), tc.token) + assert.Equal(t, tc.expected, GetContextToken(ctx)) + }) + } + + t.Run("get from empty context returns empty string", func(t *testing.T) { + assert.Equal(t, "", GetContextToken(context.Background())) + }) +} + +func Test_Context_EnterpriseID(t *testing.T) { + tests := map[string]struct { + enterpriseID string + expected string + }{ + "set and get an enterprise ID": { + enterpriseID: "E12345", + expected: "E12345", + }, + "set and get an empty enterprise ID": { + enterpriseID: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := SetContextEnterpriseID(context.Background(), tc.enterpriseID) + assert.Equal(t, tc.expected, GetContextEnterpriseID(ctx)) + }) + } + + t.Run("get from empty context returns empty string", func(t *testing.T) { + assert.Equal(t, "", GetContextEnterpriseID(context.Background())) + }) +} + +func Test_Context_TeamID(t *testing.T) { + tests := map[string]struct { + teamID string + expected string + }{ + "set and get a team ID": { + teamID: "T12345", + expected: "T12345", + }, + "set and get an empty team ID": { + teamID: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := SetContextTeamID(context.Background(), tc.teamID) + assert.Equal(t, tc.expected, GetContextTeamID(ctx)) + }) + } + + t.Run("get from empty context returns empty string", func(t *testing.T) { + assert.Equal(t, "", GetContextTeamID(context.Background())) + }) +} + +func Test_Context_TeamDomain(t *testing.T) { + tests := map[string]struct { + teamDomain string + expected string + }{ + "set and get a team domain": { + teamDomain: "subarachnoid", + expected: "subarachnoid", + }, + "set and get an empty team domain": { + teamDomain: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := SetContextTeamDomain(context.Background(), tc.teamDomain) + assert.Equal(t, tc.expected, GetContextTeamDomain(ctx)) + }) + } + + t.Run("get from empty context returns empty string", func(t *testing.T) { + assert.Equal(t, "", GetContextTeamDomain(context.Background())) + }) +} + +func Test_Context_UserID(t *testing.T) { + tests := map[string]struct { + userID string + expected string + }{ + "set and get a user ID": { + userID: "U12345", + expected: "U12345", + }, + "set and get an empty user ID": { + userID: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := SetContextUserID(context.Background(), tc.userID) + assert.Equal(t, tc.expected, GetContextUserID(ctx)) + }) + } + + t.Run("get from empty context returns empty string", func(t *testing.T) { + assert.Equal(t, "", GetContextUserID(context.Background())) + }) +} diff --git a/internal/goutils/recursivecopy_test.go b/internal/goutils/recursivecopy_test.go new file mode 100644 index 00000000..f98f423c --- /dev/null +++ b/internal/goutils/recursivecopy_test.go @@ -0,0 +1,218 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package goutils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ShouldIgnore(t *testing.T) { + tests := map[string]struct { + val string + list []string + ignoreFunc func(string) bool + expected bool + }{ + "returns true when value is in list": { + val: "node_modules", + list: []string{"node_modules", ".git"}, + expected: true, + }, + "returns false when value is not in list": { + val: "src", + list: []string{"node_modules", ".git"}, + expected: false, + }, + "returns false with empty list and nil ignoreFunc": { + val: "anything", + list: []string{}, + expected: false, + }, + "returns false with nil list and nil ignoreFunc": { + val: "anything", + list: nil, + expected: false, + }, + "returns true when ignoreFunc returns true": { + val: "secret.txt", + list: []string{}, + ignoreFunc: func(s string) bool { + return s == "secret.txt" + }, + expected: true, + }, + "returns false when ignoreFunc returns false": { + val: "README.md", + list: []string{}, + ignoreFunc: func(s string) bool { + return s == "secret.txt" + }, + expected: false, + }, + "list match takes priority over ignoreFunc": { + val: "node_modules", + list: []string{"node_modules"}, + ignoreFunc: func(s string) bool { + return false + }, + expected: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := ShouldIgnore(tc.val, tc.list, tc.ignoreFunc) + assert.Equal(t, tc.expected, result) + }) + } +} + +// TODO: Refactor to use afero.Fs once Copy accepts it. Currently uses t.TempDir() which is safe. +func Test_Copy(t *testing.T) { + t.Run("copies a regular file", func(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + + srcFile := filepath.Join(srcDir, "test.txt") + dstFile := filepath.Join(dstDir, "test.txt") + + err := os.WriteFile(srcFile, []byte("hello world"), 0644) + require.NoError(t, err) + + err = Copy(srcFile, dstFile) + require.NoError(t, err) + + content, err := os.ReadFile(dstFile) + require.NoError(t, err) + assert.Equal(t, "hello world", string(content)) + }) + + t.Run("returns error for non-existent source", func(t *testing.T) { + dstDir := t.TempDir() + err := Copy("/nonexistent/file.txt", filepath.Join(dstDir, "file.txt")) + assert.Error(t, err) + }) +} + +// TODO: Refactor to use afero.Fs once CopySymLink accepts it. Currently uses t.TempDir() which is safe. +func Test_CopySymLink(t *testing.T) { + t.Run("copies a symbolic link", func(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + + // Create a target file and a symlink to it + targetFile := filepath.Join(srcDir, "target.txt") + err := os.WriteFile(targetFile, []byte("target content"), 0644) + require.NoError(t, err) + + srcLink := filepath.Join(srcDir, "link.txt") + err = os.Symlink(targetFile, srcLink) + require.NoError(t, err) + + dstLink := filepath.Join(dstDir, "link.txt") + err = CopySymLink(srcLink, dstLink) + require.NoError(t, err) + + // Verify the symlink target is the same + linkTarget, err := os.Readlink(dstLink) + require.NoError(t, err) + assert.Equal(t, targetFile, linkTarget) + }) +} + +// TODO: Refactor to use afero.Fs once CopyDirectory accepts it. Currently uses t.TempDir() which is safe. +func Test_CopyDirectory(t *testing.T) { + t.Run("copies directory structure", func(t *testing.T) { + srcDir := t.TempDir() + dstDir := filepath.Join(t.TempDir(), "dst") + + // Create source structure + err := os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("file1"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte("file2"), 0644) + require.NoError(t, err) + + err = CopyDirectory(CopyDirectoryOpts{Src: srcDir, Dst: dstDir}) + require.NoError(t, err) + + // Verify files were copied + content, err := os.ReadFile(filepath.Join(dstDir, "file1.txt")) + require.NoError(t, err) + assert.Equal(t, "file1", string(content)) + + content, err = os.ReadFile(filepath.Join(dstDir, "subdir", "file2.txt")) + require.NoError(t, err) + assert.Equal(t, "file2", string(content)) + }) + + t.Run("ignores specified files", func(t *testing.T) { + srcDir := t.TempDir() + dstDir := filepath.Join(t.TempDir(), "dst") + + err := os.WriteFile(filepath.Join(srcDir, "keep.txt"), []byte("keep"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(srcDir, "ignore.txt"), []byte("ignore"), 0644) + require.NoError(t, err) + + err = CopyDirectory(CopyDirectoryOpts{ + Src: srcDir, + Dst: dstDir, + IgnoreFiles: []string{"ignore.txt"}, + }) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(dstDir, "keep.txt")) + assert.NoFileExists(t, filepath.Join(dstDir, "ignore.txt")) + }) + + t.Run("ignores specified directories", func(t *testing.T) { + srcDir := t.TempDir() + dstDir := filepath.Join(t.TempDir(), "dst") + + err := os.MkdirAll(filepath.Join(srcDir, "keep_dir"), 0755) + require.NoError(t, err) + err = os.MkdirAll(filepath.Join(srcDir, "node_modules"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(srcDir, "keep_dir", "file.txt"), []byte("keep"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(srcDir, "node_modules", "file.txt"), []byte("ignore"), 0644) + require.NoError(t, err) + + err = CopyDirectory(CopyDirectoryOpts{ + Src: srcDir, + Dst: dstDir, + IgnoreDirectories: []string{"node_modules"}, + }) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(dstDir, "keep_dir", "file.txt")) + assert.NoDirExists(t, filepath.Join(dstDir, "node_modules")) + }) + + t.Run("returns error for non-existent source", func(t *testing.T) { + dstDir := filepath.Join(t.TempDir(), "dst") + err := CopyDirectory(CopyDirectoryOpts{ + Src: "/nonexistent/path", + Dst: dstDir, + }) + assert.Error(t, err) + }) +} diff --git a/internal/image/image_test.go b/internal/image/image_test.go new file mode 100644 index 00000000..10712e91 --- /dev/null +++ b/internal/image/image_test.go @@ -0,0 +1,138 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package image + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" + + "github.com/slackapi/slack-cli/internal/slackdeps" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestPNG(t *testing.T, width, height int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for x := range width { + for y := range height { + img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + var buf bytes.Buffer + err := png.Encode(&buf, img) + require.NoError(t, err) + return buf.Bytes() +} + +func Test_ResizeImage(t *testing.T) { + pngData := createTestPNG(t, 100, 100) + reader := bytes.NewReader(pngData) + + resized, err := ResizeImage(reader, 50, 50) + require.NoError(t, err) + assert.NotNil(t, resized) + assert.Equal(t, 50, resized.Bounds().Dx()) + assert.Equal(t, 50, resized.Bounds().Dy()) +} + +func Test_ResizeImageToBytes(t *testing.T) { + pngData := createTestPNG(t, 100, 100) + reader := bytes.NewReader(pngData) + + result, err := ResizeImageToBytes(reader, 50, 50) + require.NoError(t, err) + assert.NotEmpty(t, result) +} + +func Test_ResizeImageFromFileToBytes(t *testing.T) { + pngData := createTestPNG(t, 100, 100) + fs := slackdeps.NewFsMock() + err := afero.WriteFile(fs, "/test.png", pngData, 0644) + require.NoError(t, err) + + result, err := ResizeImageFromFileToBytes(fs, "/test.png", 50, 50) + require.NoError(t, err) + assert.NotEmpty(t, result) +} + +func Test_ResizeImageFromFileToBytes_FileNotFound(t *testing.T) { + fs := slackdeps.NewFsMock() + _, err := ResizeImageFromFileToBytes(fs, "/nonexistent.png", 50, 50) + assert.Error(t, err) +} + +func Test_CropResizeImageRatio(t *testing.T) { + pngData := createTestPNG(t, 200, 100) + reader := bytes.NewReader(pngData) + + result, err := CropResizeImageRatio(reader, 100, 1, 1) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 100, result.Bounds().Dx()) +} + +func Test_CropResizeImageRatioToBytes(t *testing.T) { + pngData := createTestPNG(t, 200, 100) + reader := bytes.NewReader(pngData) + + result, err := CropResizeImageRatioToBytes(reader, 100, 1, 1) + require.NoError(t, err) + assert.NotEmpty(t, result) +} + +func Test_CropResizeImageRatioFromFile(t *testing.T) { + pngData := createTestPNG(t, 200, 100) + fs := slackdeps.NewFsMock() + err := afero.WriteFile(fs, "/test.png", pngData, 0644) + require.NoError(t, err) + + result, err := CropResizeImageRatioFromFile(fs, "/test.png", 100, 1, 1) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func Test_CropResizeImageRatioFromFile_FileNotFound(t *testing.T) { + fs := slackdeps.NewFsMock() + _, err := CropResizeImageRatioFromFile(fs, "/nonexistent.png", 100, 1, 1) + assert.Error(t, err) +} + +func Test_CropResizeImageRatioFromFileToBytes(t *testing.T) { + pngData := createTestPNG(t, 200, 100) + fs := slackdeps.NewFsMock() + err := afero.WriteFile(fs, "/test.png", pngData, 0644) + require.NoError(t, err) + + result, err := CropResizeImageRatioFromFileToBytes(fs, "/test.png", 100, 1, 1) + require.NoError(t, err) + assert.NotEmpty(t, result) +} + +func Test_CropResizeImageRatioFromFileToBytes_FileNotFound(t *testing.T) { + fs := slackdeps.NewFsMock() + _, err := CropResizeImageRatioFromFileToBytes(fs, "/nonexistent.png", 100, 1, 1) + assert.Error(t, err) +} + +func Test_ResizeImage_InvalidReader(t *testing.T) { + reader := bytes.NewReader([]byte("not a valid image")) + _, err := ResizeImage(reader, 50, 50) + assert.Error(t, err) +} diff --git a/internal/iostreams/reader_test.go b/internal/iostreams/reader_test.go new file mode 100644 index 00000000..3295f4d0 --- /dev/null +++ b/internal/iostreams/reader_test.go @@ -0,0 +1,55 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iostreams + +import ( + "strings" + "testing" + + "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/slackdeps" + "github.com/stretchr/testify/assert" +) + +func Test_ReadIn(t *testing.T) { + tests := map[string]struct { + input string + expected string + }{ + "returns the configured stdin reader": { + input: "test input", + expected: "test input", + }, + "returns an empty reader": { + input: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fsMock := slackdeps.NewFsMock() + osMock := slackdeps.NewOsMock() + cfg := config.NewConfig(fsMock, osMock) + io := NewIOStreams(cfg, fsMock, osMock) + io.Stdin = strings.NewReader(tc.input) + + reader := io.ReadIn() + + buf := make([]byte, len(tc.input)+1) + n, _ := reader.Read(buf) + assert.Equal(t, tc.expected, string(buf[:n])) + }) + } +} diff --git a/internal/ioutils/host_test.go b/internal/ioutils/host_test.go new file mode 100644 index 00000000..0c7c3dc7 --- /dev/null +++ b/internal/ioutils/host_test.go @@ -0,0 +1,37 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ioutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_GetHostname(t *testing.T) { + t.Run("returns a non-empty hashed hostname", func(t *testing.T) { + hostname := GetHostname() + assert.NotEmpty(t, hostname) + // The hostname should be hashed, not the raw hostname + // It should not be "unknown" on a normal system + assert.NotEqual(t, "unknown", hostname) + }) + + t.Run("returns consistent results", func(t *testing.T) { + hostname1 := GetHostname() + hostname2 := GetHostname() + assert.Equal(t, hostname1, hostname2) + }) +} diff --git a/internal/prompts/permissions_test.go b/internal/prompts/permissions_test.go new file mode 100644 index 00000000..c64395c9 --- /dev/null +++ b/internal/prompts/permissions_test.go @@ -0,0 +1,80 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prompts + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/stretchr/testify/assert" +) + +func Test_AccessLabels(t *testing.T) { + tests := map[string]struct { + current types.Permission + expectedCurrent string + }{ + "app_collaborators as current": { + current: types.PermissionAppCollaborators, + expectedCurrent: "app collaborators only (current)", + }, + "everyone as current": { + current: types.PermissionEveryone, + expectedCurrent: "everyone (current)", + }, + "named_entities as current": { + current: types.PermissionNamedEntities, + expectedCurrent: "specific users (current)", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + labels, distributions := AccessLabels(tc.current) + assert.Len(t, labels, 3) + assert.Len(t, distributions, 3) + assert.Equal(t, tc.expectedCurrent, labels[0]) + assert.Equal(t, tc.current, distributions[0]) + }) + } +} + +func Test_TriggerAccessLabels(t *testing.T) { + tests := map[string]struct { + current types.Permission + expectedCurrent string + }{ + "app_collaborators as current": { + current: types.PermissionAppCollaborators, + expectedCurrent: "app collaborators only (current)", + }, + "everyone as current": { + current: types.PermissionEveryone, + expectedCurrent: "everyone (current)", + }, + "named_entities as current": { + current: types.PermissionNamedEntities, + expectedCurrent: "specific entities (current)", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + labels, distributions := TriggerAccessLabels(tc.current) + assert.Len(t, labels, 3) + assert.Len(t, distributions, 3) + assert.Equal(t, tc.expectedCurrent, labels[0]) + assert.Equal(t, tc.current, distributions[0]) + }) + } +}