This is page 3 of 5. Use http://codebase.md/portainer/portainer-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ └── integration-test.mdc
├── .github
│ └── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CLAUDE.md
├── cloc.sh
├── cmd
│ ├── portainer-mcp
│ │ └── mcp.go
│ └── token-count
│ └── token.go
├── docs
│ ├── clients_and_models.md
│ ├── design
│ │ ├── 202503-1-external-tools-file.md
│ │ ├── 202503-2-tools-vs-mcp-resources.md
│ │ ├── 202503-3-specific-update-tools.md
│ │ ├── 202504-1-embedded-tools-yaml.md
│ │ ├── 202504-2-tools-yaml-versioning.md
│ │ ├── 202504-3-portainer-version-compatibility.md
│ │ └── 202504-4-read-only-mode.md
│ └── design_summary.md
├── go.mod
├── go.sum
├── internal
│ ├── k8sutil
│ │ ├── stripper_test.go
│ │ └── stripper.go
│ ├── mcp
│ │ ├── access_group_test.go
│ │ ├── access_group.go
│ │ ├── docker_test.go
│ │ ├── docker.go
│ │ ├── environment_test.go
│ │ ├── environment.go
│ │ ├── group_test.go
│ │ ├── group.go
│ │ ├── kubernetes_test.go
│ │ ├── kubernetes.go
│ │ ├── mocks_test.go
│ │ ├── schema_test.go
│ │ ├── schema.go
│ │ ├── server_test.go
│ │ ├── server.go
│ │ ├── settings_test.go
│ │ ├── settings.go
│ │ ├── stack_test.go
│ │ ├── stack.go
│ │ ├── tag_test.go
│ │ ├── tag.go
│ │ ├── team_test.go
│ │ ├── team.go
│ │ ├── testdata
│ │ │ ├── invalid_tools.yaml
│ │ │ └── valid_tools.yaml
│ │ ├── user_test.go
│ │ ├── user.go
│ │ ├── utils_test.go
│ │ └── utils.go
│ └── tooldef
│ ├── tooldef_test.go
│ ├── tooldef.go
│ └── tools.yaml
├── LICENSE
├── Makefile
├── pkg
│ ├── portainer
│ │ ├── client
│ │ │ ├── access_group_test.go
│ │ │ ├── access_group.go
│ │ │ ├── client_test.go
│ │ │ ├── client.go
│ │ │ ├── docker_test.go
│ │ │ ├── docker.go
│ │ │ ├── environment_test.go
│ │ │ ├── environment.go
│ │ │ ├── group_test.go
│ │ │ ├── group.go
│ │ │ ├── kubernetes_test.go
│ │ │ ├── kubernetes.go
│ │ │ ├── mocks_test.go
│ │ │ ├── settings_test.go
│ │ │ ├── settings.go
│ │ │ ├── stack_test.go
│ │ │ ├── stack.go
│ │ │ ├── tag_test.go
│ │ │ ├── tag.go
│ │ │ ├── team_test.go
│ │ │ ├── team.go
│ │ │ ├── user_test.go
│ │ │ ├── user.go
│ │ │ ├── version_test.go
│ │ │ └── version.go
│ │ ├── models
│ │ │ ├── access_group_test.go
│ │ │ ├── access_group.go
│ │ │ ├── access_policy_test.go
│ │ │ ├── access_policy.go
│ │ │ ├── docker.go
│ │ │ ├── environment_test.go
│ │ │ ├── environment.go
│ │ │ ├── group_test.go
│ │ │ ├── group.go
│ │ │ ├── kubernetes.go
│ │ │ ├── settings_test.go
│ │ │ ├── settings.go
│ │ │ ├── stack_test.go
│ │ │ ├── stack.go
│ │ │ ├── tag_test.go
│ │ │ ├── tag.go
│ │ │ ├── team_test.go
│ │ │ ├── team.go
│ │ │ ├── user_test.go
│ │ │ └── user.go
│ │ └── utils
│ │ ├── utils_test.go
│ │ └── utils.go
│ └── toolgen
│ ├── param_test.go
│ ├── param.go
│ ├── yaml_test.go
│ └── yaml.go
├── README.md
├── tests
│ └── integration
│ ├── access_group_test.go
│ ├── containers
│ │ └── portainer.go
│ ├── docker_test.go
│ ├── environment_test.go
│ ├── group_test.go
│ ├── helpers
│ │ └── test_env.go
│ ├── server_test.go
│ ├── settings_test.go
│ ├── stack_test.go
│ ├── tag_test.go
│ ├── team_test.go
│ └── user_test.go
└── token.sh
```
# Files
--------------------------------------------------------------------------------
/pkg/portainer/client/environment_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/mock"
11 | )
12 |
13 | func TestGetEnvironments(t *testing.T) {
14 | tests := []struct {
15 | name string
16 | mockEndpoints []*apimodels.PortainereeEndpoint
17 | mockError error
18 | expected []models.Environment
19 | expectedError bool
20 | }{
21 | {
22 | name: "successful retrieval",
23 | mockEndpoints: []*apimodels.PortainereeEndpoint{
24 | {
25 | ID: 1,
26 | Name: "env1",
27 | GroupID: 1,
28 | Status: 1, // active
29 | Type: 1, // docker-local
30 | TagIds: []int64{1, 2},
31 | UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
32 | "1": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
33 | "2": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
34 | "3": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
35 | "4": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
36 | "5": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
37 | },
38 | TeamAccessPolicies: apimodels.PortainerTeamAccessPolicies{
39 | "6": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
40 | "7": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
41 | "8": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
42 | "9": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
43 | "10": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
44 | },
45 | },
46 | {
47 | ID: 2,
48 | Name: "env2",
49 | GroupID: 1,
50 | Status: 2, // inactive
51 | Type: 2, // docker-agent
52 | TagIds: []int64{3},
53 | },
54 | {
55 | ID: 3,
56 | Name: "env3",
57 | Status: 0, // unknown
58 | Type: 0, // unknown
59 | },
60 | },
61 | expected: []models.Environment{
62 | {
63 | ID: 1,
64 | Name: "env1",
65 | Status: "active",
66 | Type: "docker-local",
67 | TagIds: []int{1, 2},
68 | UserAccesses: map[int]string{
69 | 1: "environment_administrator",
70 | 2: "helpdesk_user",
71 | 3: "standard_user",
72 | 4: "readonly_user",
73 | 5: "operator_user",
74 | },
75 | TeamAccesses: map[int]string{
76 | 6: "environment_administrator",
77 | 7: "helpdesk_user",
78 | 8: "standard_user",
79 | 9: "readonly_user",
80 | 10: "operator_user",
81 | },
82 | },
83 | {
84 | ID: 2,
85 | Name: "env2",
86 | Status: "inactive",
87 | Type: "docker-agent",
88 | TagIds: []int{3},
89 | UserAccesses: map[int]string{},
90 | TeamAccesses: map[int]string{},
91 | },
92 | {
93 | ID: 3,
94 | Name: "env3",
95 | Status: "unknown",
96 | Type: "unknown",
97 | TagIds: []int{},
98 | UserAccesses: map[int]string{},
99 | TeamAccesses: map[int]string{},
100 | },
101 | },
102 | },
103 | {
104 | name: "empty environments",
105 | mockEndpoints: []*apimodels.PortainereeEndpoint{},
106 | expected: []models.Environment{},
107 | },
108 | {
109 | name: "list error",
110 | mockError: errors.New("failed to list endpoints"),
111 | expectedError: true,
112 | },
113 | }
114 |
115 | for _, tt := range tests {
116 | t.Run(tt.name, func(t *testing.T) {
117 | mockAPI := new(MockPortainerAPI)
118 | mockAPI.On("ListEndpoints").Return(tt.mockEndpoints, tt.mockError)
119 |
120 | client := &PortainerClient{cli: mockAPI}
121 |
122 | environments, err := client.GetEnvironments()
123 |
124 | if tt.expectedError {
125 | assert.Error(t, err)
126 | return
127 | }
128 | assert.NoError(t, err)
129 | assert.Equal(t, tt.expected, environments)
130 | mockAPI.AssertExpectations(t)
131 | })
132 | }
133 | }
134 |
135 | func TestUpdateEnvironmentTags(t *testing.T) {
136 | tests := []struct {
137 | name string
138 | envID int
139 | tagIds []int
140 | mockError error
141 | expectedError bool
142 | }{
143 | {
144 | name: "successful update",
145 | envID: 1,
146 | tagIds: []int{1, 2, 3},
147 | },
148 | {
149 | name: "update error",
150 | envID: 1,
151 | tagIds: []int{1},
152 | mockError: errors.New("failed to update tags"),
153 | expectedError: true,
154 | },
155 | {
156 | name: "empty tags",
157 | envID: 1,
158 | tagIds: []int{},
159 | },
160 | }
161 |
162 | for _, tt := range tests {
163 | t.Run(tt.name, func(t *testing.T) {
164 | mockAPI := new(MockPortainerAPI)
165 | mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
166 |
167 | client := &PortainerClient{cli: mockAPI}
168 |
169 | err := client.UpdateEnvironmentTags(tt.envID, tt.tagIds)
170 |
171 | if tt.expectedError {
172 | assert.Error(t, err)
173 | return
174 | }
175 | assert.NoError(t, err)
176 | mockAPI.AssertExpectations(t)
177 | })
178 | }
179 | }
180 |
181 | func TestUpdateEnvironmentUserAccesses(t *testing.T) {
182 | tests := []struct {
183 | name string
184 | envID int
185 | userAccesses map[int]string
186 | mockError error
187 | expectedError bool
188 | }{
189 | {
190 | name: "successful update",
191 | envID: 1,
192 | userAccesses: map[int]string{
193 | 1: "environment_administrator",
194 | 2: "helpdesk_user",
195 | 3: "standard_user",
196 | 4: "readonly_user",
197 | 5: "operator_user",
198 | },
199 | },
200 | {
201 | name: "update error",
202 | envID: 1,
203 | userAccesses: map[int]string{
204 | 1: "environment_administrator",
205 | },
206 | mockError: errors.New("failed to update user accesses"),
207 | expectedError: true,
208 | },
209 | {
210 | name: "empty accesses",
211 | envID: 1,
212 | userAccesses: map[int]string{},
213 | },
214 | }
215 |
216 | for _, tt := range tests {
217 | t.Run(tt.name, func(t *testing.T) {
218 | mockAPI := new(MockPortainerAPI)
219 | mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
220 |
221 | client := &PortainerClient{cli: mockAPI}
222 |
223 | err := client.UpdateEnvironmentUserAccesses(tt.envID, tt.userAccesses)
224 |
225 | if tt.expectedError {
226 | assert.Error(t, err)
227 | return
228 | }
229 | assert.NoError(t, err)
230 | mockAPI.AssertExpectations(t)
231 | })
232 | }
233 | }
234 |
235 | func TestUpdateEnvironmentTeamAccesses(t *testing.T) {
236 | tests := []struct {
237 | name string
238 | envID int
239 | teamAccesses map[int]string
240 | mockError error
241 | expectedError bool
242 | }{
243 | {
244 | name: "successful update",
245 | envID: 1,
246 | teamAccesses: map[int]string{
247 | 1: "environment_administrator",
248 | 2: "helpdesk_user",
249 | 3: "standard_user",
250 | 4: "readonly_user",
251 | 5: "operator_user",
252 | },
253 | },
254 | {
255 | name: "update error",
256 | envID: 1,
257 | teamAccesses: map[int]string{
258 | 1: "environment_administrator",
259 | },
260 | mockError: errors.New("failed to update team accesses"),
261 | expectedError: true,
262 | },
263 | {
264 | name: "empty accesses",
265 | envID: 1,
266 | teamAccesses: map[int]string{},
267 | },
268 | }
269 |
270 | for _, tt := range tests {
271 | t.Run(tt.name, func(t *testing.T) {
272 | mockAPI := new(MockPortainerAPI)
273 | mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
274 |
275 | client := &PortainerClient{cli: mockAPI}
276 |
277 | err := client.UpdateEnvironmentTeamAccesses(tt.envID, tt.teamAccesses)
278 |
279 | if tt.expectedError {
280 | assert.Error(t, err)
281 | return
282 | }
283 | assert.NoError(t, err)
284 | mockAPI.AssertExpectations(t)
285 | })
286 | }
287 | }
288 |
```
--------------------------------------------------------------------------------
/tests/integration/team_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
8 | "github.com/portainer/portainer-mcp/internal/mcp"
9 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
11 |
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | const (
17 | testTeamName = "test-mcp-team"
18 | testTeamNewName = "test-mcp-team-updated"
19 | testUser1Name = "test-team-user1"
20 | testUser2Name = "test-team-user2"
21 | testTeamUserPassword = "testpassword"
22 | teamUserRoleStandard = 2 // Portainer API role ID for Standard User
23 | )
24 |
25 | // prepareTeamManagementTestEnvironment creates test users that can be assigned to teams
26 | func prepareTeamManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int) {
27 | testUser1ID, err := env.RawClient.CreateUser(testUser1Name, testTeamUserPassword, teamUserRoleStandard)
28 | require.NoError(t, err, "Failed to create first test user via raw client")
29 |
30 | testUser2ID, err := env.RawClient.CreateUser(testUser2Name, testTeamUserPassword, teamUserRoleStandard)
31 | require.NoError(t, err, "Failed to create second test user via raw client")
32 |
33 | return int(testUser1ID), int(testUser2ID)
34 | }
35 |
36 | // TestTeamManagement is an integration test suite that verifies the complete
37 | // lifecycle of team management in Portainer MCP. It tests team creation,
38 | // listing, name updates, and member management.
39 | func TestTeamManagement(t *testing.T) {
40 | env := helpers.NewTestEnv(t)
41 | defer env.Cleanup(t)
42 |
43 | // Prepare the test environment
44 | testUser1ID, testUser2ID := prepareTeamManagementTestEnvironment(t, env)
45 |
46 | var testTeamID int
47 |
48 | // Subtest: Team Creation
49 | // Verifies that:
50 | // - A new team can be created via the MCP handler.
51 | // - The handler response indicates success with an ID.
52 | // - The created team exists in Portainer when checked directly via the Raw Client.
53 | t.Run("Team Creation", func(t *testing.T) {
54 | handler := env.MCPServer.HandleCreateTeam()
55 | request := mcp.CreateMCPRequest(map[string]any{
56 | "name": testTeamName,
57 | })
58 |
59 | result, err := handler(env.Ctx, request)
60 | require.NoError(t, err, "Failed to create team via MCP handler")
61 |
62 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
63 | require.True(t, ok, "Expected text content in MCP response")
64 |
65 | // Check for success message and extract ID for later tests
66 | assert.Contains(t, textContent.Text, "Team created successfully with ID:", "Success message prefix mismatch")
67 |
68 | // Verify by fetching teams directly via client and finding the created team by name
69 | team, err := env.RawClient.GetTeamByName(testTeamName)
70 | require.NoError(t, err, "Failed to get team directly via client after creation")
71 | assert.Equal(t, testTeamName, team.Name, "Team name mismatch")
72 |
73 | // Extract team ID for subsequent tests
74 | testTeamID = int(team.ID)
75 | })
76 |
77 | // Subtest: Team Listing
78 | // Verifies that:
79 | // - The team list can be retrieved via the MCP handler
80 | // - The list contains the expected number of teams (one, the test team)
81 | // - The team has the correct name property
82 | // - The team data matches the team obtained directly via Raw Client when converted to the same model
83 | t.Run("Team Listing", func(t *testing.T) {
84 | handler := env.MCPServer.HandleGetTeams()
85 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
86 | require.NoError(t, err, "Failed to get teams via MCP handler")
87 |
88 | assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
89 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
90 | assert.True(t, ok, "Expected text content in MCP response")
91 |
92 | var retrievedTeams []models.Team
93 | err = json.Unmarshal([]byte(textContent.Text), &retrievedTeams)
94 | require.NoError(t, err, "Failed to unmarshal retrieved teams")
95 | require.Len(t, retrievedTeams, 1, "Expected exactly one team after unmarshalling")
96 |
97 | team := retrievedTeams[0]
98 | assert.Equal(t, testTeamName, team.Name, "Team name mismatch")
99 |
100 | // Fetch the same team directly via the client
101 | rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
102 | require.NoError(t, err, "Failed to get team directly via client")
103 |
104 | // Convert the raw team to the expected Team model
105 | rawMemberships, err := env.RawClient.ListTeamMemberships()
106 | require.NoError(t, err, "Failed to get team memberships directly via client")
107 | expectedTeam := models.ConvertToTeam(rawTeam, rawMemberships)
108 | assert.Equal(t, expectedTeam, team, "Team mismatch between MCP handler and direct client call")
109 | })
110 |
111 | // Subtest: Team Name Update
112 | // Verifies that:
113 | // - A team's name can be updated via the MCP handler
114 | // - The handler response indicates success
115 | // - The team name is actually updated when checked directly via Raw Client
116 | t.Run("Team Name Update", func(t *testing.T) {
117 | handler := env.MCPServer.HandleUpdateTeamName()
118 | request := mcp.CreateMCPRequest(map[string]any{
119 | "id": float64(testTeamID),
120 | "name": testTeamNewName,
121 | })
122 |
123 | result, err := handler(env.Ctx, request)
124 | require.NoError(t, err, "Failed to update team name via MCP handler")
125 |
126 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
127 | require.True(t, ok, "Expected text content in MCP response for team name update")
128 | assert.Contains(t, textContent.Text, "Team name updated successfully", "Success message mismatch for team name update")
129 |
130 | // Verify by fetching team directly via raw client
131 | rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
132 | require.NoError(t, err, "Failed to get team directly via client after name update")
133 | assert.Equal(t, testTeamNewName, rawTeam.Name, "Team name was not updated")
134 | })
135 |
136 | // Subtest: Team Members Update
137 | // Verifies that:
138 | // - Team members can be updated via the MCP handler
139 | // - The handler response indicates success
140 | // - The team memberships are correctly updated when checked directly via Raw Client
141 | // - Both test users are properly assigned to the team
142 | t.Run("Team Members Update", func(t *testing.T) {
143 | handler := env.MCPServer.HandleUpdateTeamMembers()
144 | request := mcp.CreateMCPRequest(map[string]any{
145 | "id": float64(testTeamID),
146 | "userIds": []any{float64(testUser1ID), float64(testUser2ID)},
147 | })
148 |
149 | result, err := handler(env.Ctx, request)
150 | require.NoError(t, err, "Failed to update team members via MCP handler")
151 |
152 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
153 | require.True(t, ok, "Expected text content in MCP response for team members update")
154 | assert.Contains(t, textContent.Text, "Team members updated successfully", "Success message mismatch for team members update")
155 |
156 | // Verify by fetching team directly via raw client
157 | rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
158 | require.NoError(t, err, "Failed to get team directly via client after member update")
159 | rawMemberships, err := env.RawClient.ListTeamMemberships()
160 | require.NoError(t, err, "Failed to get team memberships directly via client")
161 | expectedTeam := models.ConvertToTeam(rawTeam, rawMemberships)
162 | assert.ElementsMatch(t, []int{testUser1ID, testUser2ID}, expectedTeam.MemberIDs, "Team memberships mismatch")
163 | })
164 | }
165 |
```
--------------------------------------------------------------------------------
/internal/mcp/mocks_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
7 | "github.com/stretchr/testify/mock"
8 | )
9 |
10 | // Mock Implementation Patterns:
11 | //
12 | // This file contains mock implementations of the PortainerClient interface.
13 | // The following patterns are used throughout the mocks:
14 | //
15 | // 1. Methods returning (T, error):
16 | // - Uses m.Called() to record the method call and get mock behavior
17 | // - Includes nil check on first return value to avoid type assertion panics
18 | // - Example:
19 | // func (m *Mock) Method() (T, error) {
20 | // args := m.Called()
21 | // if args.Get(0) == nil {
22 | // return nil, args.Error(1)
23 | // }
24 | // return args.Get(0).(T), args.Error(1)
25 | // }
26 | //
27 | // 2. Methods returning only error:
28 | // - Uses m.Called() with any parameters
29 | // - Returns only the error value
30 | // - Example:
31 | // func (m *Mock) Method(param string) error {
32 | // args := m.Called(param)
33 | // return args.Error(0)
34 | // }
35 | //
36 | // Usage in Tests:
37 | // mock := new(MockPortainerClient)
38 | // mock.On("MethodName").Return(expectedValue, nil)
39 | // result, err := mock.MethodName()
40 | // mock.AssertExpectations(t)
41 |
42 | // MockPortainerClient is a mock implementation of the PortainerClient interface
43 | type MockPortainerClient struct {
44 | mock.Mock
45 | }
46 |
47 | // Tag methods
48 |
49 | func (m *MockPortainerClient) GetEnvironmentTags() ([]models.EnvironmentTag, error) {
50 | args := m.Called()
51 | if args.Get(0) == nil {
52 | return nil, args.Error(1)
53 | }
54 | return args.Get(0).([]models.EnvironmentTag), args.Error(1)
55 | }
56 |
57 | func (m *MockPortainerClient) CreateEnvironmentTag(name string) (int, error) {
58 | args := m.Called(name)
59 | return args.Int(0), args.Error(1)
60 | }
61 |
62 | // Environment methods
63 |
64 | func (m *MockPortainerClient) GetEnvironments() ([]models.Environment, error) {
65 | args := m.Called()
66 | if args.Get(0) == nil {
67 | return nil, args.Error(1)
68 | }
69 | return args.Get(0).([]models.Environment), args.Error(1)
70 | }
71 |
72 | func (m *MockPortainerClient) UpdateEnvironmentTags(id int, tagIds []int) error {
73 | args := m.Called(id, tagIds)
74 | return args.Error(0)
75 | }
76 |
77 | func (m *MockPortainerClient) UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error {
78 | args := m.Called(id, userAccesses)
79 | return args.Error(0)
80 | }
81 |
82 | func (m *MockPortainerClient) UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error {
83 | args := m.Called(id, teamAccesses)
84 | return args.Error(0)
85 | }
86 |
87 | // Environment Group methods
88 |
89 | func (m *MockPortainerClient) GetEnvironmentGroups() ([]models.Group, error) {
90 | args := m.Called()
91 | if args.Get(0) == nil {
92 | return nil, args.Error(1)
93 | }
94 | return args.Get(0).([]models.Group), args.Error(1)
95 | }
96 |
97 | func (m *MockPortainerClient) CreateEnvironmentGroup(name string, environmentIds []int) (int, error) {
98 | args := m.Called(name, environmentIds)
99 | return args.Int(0), args.Error(1)
100 | }
101 |
102 | func (m *MockPortainerClient) UpdateEnvironmentGroupName(id int, name string) error {
103 | args := m.Called(id, name)
104 | return args.Error(0)
105 | }
106 |
107 | func (m *MockPortainerClient) UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error {
108 | args := m.Called(id, environmentIds)
109 | return args.Error(0)
110 | }
111 |
112 | func (m *MockPortainerClient) UpdateEnvironmentGroupTags(id int, tagIds []int) error {
113 | args := m.Called(id, tagIds)
114 | return args.Error(0)
115 | }
116 |
117 | // Access Group methods
118 |
119 | func (m *MockPortainerClient) GetAccessGroups() ([]models.AccessGroup, error) {
120 | args := m.Called()
121 | if args.Get(0) == nil {
122 | return nil, args.Error(1)
123 | }
124 | return args.Get(0).([]models.AccessGroup), args.Error(1)
125 | }
126 |
127 | func (m *MockPortainerClient) CreateAccessGroup(name string, environmentIds []int) (int, error) {
128 | args := m.Called(name, environmentIds)
129 | return args.Int(0), args.Error(1)
130 | }
131 |
132 | func (m *MockPortainerClient) UpdateAccessGroupName(id int, name string) error {
133 | args := m.Called(id, name)
134 | return args.Error(0)
135 | }
136 |
137 | func (m *MockPortainerClient) UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error {
138 | args := m.Called(id, userAccesses)
139 | return args.Error(0)
140 | }
141 |
142 | func (m *MockPortainerClient) UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error {
143 | args := m.Called(id, teamAccesses)
144 | return args.Error(0)
145 | }
146 |
147 | func (m *MockPortainerClient) AddEnvironmentToAccessGroup(id int, environmentId int) error {
148 | args := m.Called(id, environmentId)
149 | return args.Error(0)
150 | }
151 |
152 | func (m *MockPortainerClient) RemoveEnvironmentFromAccessGroup(id int, environmentId int) error {
153 | args := m.Called(id, environmentId)
154 | return args.Error(0)
155 | }
156 |
157 | // Stack methods
158 |
159 | func (m *MockPortainerClient) GetStacks() ([]models.Stack, error) {
160 | args := m.Called()
161 | if args.Get(0) == nil {
162 | return nil, args.Error(1)
163 | }
164 | return args.Get(0).([]models.Stack), args.Error(1)
165 | }
166 |
167 | func (m *MockPortainerClient) GetStackFile(id int) (string, error) {
168 | args := m.Called(id)
169 | return args.String(0), args.Error(1)
170 | }
171 |
172 | func (m *MockPortainerClient) CreateStack(name string, file string, environmentGroupIds []int) (int, error) {
173 | args := m.Called(name, file, environmentGroupIds)
174 | return args.Int(0), args.Error(1)
175 | }
176 |
177 | func (m *MockPortainerClient) UpdateStack(id int, file string, environmentGroupIds []int) error {
178 | args := m.Called(id, file, environmentGroupIds)
179 | return args.Error(0)
180 | }
181 |
182 | // Team methods
183 |
184 | func (m *MockPortainerClient) CreateTeam(name string) (int, error) {
185 | args := m.Called(name)
186 | return args.Int(0), args.Error(1)
187 | }
188 |
189 | func (m *MockPortainerClient) GetTeams() ([]models.Team, error) {
190 | args := m.Called()
191 | if args.Get(0) == nil {
192 | return nil, args.Error(1)
193 | }
194 | return args.Get(0).([]models.Team), args.Error(1)
195 | }
196 |
197 | func (m *MockPortainerClient) UpdateTeamName(id int, name string) error {
198 | args := m.Called(id, name)
199 | return args.Error(0)
200 | }
201 |
202 | func (m *MockPortainerClient) UpdateTeamMembers(id int, userIds []int) error {
203 | args := m.Called(id, userIds)
204 | return args.Error(0)
205 | }
206 |
207 | // User methods
208 |
209 | func (m *MockPortainerClient) GetUsers() ([]models.User, error) {
210 | args := m.Called()
211 | if args.Get(0) == nil {
212 | return nil, args.Error(1)
213 | }
214 | return args.Get(0).([]models.User), args.Error(1)
215 | }
216 |
217 | func (m *MockPortainerClient) UpdateUserRole(id int, role string) error {
218 | args := m.Called(id, role)
219 | return args.Error(0)
220 | }
221 |
222 | // Settings methods
223 |
224 | func (m *MockPortainerClient) GetSettings() (models.PortainerSettings, error) {
225 | args := m.Called()
226 | if args.Get(0) == nil {
227 | return models.PortainerSettings{}, args.Error(1)
228 | }
229 | return args.Get(0).(models.PortainerSettings), args.Error(1)
230 | }
231 |
232 | func (m *MockPortainerClient) GetVersion() (string, error) {
233 | args := m.Called()
234 | if args.Get(0) == nil {
235 | return "", args.Error(1)
236 | }
237 | return args.Get(0).(string), args.Error(1)
238 | }
239 |
240 | // Docker Proxy methods
241 | func (m *MockPortainerClient) ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error) {
242 | args := m.Called(opts)
243 | if args.Get(0) == nil {
244 | return nil, args.Error(1)
245 | }
246 | return args.Get(0).(*http.Response), args.Error(1)
247 | }
248 |
249 | // Kubernetes Proxy methods
250 | func (m *MockPortainerClient) ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error) {
251 | args := m.Called(opts)
252 | if args.Get(0) == nil {
253 | return nil, args.Error(1)
254 | }
255 | return args.Get(0).(*http.Response), args.Error(1)
256 | }
257 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/team_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestGetTeams(t *testing.T) {
13 | tests := []struct {
14 | name string
15 | mockTeams []*apimodels.PortainerTeam
16 | mockMemberships []*apimodels.PortainerTeamMembership
17 | mockTeamError error
18 | mockMemberError error
19 | expected []models.Team
20 | expectedError bool
21 | }{
22 | {
23 | name: "successful retrieval",
24 | mockTeams: []*apimodels.PortainerTeam{
25 | {
26 | ID: 1,
27 | Name: "team1",
28 | },
29 | {
30 | ID: 2,
31 | Name: "team2",
32 | },
33 | },
34 | mockMemberships: []*apimodels.PortainerTeamMembership{
35 | {
36 | ID: 1,
37 | UserID: 100,
38 | TeamID: 1,
39 | },
40 | {
41 | ID: 2,
42 | UserID: 101,
43 | TeamID: 1,
44 | },
45 | {
46 | ID: 3,
47 | UserID: 102,
48 | TeamID: 2,
49 | },
50 | },
51 | expected: []models.Team{
52 | {
53 | ID: 1,
54 | Name: "team1",
55 | MemberIDs: []int{100, 101},
56 | },
57 | {
58 | ID: 2,
59 | Name: "team2",
60 | MemberIDs: []int{102},
61 | },
62 | },
63 | },
64 | {
65 | name: "teams with empty memberships",
66 | mockTeams: []*apimodels.PortainerTeam{
67 | {
68 | ID: 1,
69 | Name: "team1",
70 | },
71 | {
72 | ID: 2,
73 | Name: "team2",
74 | },
75 | },
76 | mockMemberships: []*apimodels.PortainerTeamMembership{},
77 | expected: []models.Team{
78 | {
79 | ID: 1,
80 | Name: "team1",
81 | MemberIDs: []int{},
82 | },
83 | {
84 | ID: 2,
85 | Name: "team2",
86 | MemberIDs: []int{},
87 | },
88 | },
89 | },
90 | {
91 | name: "empty teams",
92 | mockTeams: []*apimodels.PortainerTeam{},
93 | mockMemberships: []*apimodels.PortainerTeamMembership{},
94 | expected: []models.Team{},
95 | },
96 | {
97 | name: "list teams error",
98 | mockTeamError: errors.New("failed to list teams"),
99 | expectedError: true,
100 | },
101 | {
102 | name: "list memberships error",
103 | mockTeams: []*apimodels.PortainerTeam{
104 | {
105 | ID: 1,
106 | Name: "team1",
107 | },
108 | },
109 | mockMemberError: errors.New("failed to list memberships"),
110 | expectedError: true,
111 | },
112 | }
113 |
114 | for _, tt := range tests {
115 | t.Run(tt.name, func(t *testing.T) {
116 | mockAPI := new(MockPortainerAPI)
117 | mockAPI.On("ListTeams").Return(tt.mockTeams, tt.mockTeamError)
118 | mockAPI.On("ListTeamMemberships").Return(tt.mockMemberships, tt.mockMemberError)
119 |
120 | client := &PortainerClient{cli: mockAPI}
121 |
122 | teams, err := client.GetTeams()
123 |
124 | if tt.expectedError {
125 | assert.Error(t, err)
126 | return
127 | }
128 | assert.NoError(t, err)
129 | assert.Equal(t, tt.expected, teams)
130 | mockAPI.AssertExpectations(t)
131 | })
132 | }
133 | }
134 |
135 | func TestUpdateTeamName(t *testing.T) {
136 | tests := []struct {
137 | name string
138 | teamID int
139 | teamName string
140 | mockError error
141 | expectedError bool
142 | }{
143 | {
144 | name: "successful update",
145 | teamID: 1,
146 | teamName: "new-team-name",
147 | },
148 | {
149 | name: "update error",
150 | teamID: 2,
151 | teamName: "new-team-name",
152 | mockError: errors.New("failed to update team name"),
153 | expectedError: true,
154 | },
155 | }
156 |
157 | for _, tt := range tests {
158 | t.Run(tt.name, func(t *testing.T) {
159 | mockAPI := new(MockPortainerAPI)
160 | mockAPI.On("UpdateTeamName", tt.teamID, tt.teamName).Return(tt.mockError)
161 |
162 | client := &PortainerClient{cli: mockAPI}
163 |
164 | err := client.UpdateTeamName(tt.teamID, tt.teamName)
165 |
166 | if tt.expectedError {
167 | assert.Error(t, err)
168 | return
169 | }
170 | assert.NoError(t, err)
171 | mockAPI.AssertExpectations(t)
172 | })
173 | }
174 | }
175 |
176 | func TestCreateTeam(t *testing.T) {
177 | tests := []struct {
178 | name string
179 | teamName string
180 | mockID int64
181 | mockError error
182 | expected int
183 | expectedError bool
184 | }{
185 | {
186 | name: "successful creation",
187 | teamName: "new-team",
188 | mockID: 1,
189 | expected: 1,
190 | },
191 | {
192 | name: "create error",
193 | teamName: "new-team",
194 | mockError: errors.New("failed to create team"),
195 | expectedError: true,
196 | },
197 | }
198 |
199 | for _, tt := range tests {
200 | t.Run(tt.name, func(t *testing.T) {
201 | mockAPI := new(MockPortainerAPI)
202 | mockAPI.On("CreateTeam", tt.teamName).Return(tt.mockID, tt.mockError)
203 |
204 | client := &PortainerClient{cli: mockAPI}
205 |
206 | id, err := client.CreateTeam(tt.teamName)
207 |
208 | if tt.expectedError {
209 | assert.Error(t, err)
210 | return
211 | }
212 | assert.NoError(t, err)
213 | assert.Equal(t, tt.expected, id)
214 | mockAPI.AssertExpectations(t)
215 | })
216 | }
217 | }
218 |
219 | func TestUpdateTeamMembers(t *testing.T) {
220 | tests := []struct {
221 | name string
222 | teamID int
223 | userIDs []int
224 | mockMemberships []*apimodels.PortainerTeamMembership
225 | mockListError error
226 | mockDeleteError error
227 | mockCreateError error
228 | expectedError bool
229 | }{
230 | {
231 | name: "successful update - add and remove members",
232 | teamID: 1,
233 | userIDs: []int{101, 102}, // Want to keep 101 and add 102
234 | mockMemberships: []*apimodels.PortainerTeamMembership{
235 | {
236 | ID: 1,
237 | UserID: 100, // Should be removed
238 | TeamID: 1,
239 | },
240 | {
241 | ID: 2,
242 | UserID: 101, // Should be kept
243 | TeamID: 1,
244 | },
245 | },
246 | },
247 | {
248 | name: "successful update - no changes needed",
249 | teamID: 1,
250 | userIDs: []int{100, 101},
251 | mockMemberships: []*apimodels.PortainerTeamMembership{
252 | {
253 | ID: 1,
254 | UserID: 100,
255 | TeamID: 1,
256 | },
257 | {
258 | ID: 2,
259 | UserID: 101,
260 | TeamID: 1,
261 | },
262 | },
263 | },
264 | {
265 | name: "list memberships error",
266 | teamID: 1,
267 | userIDs: []int{100},
268 | mockListError: errors.New("failed to list memberships"),
269 | expectedError: true,
270 | },
271 | {
272 | name: "delete membership error",
273 | teamID: 1,
274 | userIDs: []int{101}, // Want to remove 100
275 | mockMemberships: []*apimodels.PortainerTeamMembership{
276 | {
277 | ID: 1,
278 | UserID: 100,
279 | TeamID: 1,
280 | },
281 | },
282 | mockDeleteError: errors.New("failed to delete membership"),
283 | expectedError: true,
284 | },
285 | {
286 | name: "create membership error",
287 | teamID: 1,
288 | userIDs: []int{100}, // Want to add 100
289 | mockMemberships: []*apimodels.PortainerTeamMembership{},
290 | mockCreateError: errors.New("failed to create membership"),
291 | expectedError: true,
292 | },
293 | }
294 |
295 | for _, tt := range tests {
296 | t.Run(tt.name, func(t *testing.T) {
297 | mockAPI := new(MockPortainerAPI)
298 | mockAPI.On("ListTeamMemberships").Return(tt.mockMemberships, tt.mockListError)
299 |
300 | // Set up delete expectations for memberships that should be removed
301 | for _, membership := range tt.mockMemberships {
302 | shouldDelete := true
303 | for _, keepID := range tt.userIDs {
304 | if int(membership.UserID) == keepID {
305 | shouldDelete = false
306 | break
307 | }
308 | }
309 | if shouldDelete {
310 | mockAPI.On("DeleteTeamMembership", int(membership.ID)).Return(tt.mockDeleteError)
311 | }
312 | }
313 |
314 | // Set up create expectations for new members
315 | for _, userID := range tt.userIDs {
316 | exists := false
317 | for _, membership := range tt.mockMemberships {
318 | if int(membership.UserID) == userID && int(membership.TeamID) == tt.teamID {
319 | exists = true
320 | break
321 | }
322 | }
323 | if !exists {
324 | mockAPI.On("CreateTeamMembership", tt.teamID, userID).Return(tt.mockCreateError)
325 | }
326 | }
327 |
328 | client := &PortainerClient{cli: mockAPI}
329 |
330 | err := client.UpdateTeamMembers(tt.teamID, tt.userIDs)
331 |
332 | if tt.expectedError {
333 | assert.Error(t, err)
334 | return
335 | }
336 | assert.NoError(t, err)
337 | mockAPI.AssertExpectations(t)
338 | })
339 | }
340 | }
341 |
```
--------------------------------------------------------------------------------
/tests/integration/environment_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "testing"
7 |
8 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/portainer/client-api-go/v2/client/utils"
10 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
11 | "github.com/portainer/portainer-mcp/internal/mcp"
12 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
13 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | const (
19 | // Test data constants
20 | testEndpointName = "test-endpoint"
21 | testTag1Name = "tag1"
22 | testTag2Name = "tag2"
23 | )
24 |
25 | // prepareTestEnvironment prepares the test environment for the tests
26 | // It enables Edge Compute settings and creates an Edge Docker endpoint
27 | func prepareEnvironmentManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) {
28 | host, port := env.Portainer.GetHostAndPort()
29 | serverAddr := fmt.Sprintf("%s:%s", host, port)
30 | tunnelAddr := fmt.Sprintf("%s:8000", host)
31 |
32 | err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
33 | require.NoError(t, err, "Failed to update settings")
34 |
35 | _, err = env.RawClient.CreateEdgeDockerEndpoint(testEndpointName)
36 | require.NoError(t, err, "Failed to create Edge Docker endpoint")
37 | }
38 |
39 | // TestEnvironmentManagement is an integration test suite that verifies the complete
40 | // lifecycle of environment management in Portainer MCP. It tests the retrieval and
41 | // configuration of environments, including tag management, user access controls,
42 | // and team access policies.
43 | func TestEnvironmentManagement(t *testing.T) {
44 | env := helpers.NewTestEnv(t)
45 | defer env.Cleanup(t)
46 |
47 | // Prepare the test environment
48 | prepareEnvironmentManagementTestEnvironment(t, env)
49 |
50 | var environment models.Environment
51 |
52 | // Subtest: Environment Retrieval
53 | // Verifies that:
54 | // - The environment is correctly retrieved from the system
55 | // - The environment has the expected default properties (type, status)
56 | // - No tags, user accesses, or team accesses are initially assigned
57 | // - Compares MCP handler output with direct client API call result
58 | t.Run("Environment Retrieval", func(t *testing.T) {
59 | handler := env.MCPServer.HandleGetEnvironments()
60 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
61 | require.NoError(t, err, "Failed to get environments via MCP handler")
62 |
63 | assert.Len(t, result.Content, 1, "Expected exactly one environment from MCP handler")
64 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
65 | assert.True(t, ok, "Expected text content in MCP response")
66 |
67 | var environments []models.Environment
68 | err = json.Unmarshal([]byte(textContent.Text), &environments)
69 | require.NoError(t, err, "Failed to unmarshal environments from MCP response")
70 | require.Len(t, environments, 1, "Expected exactly one environment after unmarshalling")
71 |
72 | // Extract the environment for subsequent tests
73 | environment = environments[0]
74 |
75 | // Fetch the same endpoint directly via the client
76 | rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
77 | require.NoError(t, err, "Failed to get endpoint directly via client")
78 |
79 | // Convert the raw endpoint to the expected Environment model using the package's converter
80 | expectedEnvironment := models.ConvertEndpointToEnvironment(rawEndpoint)
81 |
82 | // Compare the Environment struct from MCP handler with the one converted from the direct client call
83 | assert.Equal(t, expectedEnvironment, environment, "Mismatch between MCP handler environment and converted client environment")
84 | })
85 |
86 | // Subtest: Tag Management
87 | // Verifies that:
88 | // - New tags can be created in the system
89 | // - Multiple tags can be assigned to an environment simultaneously
90 | // - The environment correctly reflects the assigned tag IDs
91 | // - The tags are properly persisted in the endpoint configuration
92 | t.Run("Tag Management", func(t *testing.T) {
93 | tagId1, err := env.RawClient.CreateTag(testTag1Name)
94 | require.NoError(t, err, "Failed to create first tag")
95 | tagId2, err := env.RawClient.CreateTag(testTag2Name)
96 | require.NoError(t, err, "Failed to create second tag")
97 |
98 | request := mcp.CreateMCPRequest(map[string]any{
99 | "id": float64(environment.ID),
100 | "tagIds": []any{float64(tagId1), float64(tagId2)},
101 | })
102 |
103 | handler := env.MCPServer.HandleUpdateEnvironmentTags()
104 | _, err = handler(env.Ctx, request)
105 | require.NoError(t, err, "Failed to update environment tags via MCP handler")
106 |
107 | // Verify by fetching endpoint directly via client
108 | rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
109 | require.NoError(t, err, "Failed to get endpoint via client after tag update")
110 | assert.ElementsMatch(t, []int64{tagId1, tagId2}, rawEndpoint.TagIds, "Tag IDs mismatch (Client check)") // Use ElementsMatch for unordered comparison
111 | })
112 |
113 | // Subtest: User Access Management
114 | // Verifies that:
115 | // - User access policies can be assigned to an environment
116 | // - Multiple users with different access levels can be configured
117 | // - Access levels are correctly mapped to appropriate role IDs
118 | // - The environment's user access policies are properly updated and persisted
119 | t.Run("User Access Management", func(t *testing.T) {
120 | request := mcp.CreateMCPRequest(map[string]any{
121 | "id": float64(environment.ID),
122 | "userAccesses": []any{
123 | map[string]any{"id": float64(1), "access": "environment_administrator"},
124 | map[string]any{"id": float64(2), "access": "standard_user"},
125 | },
126 | })
127 |
128 | handler := env.MCPServer.HandleUpdateEnvironmentUserAccesses()
129 | _, err := handler(env.Ctx, request)
130 | require.NoError(t, err, "Failed to update environment user accesses via MCP handler")
131 |
132 | // Verify by fetching endpoint directly via client
133 | rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
134 | require.NoError(t, err, "Failed to get endpoint via client after user access update")
135 |
136 | expectedRawUserAccesses := utils.BuildAccessPolicies[apimodels.PortainerUserAccessPolicies](map[int64]string{
137 | 1: "environment_administrator",
138 | 2: "standard_user",
139 | })
140 | assert.Equal(t, expectedRawUserAccesses, rawEndpoint.UserAccessPolicies, "User access policies mismatch (Client check)")
141 | })
142 |
143 | // Subtest: Team Access Management
144 | // Verifies that:
145 | // - Team access policies can be assigned to an environment
146 | // - Multiple teams with different access levels can be configured
147 | // - Access levels are correctly mapped to appropriate role IDs
148 | // - The environment's team access policies are properly updated and persisted
149 | t.Run("Team Access Management", func(t *testing.T) {
150 | request := mcp.CreateMCPRequest(map[string]any{
151 | "id": float64(environment.ID),
152 | "teamAccesses": []any{
153 | map[string]any{"id": float64(1), "access": "environment_administrator"},
154 | map[string]any{"id": float64(2), "access": "standard_user"},
155 | },
156 | })
157 |
158 | handler := env.MCPServer.HandleUpdateEnvironmentTeamAccesses()
159 | _, err := handler(env.Ctx, request)
160 | require.NoError(t, err, "Failed to update environment team accesses via MCP handler")
161 |
162 | // Verify by fetching endpoint directly via client
163 | rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
164 | require.NoError(t, err, "Failed to get endpoint via client after team access update")
165 |
166 | expectedRawTeamAccesses := utils.BuildAccessPolicies[apimodels.PortainerTeamAccessPolicies](map[int64]string{
167 | 1: "environment_administrator",
168 | 2: "standard_user",
169 | })
170 | assert.Equal(t, expectedRawTeamAccesses, rawEndpoint.TeamAccessPolicies, "Team access policies mismatch (Client check)")
171 | })
172 | }
173 |
```
--------------------------------------------------------------------------------
/tests/integration/docker_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "testing"
7 |
8 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
9 |
10 | "github.com/portainer/portainer-mcp/internal/mcp"
11 | "github.com/portainer/portainer-mcp/tests/integration/containers"
12 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | const (
18 | // Test data constants
19 | testLocalEndpointName = "test-local-endpoint"
20 | testLocalEndpointID = 1
21 | testVolumeName = "test-proxy-volume"
22 | )
23 |
24 | // prepareDockerProxyTestEnvironment prepares the test environment for the tests
25 | // It creates a local Docker endpoint
26 | func prepareDockerProxyTestEnvironment(t *testing.T, env *helpers.TestEnv) {
27 | _, err := env.RawClient.CreateLocalDockerEndpoint(testLocalEndpointName)
28 | require.NoError(t, err, "Failed to create Local Docker endpoint")
29 | }
30 |
31 | // TestDockerProxy is an integration test suite that verifies the Docker proxy functionality
32 | // provided by the Portainer MCP server. It tests the ability to proxy various Docker API requests
33 | // to a specified environment, including:
34 | // - Retrieving Docker version information (GET /version)
35 | // - Creating a volume (POST /volumes/create)
36 | // - Listing volumes with filters (GET /volumes?filters=...)
37 | // - Removing a volume (DELETE /volumes/{name})
38 | // It primarily tests against volumes, as testing container operations would require
39 | // pulling images beforehand, potentially leading to rate limiting issues in CI/CD
40 | // or rapid testing scenarios.
41 | func TestDockerProxy(t *testing.T) {
42 | env := helpers.NewTestEnv(t, containers.WithDockerSocketBind(true))
43 | defer env.Cleanup(t)
44 |
45 | // Prepare the test environment
46 | prepareDockerProxyTestEnvironment(t, env)
47 |
48 | // Subtest: GET /version
49 | // Verifies that:
50 | // - A simple GET request to the Docker /version endpoint can be successfully proxied.
51 | // - The handler returns a non-empty response without errors.
52 | // - The response content contains expected fields like ApiVersion and Version.
53 | t.Run("GET /version", func(t *testing.T) {
54 | request := mcp.CreateMCPRequest(map[string]any{
55 | "environmentId": float64(testLocalEndpointID),
56 | "method": "GET",
57 | "dockerAPIPath": "/version",
58 | "queryParams": nil, // No query params for /version
59 | "headers": nil, // No specific headers needed
60 | "body": "", // No body for GET request
61 | })
62 |
63 | handler := env.MCPServer.HandleDockerProxy()
64 | result, err := handler(env.Ctx, request)
65 |
66 | require.NoError(t, err, "Handler execution failed")
67 | require.NotNil(t, result, "Handler returned nil result")
68 | require.Len(t, result.Content, 1, "Expected exactly one content item in result")
69 |
70 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
71 | require.True(t, ok, "Expected text content in result")
72 | require.NotEmpty(t, textContent.Text, "Result text content should not be empty")
73 |
74 | // Unmarshal and check specific fields
75 | var versionInfo map[string]any // Using map[string]any for flexibility
76 | err = json.Unmarshal([]byte(textContent.Text), &versionInfo)
77 | require.NoError(t, err, "Failed to unmarshal version JSON")
78 | assert.Contains(t, versionInfo, "ApiVersion", "Version info should contain ApiVersion")
79 | assert.NotEmpty(t, versionInfo["ApiVersion"], "ApiVersion should not be empty")
80 | assert.Contains(t, versionInfo, "Version", "Version info should contain Version")
81 | assert.NotEmpty(t, versionInfo["Version"], "Version should not be empty")
82 | })
83 |
84 | // Subtest: Create Volume
85 | // Verifies that:
86 | // - A POST request to /volumes/create proxies correctly.
87 | // - A volume with the specified name is created.
88 | // - The handler response reflects the created volume details.
89 | t.Run("Create Volume", func(t *testing.T) {
90 | createBody := fmt.Sprintf(`{"Name": "%s"}`, testVolumeName)
91 | request := mcp.CreateMCPRequest(map[string]any{
92 | "environmentId": float64(testLocalEndpointID),
93 | "method": "POST",
94 | "dockerAPIPath": "/volumes/create",
95 | "headers": []any{
96 | map[string]any{"key": "Content-Type", "value": "application/json"},
97 | },
98 | "body": createBody,
99 | })
100 |
101 | handler := env.MCPServer.HandleDockerProxy()
102 | result, err := handler(env.Ctx, request)
103 |
104 | require.NoError(t, err, "Create Volume handler execution failed")
105 | require.NotNil(t, result, "Create Volume handler returned nil result")
106 | require.Len(t, result.Content, 1, "Expected one content item for Create Volume")
107 |
108 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
109 | require.True(t, ok, "Expected text content for Create Volume")
110 | require.NotEmpty(t, textContent.Text, "Create Volume response text should not be empty")
111 |
112 | var volumeInfo map[string]any
113 | err = json.Unmarshal([]byte(textContent.Text), &volumeInfo)
114 | require.NoError(t, err, "Failed to unmarshal Create Volume response")
115 | assert.Equal(t, testVolumeName, volumeInfo["Name"], "Volume name in response mismatch")
116 | })
117 |
118 | // Subtest: List Volumes with Filter
119 | // Verifies that:
120 | // - A GET request to /volumes with a name filter proxies correctly.
121 | // - The response contains only the volume created earlier.
122 | t.Run("List Volumes with Filter", func(t *testing.T) {
123 | filterJSON := fmt.Sprintf(`{"name":["%s"]}`, testVolumeName)
124 | request := mcp.CreateMCPRequest(map[string]any{
125 | "environmentId": float64(testLocalEndpointID),
126 | "method": "GET",
127 | "dockerAPIPath": "/volumes",
128 | "queryParams": []any{
129 | map[string]any{"key": "filters", "value": filterJSON},
130 | },
131 | })
132 |
133 | handler := env.MCPServer.HandleDockerProxy()
134 | result, err := handler(env.Ctx, request)
135 |
136 | require.NoError(t, err, "List Volumes handler execution failed")
137 | require.NotNil(t, result, "List Volumes handler returned nil result")
138 | require.Len(t, result.Content, 1, "Expected one content item for List Volumes")
139 |
140 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
141 | require.True(t, ok, "Expected text content for List Volumes")
142 | require.NotEmpty(t, textContent.Text, "List Volumes response text should not be empty")
143 |
144 | var listResponse map[string][]map[string]any
145 | err = json.Unmarshal([]byte(textContent.Text), &listResponse)
146 | require.NoError(t, err, "Failed to unmarshal List Volumes response")
147 | require.Contains(t, listResponse, "Volumes", "List response missing 'Volumes' key")
148 | require.Len(t, listResponse["Volumes"], 1, "Expected exactly one volume in the filtered list")
149 | assert.Equal(t, testVolumeName, listResponse["Volumes"][0]["Name"], "Filtered volume name mismatch")
150 | })
151 |
152 | // Subtest: Remove Volume
153 | // Verifies that:
154 | // - A DELETE request to /volumes/{name} proxies correctly.
155 | // - The volume created earlier is successfully removed.
156 | // - The handler response is empty (reflecting Docker's 204 No Content).
157 | t.Run("Remove Volume", func(t *testing.T) {
158 | request := mcp.CreateMCPRequest(map[string]any{
159 | "environmentId": float64(testLocalEndpointID),
160 | "method": "DELETE",
161 | "dockerAPIPath": "/volumes/" + testVolumeName,
162 | })
163 |
164 | handler := env.MCPServer.HandleDockerProxy()
165 | result, err := handler(env.Ctx, request)
166 |
167 | require.NoError(t, err, "Remove Volume handler execution failed")
168 | require.NotNil(t, result, "Remove Volume handler returned nil result")
169 | require.Len(t, result.Content, 1, "Expected one content item for Remove Volume")
170 |
171 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
172 | require.True(t, ok, "Expected text content for Remove Volume")
173 | assert.Empty(t, textContent.Text, "Remove Volume response text should be empty for 204 No Content")
174 | })
175 | }
176 |
```
--------------------------------------------------------------------------------
/internal/mcp/docker_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/mock"
15 | )
16 |
17 | func createMockHttpResponse(statusCode int, body string) *http.Response {
18 | return &http.Response{
19 | StatusCode: statusCode,
20 | Body: io.NopCloser(strings.NewReader(body)),
21 | }
22 | }
23 |
24 | // errorReader simulates an error during io.ReadAll
25 | type errorReader struct{}
26 |
27 | func (r *errorReader) Read(p []byte) (n int, err error) {
28 | return 0, fmt.Errorf("simulated read error")
29 | }
30 |
31 | func (r *errorReader) Close() error {
32 | return nil
33 | }
34 |
35 | func TestHandleDockerProxy_ParameterValidation(t *testing.T) {
36 | tests := []struct {
37 | name string
38 | inputParams map[string]any
39 | expectedErrorMsg string
40 | }{
41 | {
42 | name: "invalid body type (not a string)",
43 | inputParams: map[string]any{
44 | "environmentId": float64(2),
45 | "dockerAPIPath": "/containers/create",
46 | "method": "POST",
47 | "body": 123.45, // Invalid type for body
48 | },
49 | expectedErrorMsg: "body must be a string",
50 | },
51 | {
52 | name: "missing environmentId",
53 | inputParams: map[string]any{
54 | "dockerAPIPath": "/containers/json",
55 | "method": "GET",
56 | },
57 | expectedErrorMsg: "environmentId is required",
58 | },
59 | {
60 | name: "missing dockerAPIPath",
61 | inputParams: map[string]any{
62 | "environmentId": float64(1),
63 | "method": "GET",
64 | },
65 | expectedErrorMsg: "dockerAPIPath is required",
66 | },
67 | {
68 | name: "missing method",
69 | inputParams: map[string]any{
70 | "environmentId": float64(1),
71 | "dockerAPIPath": "/containers/json",
72 | },
73 | expectedErrorMsg: "method is required",
74 | },
75 | {
76 | name: "invalid dockerAPIPath (no leading slash)",
77 | inputParams: map[string]any{
78 | "environmentId": float64(1),
79 | "dockerAPIPath": "containers/json",
80 | "method": "GET",
81 | },
82 | expectedErrorMsg: "dockerAPIPath must start with a leading slash",
83 | },
84 | {
85 | name: "invalid HTTP method",
86 | inputParams: map[string]any{
87 | "environmentId": float64(1),
88 | "dockerAPIPath": "/containers/json",
89 | "method": "INVALID",
90 | },
91 | expectedErrorMsg: "invalid method: INVALID",
92 | },
93 | {
94 | name: "invalid queryParams type (not an array)",
95 | inputParams: map[string]any{
96 | "environmentId": float64(1),
97 | "dockerAPIPath": "/containers/json",
98 | "method": "GET",
99 | "queryParams": "not-an-array", // Invalid type
100 | },
101 | expectedErrorMsg: "queryParams must be an array",
102 | },
103 | {
104 | name: "invalid queryParams content (missing key)",
105 | inputParams: map[string]any{
106 | "environmentId": float64(1),
107 | "dockerAPIPath": "/containers/json",
108 | "method": "GET",
109 | "queryParams": []any{map[string]any{"value": "true"}}, // Missing 'key'
110 | },
111 | expectedErrorMsg: "invalid query params: invalid key: <nil>",
112 | },
113 | {
114 | name: "invalid headers type (not an array)",
115 | inputParams: map[string]any{
116 | "environmentId": float64(1),
117 | "dockerAPIPath": "/containers/json",
118 | "method": "GET",
119 | "headers": map[string]any{"key": "value"}, // Invalid type
120 | },
121 | expectedErrorMsg: "headers must be an array",
122 | },
123 | {
124 | name: "invalid headers content (value not string)",
125 | inputParams: map[string]any{
126 | "environmentId": float64(1),
127 | "dockerAPIPath": "/containers/json",
128 | "method": "GET",
129 | "headers": []any{map[string]any{"key": "X-Custom", "value": 123}}, // Value not string
130 | },
131 | expectedErrorMsg: "invalid headers: invalid value: 123",
132 | },
133 | }
134 |
135 | for _, tt := range tests {
136 | t.Run(tt.name, func(t *testing.T) {
137 | server := &PortainerMCPServer{}
138 |
139 | request := CreateMCPRequest(tt.inputParams)
140 | handler := server.HandleDockerProxy()
141 | result, err := handler(context.Background(), request)
142 |
143 | // All parameter/validation errors now return (result{IsError: true}, nil)
144 | assert.NoError(t, err) // Handler now returns nil error
145 | assert.NotNil(t, result) // Handler returns a result object
146 | assert.True(t, result.IsError, "result.IsError should be true for parameter validation errors")
147 | assert.Len(t, result.Content, 1) // Expect one content item for the error message
148 | textContent, ok := result.Content[0].(mcp.TextContent) // Content should be TextContent
149 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
150 | assert.Contains(t, textContent.Text, tt.expectedErrorMsg, "Error message mismatch")
151 | })
152 | }
153 | }
154 |
155 | func TestHandleDockerProxy_ClientInteraction(t *testing.T) {
156 | type testCase struct {
157 | name string
158 | input map[string]any // Parameters for the MCP request
159 | mock struct { // Details for mocking the client call
160 | response *http.Response
161 | err error
162 | }
163 | expect struct { // Expected outcome
164 | errSubstring string // Check for error containing this text (if error expected)
165 | resultText string // Expected text result (if success expected)
166 | }
167 | }
168 |
169 | tests := []testCase{
170 | {
171 | name: "successful GET request", // Query params are parsed by toolgen, but not yet passed by handler
172 | input: map[string]any{
173 | "environmentId": float64(1),
174 | "dockerAPIPath": "/containers/json",
175 | "method": "GET",
176 | "queryParams": []any{ //
177 | map[string]any{"key": "all", "value": "true"},
178 | map[string]any{"key": "filter", "value": "dangling"},
179 | },
180 | },
181 | mock: struct {
182 | response *http.Response
183 | err error
184 | }{
185 | response: createMockHttpResponse(http.StatusOK, `[{"Id":"123"}]`),
186 | err: nil,
187 | },
188 | expect: struct {
189 | errSubstring string
190 | resultText string
191 | }{
192 | resultText: `[{"Id":"123"}]`,
193 | },
194 | },
195 | {
196 | name: "successful POST request with body",
197 | input: map[string]any{
198 | "environmentId": float64(2),
199 | "dockerAPIPath": "/containers/create",
200 | "method": "POST",
201 | "body": `{"name":"test"}`,
202 | "headers": []any{
203 | map[string]any{"key": "X-Custom", "value": "test-value"},
204 | map[string]any{"key": "Authorization", "value": "Bearer abc"},
205 | },
206 | },
207 | mock: struct {
208 | response *http.Response
209 | err error
210 | }{
211 | response: createMockHttpResponse(http.StatusCreated, `{"Id":"456"}`),
212 | err: nil,
213 | },
214 | expect: struct {
215 | errSubstring string
216 | resultText string
217 | }{
218 | resultText: `{"Id":"456"}`,
219 | },
220 | },
221 | {
222 | name: "client API error",
223 | input: map[string]any{
224 | "environmentId": float64(3),
225 | "dockerAPIPath": "/version",
226 | "method": "GET",
227 | },
228 | mock: struct {
229 | response *http.Response
230 | err error
231 | }{
232 | response: nil,
233 | err: errors.New("portainer api error"),
234 | },
235 | expect: struct {
236 | errSubstring string
237 | resultText string
238 | }{
239 | errSubstring: "failed to send Docker API request: portainer api error",
240 | },
241 | },
242 | {
243 | name: "error reading response body",
244 | input: map[string]any{
245 | "environmentId": float64(4),
246 | "dockerAPIPath": "/info",
247 | "method": "GET",
248 | },
249 | mock: struct {
250 | response *http.Response
251 | err error
252 | }{
253 | response: &http.Response{
254 | StatusCode: http.StatusOK,
255 | Body: &errorReader{}, // Simulate read error
256 | },
257 | err: nil, // No client error, but response body read fails
258 | },
259 | expect: struct {
260 | errSubstring string
261 | resultText string
262 | }{
263 | errSubstring: "failed to read Docker API response: simulated read error",
264 | },
265 | },
266 | }
267 |
268 | for _, tc := range tests {
269 | t.Run(tc.name, func(t *testing.T) {
270 | mockClient := new(MockPortainerClient)
271 |
272 | mockClient.On("ProxyDockerRequest", mock.AnythingOfType("models.DockerProxyRequestOptions")).
273 | Return(tc.mock.response, tc.mock.err)
274 |
275 | server := &PortainerMCPServer{
276 | cli: mockClient,
277 | }
278 |
279 | request := CreateMCPRequest(tc.input)
280 | handler := server.HandleDockerProxy()
281 | result, err := handler(context.Background(), request)
282 |
283 | if tc.expect.errSubstring != "" {
284 | assert.NoError(t, err)
285 | assert.NotNil(t, result)
286 | assert.True(t, result.IsError, "result.IsError should be true for errors")
287 | assert.Len(t, result.Content, 1)
288 | textContent, ok := result.Content[0].(mcp.TextContent)
289 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
290 | assert.Contains(t, textContent.Text, tc.expect.errSubstring)
291 | } else {
292 | assert.NoError(t, err)
293 | assert.NotNil(t, result)
294 | assert.Len(t, result.Content, 1)
295 | textContent, ok := result.Content[0].(mcp.TextContent)
296 | assert.True(t, ok)
297 | assert.Equal(t, tc.expect.resultText, textContent.Text)
298 | }
299 |
300 | mockClient.AssertExpectations(t)
301 | })
302 | }
303 | }
304 |
```
--------------------------------------------------------------------------------
/tests/integration/group_test.go:
--------------------------------------------------------------------------------
```go
1 | package integration
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "testing"
7 |
8 | mcpmodels "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/portainer/portainer-mcp/internal/mcp"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | "github.com/portainer/portainer-mcp/tests/integration/helpers"
12 |
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | const (
18 | testGroupName = "test-mcp-group"
19 | testGroupUpdatedName = "test-mcp-group-updated"
20 | testGroupTagName1 = "test-group-tag1"
21 | testGroupTagName2 = "test-group-tag2"
22 | testEnvName = "test-group-env"
23 | )
24 |
25 | // prepareEnvironmentGroupTestEnvironment prepares the test environment for environment group tests
26 | func prepareEnvironmentGroupTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int) {
27 | // Enable Edge features in Portainer
28 | host, port := env.Portainer.GetHostAndPort()
29 | serverAddr := fmt.Sprintf("%s:%s", host, port)
30 | tunnelAddr := fmt.Sprintf("%s:8000", host)
31 |
32 | err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
33 | require.NoError(t, err, "Failed to update settings to enable Edge features")
34 |
35 | // Create a test environment for association with groups
36 | envID, err := env.RawClient.CreateEdgeDockerEndpoint(testEnvName)
37 | require.NoError(t, err, "Failed to create test environment")
38 |
39 | // Create test tag
40 | tagID, err := env.RawClient.CreateTag(testGroupTagName1)
41 | require.NoError(t, err, "Failed to create test tag")
42 |
43 | return int(envID), int(tagID)
44 | }
45 |
46 | // TestEnvironmentGroupManagement is an integration test suite that verifies the complete
47 | // lifecycle of environment group management in Portainer MCP. It tests group creation,
48 | // listing, name updates, environment association and tag association.
49 | func TestEnvironmentGroupManagement(t *testing.T) {
50 | env := helpers.NewTestEnv(t)
51 | defer env.Cleanup(t)
52 |
53 | // Prepare the test environment
54 | testEnvID, testTagID := prepareEnvironmentGroupTestEnvironment(t, env)
55 |
56 | var testGroupID int
57 |
58 | // Subtest: Environment Group Creation
59 | // Verifies that:
60 | // - A new environment group can be created via the MCP handler
61 | // - The handler response indicates success with an ID
62 | // - The created group exists in Portainer when checked directly via Raw Client
63 | t.Run("Environment Group Creation", func(t *testing.T) {
64 | handler := env.MCPServer.HandleCreateEnvironmentGroup()
65 | request := mcp.CreateMCPRequest(map[string]any{
66 | "name": testGroupName,
67 | "environmentIds": []any{float64(testEnvID)},
68 | })
69 |
70 | result, err := handler(env.Ctx, request)
71 | require.NoError(t, err, "Failed to create environment group via MCP handler")
72 |
73 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
74 | require.True(t, ok, "Expected text content in MCP response")
75 |
76 | // Check for success message
77 | assert.Contains(t, textContent.Text, "Environment group created successfully with ID:", "Success message prefix mismatch")
78 |
79 | // Verify by fetching group directly via client and finding the created group by name
80 | group, err := env.RawClient.GetEdgeGroupByName(testGroupName)
81 | require.NoError(t, err, "Failed to get environment group directly via client")
82 | assert.Equal(t, testGroupName, group.Name, "Group name mismatch")
83 |
84 | // Extract group ID for subsequent tests
85 | testGroupID = int(group.ID)
86 | })
87 |
88 | // Subtest: Environment Group Listing
89 | // Verifies that:
90 | // - The group list can be retrieved via the MCP handler
91 | // - The list contains the expected group
92 | // - The group data matches the expected properties
93 | t.Run("Environment Group Listing", func(t *testing.T) {
94 | handler := env.MCPServer.HandleGetEnvironmentGroups()
95 | result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
96 | require.NoError(t, err, "Failed to get environment groups via MCP handler")
97 |
98 | assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
99 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
100 | assert.True(t, ok, "Expected text content in MCP response")
101 |
102 | var retrievedGroups []models.Group
103 | err = json.Unmarshal([]byte(textContent.Text), &retrievedGroups)
104 | require.NoError(t, err, "Failed to unmarshal retrieved groups")
105 | require.Len(t, retrievedGroups, 1, "Expected exactly one group after unmarshalling")
106 |
107 | group := retrievedGroups[0]
108 | assert.Equal(t, testGroupName, group.Name, "Group name mismatch")
109 |
110 | // Fetch the same group directly via the client
111 | rawGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
112 | require.NoError(t, err, "Failed to get environment group directly via client")
113 |
114 | // Convert the raw group to the expected Group model
115 | expectedGroup := models.ConvertEdgeGroupToGroup(rawGroup)
116 | assert.Equal(t, expectedGroup, group, "Group mismatch between MCP handler and direct client call")
117 | })
118 |
119 | // Subtest: Environment Group Name Update
120 | // Verifies that:
121 | // - The group name can be updated via the MCP handler
122 | // - The handler response indicates success
123 | // - The name is correctly updated when checked directly via Raw Client
124 | t.Run("Environment Group Name Update", func(t *testing.T) {
125 | handler := env.MCPServer.HandleUpdateEnvironmentGroupName()
126 | request := mcp.CreateMCPRequest(map[string]any{
127 | "id": float64(testGroupID),
128 | "name": testGroupUpdatedName,
129 | })
130 |
131 | result, err := handler(env.Ctx, request)
132 | require.NoError(t, err, "Failed to update environment group name via MCP handler")
133 |
134 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
135 | require.True(t, ok, "Expected text content in MCP response")
136 | assert.Contains(t, textContent.Text, "Environment group name updated successfully", "Success message mismatch")
137 |
138 | // Verify by fetching group directly via raw client
139 | updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
140 | require.NoError(t, err, "Failed to get environment group directly via client")
141 | assert.Equal(t, testGroupUpdatedName, updatedGroup.Name, "Group name was not updated")
142 | })
143 |
144 | // Subtest: Environment Group Tag Update
145 | // Verifies that:
146 | // - Tags can be associated with a group via the MCP handler
147 | // - The handler response indicates success
148 | // - The tags are correctly associated when checked directly via Raw Client
149 | t.Run("Environment Group Tag Update", func(t *testing.T) {
150 | // Create a second tag
151 | tagID2, err := env.RawClient.CreateTag(testGroupTagName2)
152 | require.NoError(t, err, "Failed to create second test tag")
153 |
154 | handler := env.MCPServer.HandleUpdateEnvironmentGroupTags()
155 | request := mcp.CreateMCPRequest(map[string]any{
156 | "id": float64(testGroupID),
157 | "tagIds": []any{float64(testTagID), float64(tagID2)},
158 | })
159 |
160 | result, err := handler(env.Ctx, request)
161 | require.NoError(t, err, "Failed to update environment group tags via MCP handler")
162 |
163 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
164 | require.True(t, ok, "Expected text content in MCP response")
165 | assert.Contains(t, textContent.Text, "Environment group tags updated successfully", "Success message mismatch")
166 |
167 | // Verify by fetching group directly via raw client
168 | updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
169 | require.NoError(t, err, "Failed to get environment group directly via client")
170 | assert.ElementsMatch(t, []int64{int64(testTagID), int64(tagID2)}, updatedGroup.TagIds, "Tag IDs mismatch")
171 | })
172 |
173 | // Subtest: Environment Group Environments Update
174 | // Verifies that:
175 | // - Environment associations can be updated via the MCP handler
176 | // - The handler response indicates success
177 | // - The environment associations are correctly updated when checked directly via Raw Client
178 | t.Run("Environment Group Environments Update", func(t *testing.T) {
179 | // Create a second environment
180 | env2Name := "test-env-2"
181 | env2ID, err := env.RawClient.CreateEdgeDockerEndpoint(env2Name)
182 | require.NoError(t, err, "Failed to create second test environment")
183 |
184 | handler := env.MCPServer.HandleUpdateEnvironmentGroupEnvironments()
185 | request := mcp.CreateMCPRequest(map[string]any{
186 | "id": float64(testGroupID),
187 | "environmentIds": []any{float64(testEnvID), float64(env2ID)},
188 | })
189 |
190 | result, err := handler(env.Ctx, request)
191 | require.NoError(t, err, "Failed to update environment group environments via MCP handler")
192 |
193 | textContent, ok := result.Content[0].(mcpmodels.TextContent)
194 | require.True(t, ok, "Expected text content in MCP response")
195 | assert.Contains(t, textContent.Text, "Environment group environments updated successfully", "Success message mismatch")
196 |
197 | // Verify by fetching group directly via raw client
198 | updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
199 | require.NoError(t, err, "Failed to get environment group directly via client")
200 | assert.ElementsMatch(t, []int64{int64(testEnvID), int64(env2ID)}, updatedGroup.Endpoints, "Environment IDs mismatch")
201 | })
202 | }
203 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/mocks_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/portainer/client-api-go/v2/client"
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | "github.com/stretchr/testify/mock"
9 | )
10 |
11 | // Mock Implementation Patterns:
12 | //
13 | // This file contains mock implementations of the PortainerAPIClient interface.
14 | // The following patterns are used throughout the mocks:
15 | //
16 | // 1. Methods returning (T, error):
17 | // - Uses m.Called() to record the method call and get mock behavior
18 | // - Includes nil check on first return value to avoid type assertion panics
19 | // - Example:
20 | // func (m *Mock) Method() (T, error) {
21 | // args := m.Called()
22 | // if args.Get(0) == nil {
23 | // return nil, args.Error(1)
24 | // }
25 | // return args.Get(0).(T), args.Error(1)
26 | // }
27 | //
28 | // 2. Methods returning only error:
29 | // - Uses m.Called() with any parameters
30 | // - Returns only the error value
31 | // - Example:
32 | // func (m *Mock) Method(param string) error {
33 | // args := m.Called(param)
34 | // return args.Error(0)
35 | // }
36 | //
37 | // 3. Methods with primitive return types:
38 | // - Uses type-specific getters (e.g., Int64, String)
39 | // - Example:
40 | // func (m *Mock) Method() (int64, error) {
41 | // args := m.Called()
42 | // return args.Get(0).(int64), args.Error(1)
43 | // }
44 | //
45 | // Usage in Tests:
46 | // mock := new(MockPortainerAPI)
47 | // mock.On("MethodName").Return(expectedValue, nil)
48 | // result, err := mock.MethodName()
49 | // mock.AssertExpectations(t)
50 |
51 | // MockPortainerAPI is a mock of the PortainerAPIClient interface
52 | type MockPortainerAPI struct {
53 | mock.Mock
54 | }
55 |
56 | // ListEdgeGroups mocks the ListEdgeGroups method
57 | func (m *MockPortainerAPI) ListEdgeGroups() ([]*apimodels.EdgegroupsDecoratedEdgeGroup, error) {
58 | args := m.Called()
59 | if args.Get(0) == nil {
60 | return nil, args.Error(1)
61 | }
62 | return args.Get(0).([]*apimodels.EdgegroupsDecoratedEdgeGroup), args.Error(1)
63 | }
64 |
65 | // CreateEdgeGroup mocks the CreateEdgeGroup method
66 | func (m *MockPortainerAPI) CreateEdgeGroup(name string, environmentIds []int64) (int64, error) {
67 | args := m.Called(name, environmentIds)
68 | return args.Get(0).(int64), args.Error(1)
69 | }
70 |
71 | // UpdateEdgeGroup mocks the UpdateEdgeGroup method
72 | func (m *MockPortainerAPI) UpdateEdgeGroup(id int64, name *string, environmentIds *[]int64, tagIds *[]int64) error {
73 | args := m.Called(id, name, environmentIds, tagIds)
74 | return args.Error(0)
75 | }
76 |
77 | // ListEdgeStacks mocks the ListEdgeStacks method
78 | func (m *MockPortainerAPI) ListEdgeStacks() ([]*apimodels.PortainereeEdgeStack, error) {
79 | args := m.Called()
80 | if args.Get(0) == nil {
81 | return nil, args.Error(1)
82 | }
83 | return args.Get(0).([]*apimodels.PortainereeEdgeStack), args.Error(1)
84 | }
85 |
86 | // CreateEdgeStack mocks the CreateEdgeStack method
87 | func (m *MockPortainerAPI) CreateEdgeStack(name string, file string, environmentGroupIds []int64) (int64, error) {
88 | args := m.Called(name, file, environmentGroupIds)
89 | return args.Get(0).(int64), args.Error(1)
90 | }
91 |
92 | // UpdateEdgeStack mocks the UpdateEdgeStack method
93 | func (m *MockPortainerAPI) UpdateEdgeStack(id int64, file string, environmentGroupIds []int64) error {
94 | args := m.Called(id, file, environmentGroupIds)
95 | return args.Error(0)
96 | }
97 |
98 | // GetEdgeStackFile mocks the GetEdgeStackFile method
99 | func (m *MockPortainerAPI) GetEdgeStackFile(id int64) (string, error) {
100 | args := m.Called(id)
101 | return args.String(0), args.Error(1)
102 | }
103 |
104 | // ListEndpointGroups mocks the ListEndpointGroups method
105 | func (m *MockPortainerAPI) ListEndpointGroups() ([]*apimodels.PortainerEndpointGroup, error) {
106 | args := m.Called()
107 | if args.Get(0) == nil {
108 | return nil, args.Error(1)
109 | }
110 | return args.Get(0).([]*apimodels.PortainerEndpointGroup), args.Error(1)
111 | }
112 |
113 | // CreateEndpointGroup mocks the CreateEndpointGroup method
114 | func (m *MockPortainerAPI) CreateEndpointGroup(name string, associatedEndpoints []int64) (int64, error) {
115 | args := m.Called(name, associatedEndpoints)
116 | return args.Get(0).(int64), args.Error(1)
117 | }
118 |
119 | // UpdateEndpointGroup mocks the UpdateEndpointGroup method
120 | func (m *MockPortainerAPI) UpdateEndpointGroup(id int64, name *string, userAccesses *map[int64]string, teamAccesses *map[int64]string) error {
121 | args := m.Called(id, name, userAccesses, teamAccesses)
122 | return args.Error(0)
123 | }
124 |
125 | // AddEnvironmentToEndpointGroup mocks the AddEnvironmentToEndpointGroup method
126 | func (m *MockPortainerAPI) AddEnvironmentToEndpointGroup(groupId int64, environmentId int64) error {
127 | args := m.Called(groupId, environmentId)
128 | return args.Error(0)
129 | }
130 |
131 | // RemoveEnvironmentFromEndpointGroup mocks the RemoveEnvironmentFromEndpointGroup method
132 | func (m *MockPortainerAPI) RemoveEnvironmentFromEndpointGroup(groupId int64, environmentId int64) error {
133 | args := m.Called(groupId, environmentId)
134 | return args.Error(0)
135 | }
136 |
137 | // ListEndpoints mocks the ListEndpoints method
138 | func (m *MockPortainerAPI) ListEndpoints() ([]*apimodels.PortainereeEndpoint, error) {
139 | args := m.Called()
140 | if args.Get(0) == nil {
141 | return nil, args.Error(1)
142 | }
143 | return args.Get(0).([]*apimodels.PortainereeEndpoint), args.Error(1)
144 | }
145 |
146 | // GetEndpoint mocks the GetEndpoint method
147 | func (m *MockPortainerAPI) GetEndpoint(id int64) (*apimodels.PortainereeEndpoint, error) {
148 | args := m.Called(id)
149 | if args.Get(0) == nil {
150 | return nil, args.Error(1)
151 | }
152 | return args.Get(0).(*apimodels.PortainereeEndpoint), args.Error(1)
153 | }
154 |
155 | // UpdateEndpoint mocks the UpdateEndpoint method
156 | func (m *MockPortainerAPI) UpdateEndpoint(id int64, tagIds *[]int64, userAccesses *map[int64]string, teamAccesses *map[int64]string) error {
157 | args := m.Called(id, tagIds, userAccesses, teamAccesses)
158 | return args.Error(0)
159 | }
160 |
161 | // GetSettings mocks the GetSettings method
162 | func (m *MockPortainerAPI) GetSettings() (*apimodels.PortainereeSettings, error) {
163 | args := m.Called()
164 | if args.Get(0) == nil {
165 | return nil, args.Error(1)
166 | }
167 | return args.Get(0).(*apimodels.PortainereeSettings), args.Error(1)
168 | }
169 |
170 | // ListTags mocks the ListTags method
171 | func (m *MockPortainerAPI) ListTags() ([]*apimodels.PortainerTag, error) {
172 | args := m.Called()
173 | if args.Get(0) == nil {
174 | return nil, args.Error(1)
175 | }
176 | return args.Get(0).([]*apimodels.PortainerTag), args.Error(1)
177 | }
178 |
179 | // CreateTag mocks the CreateTag method
180 | func (m *MockPortainerAPI) CreateTag(name string) (int64, error) {
181 | args := m.Called(name)
182 | return args.Get(0).(int64), args.Error(1)
183 | }
184 |
185 | // ListTeams mocks the ListTeams method
186 | func (m *MockPortainerAPI) ListTeams() ([]*apimodels.PortainerTeam, error) {
187 | args := m.Called()
188 | if args.Get(0) == nil {
189 | return nil, args.Error(1)
190 | }
191 | return args.Get(0).([]*apimodels.PortainerTeam), args.Error(1)
192 | }
193 |
194 | // ListTeamMemberships mocks the ListTeamMemberships method
195 | func (m *MockPortainerAPI) ListTeamMemberships() ([]*apimodels.PortainerTeamMembership, error) {
196 | args := m.Called()
197 | if args.Get(0) == nil {
198 | return nil, args.Error(1)
199 | }
200 | return args.Get(0).([]*apimodels.PortainerTeamMembership), args.Error(1)
201 | }
202 |
203 | // CreateTeam mocks the CreateTeam method
204 | func (m *MockPortainerAPI) CreateTeam(name string) (int64, error) {
205 | args := m.Called(name)
206 | return args.Get(0).(int64), args.Error(1)
207 | }
208 |
209 | // UpdateTeamName mocks the UpdateTeamName method
210 | func (m *MockPortainerAPI) UpdateTeamName(id int, name string) error {
211 | args := m.Called(id, name)
212 | return args.Error(0)
213 | }
214 |
215 | // DeleteTeamMembership mocks the DeleteTeamMembership method
216 | func (m *MockPortainerAPI) DeleteTeamMembership(id int) error {
217 | args := m.Called(id)
218 | return args.Error(0)
219 | }
220 |
221 | // CreateTeamMembership mocks the CreateTeamMembership method
222 | func (m *MockPortainerAPI) CreateTeamMembership(teamId int, userId int) error {
223 | args := m.Called(teamId, userId)
224 | return args.Error(0)
225 | }
226 |
227 | // ListUsers mocks the ListUsers method
228 | func (m *MockPortainerAPI) ListUsers() ([]*apimodels.PortainereeUser, error) {
229 | args := m.Called()
230 | if args.Get(0) == nil {
231 | return nil, args.Error(1)
232 | }
233 | return args.Get(0).([]*apimodels.PortainereeUser), args.Error(1)
234 | }
235 |
236 | // UpdateUserRole mocks the UpdateUserRole method
237 | func (m *MockPortainerAPI) UpdateUserRole(id int, role int64) error {
238 | args := m.Called(id, role)
239 | return args.Error(0)
240 | }
241 |
242 | // GetVersion mocks the GetVersion method
243 | func (m *MockPortainerAPI) GetVersion() (string, error) {
244 | args := m.Called()
245 | return args.String(0), args.Error(1)
246 | }
247 |
248 | // ProxyDockerRequest mocks the ProxyDockerRequest method
249 | func (m *MockPortainerAPI) ProxyDockerRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error) {
250 | args := m.Called(environmentId, opts)
251 | if args.Get(0) == nil {
252 | return nil, args.Error(1)
253 | }
254 | return args.Get(0).(*http.Response), args.Error(1)
255 | }
256 |
257 | // ProxyKubernetesRequest mocks the ProxyKubernetesRequest method
258 | func (m *MockPortainerAPI) ProxyKubernetesRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error) {
259 | args := m.Called(environmentId, opts)
260 | if args.Get(0) == nil {
261 | return nil, args.Error(1)
262 | }
263 | return args.Get(0).(*http.Response), args.Error(1)
264 | }
265 |
```
--------------------------------------------------------------------------------
/internal/mcp/team_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "testing"
8 |
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func TestHandleCreateTeam(t *testing.T) {
15 | tests := []struct {
16 | name string
17 | teamName string
18 | mockID int
19 | mockError error
20 | expectError bool
21 | setupParams func(request *mcp.CallToolRequest)
22 | }{
23 | {
24 | name: "successful team creation",
25 | teamName: "test-team",
26 | mockID: 1,
27 | mockError: nil,
28 | expectError: false,
29 | setupParams: func(request *mcp.CallToolRequest) {
30 | request.Params.Arguments = map[string]any{
31 | "name": "test-team",
32 | }
33 | },
34 | },
35 | {
36 | name: "api error",
37 | teamName: "test-team",
38 | mockID: 0,
39 | mockError: fmt.Errorf("api error"),
40 | expectError: true,
41 | setupParams: func(request *mcp.CallToolRequest) {
42 | request.Params.Arguments = map[string]any{
43 | "name": "test-team",
44 | }
45 | },
46 | },
47 | {
48 | name: "missing name parameter",
49 | teamName: "",
50 | mockID: 0,
51 | mockError: nil,
52 | expectError: true,
53 | setupParams: func(request *mcp.CallToolRequest) {
54 | // No need to set any parameters as the request will be invalid
55 | },
56 | },
57 | }
58 |
59 | for _, tt := range tests {
60 | t.Run(tt.name, func(t *testing.T) {
61 | mockClient := &MockPortainerClient{}
62 | if !tt.expectError || tt.mockError != nil {
63 | mockClient.On("CreateTeam", tt.teamName).Return(tt.mockID, tt.mockError)
64 | }
65 |
66 | server := &PortainerMCPServer{
67 | cli: mockClient,
68 | }
69 |
70 | request := CreateMCPRequest(map[string]any{})
71 | tt.setupParams(&request)
72 |
73 | handler := server.HandleCreateTeam()
74 | result, err := handler(context.Background(), request)
75 |
76 | if tt.expectError {
77 | assert.NoError(t, err)
78 | assert.NotNil(t, result)
79 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
80 | assert.Len(t, result.Content, 1)
81 | textContent, ok := result.Content[0].(mcp.TextContent)
82 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
83 | if tt.mockError != nil {
84 | assert.Contains(t, textContent.Text, tt.mockError.Error())
85 | } else {
86 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
87 | }
88 | } else {
89 | assert.NoError(t, err)
90 | assert.Len(t, result.Content, 1)
91 | textContent, ok := result.Content[0].(mcp.TextContent)
92 | assert.True(t, ok)
93 | assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
94 | }
95 |
96 | mockClient.AssertExpectations(t)
97 | })
98 | }
99 | }
100 |
101 | func TestHandleGetTeams(t *testing.T) {
102 | tests := []struct {
103 | name string
104 | mockTeams []models.Team
105 | mockError error
106 | expectError bool
107 | }{
108 | {
109 | name: "successful teams retrieval",
110 | mockTeams: []models.Team{
111 | {ID: 1, Name: "team1"},
112 | {ID: 2, Name: "team2"},
113 | },
114 | mockError: nil,
115 | expectError: false,
116 | },
117 | {
118 | name: "api error",
119 | mockTeams: nil,
120 | mockError: fmt.Errorf("api error"),
121 | expectError: true,
122 | },
123 | }
124 |
125 | for _, tt := range tests {
126 | t.Run(tt.name, func(t *testing.T) {
127 | mockClient := &MockPortainerClient{}
128 | mockClient.On("GetTeams").Return(tt.mockTeams, tt.mockError)
129 |
130 | server := &PortainerMCPServer{
131 | cli: mockClient,
132 | }
133 |
134 | handler := server.HandleGetTeams()
135 | result, err := handler(context.Background(), mcp.CallToolRequest{})
136 |
137 | if tt.expectError {
138 | assert.NoError(t, err)
139 | assert.NotNil(t, result)
140 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
141 | assert.Len(t, result.Content, 1)
142 | textContent, ok := result.Content[0].(mcp.TextContent)
143 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
144 | if tt.mockError != nil {
145 | assert.Contains(t, textContent.Text, tt.mockError.Error())
146 | } else {
147 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
148 | }
149 | } else {
150 | assert.NoError(t, err)
151 | assert.Len(t, result.Content, 1)
152 | textContent, ok := result.Content[0].(mcp.TextContent)
153 | assert.True(t, ok)
154 |
155 | var teams []models.Team
156 | err = json.Unmarshal([]byte(textContent.Text), &teams)
157 | assert.NoError(t, err)
158 | assert.Equal(t, tt.mockTeams, teams)
159 | }
160 |
161 | mockClient.AssertExpectations(t)
162 | })
163 | }
164 | }
165 |
166 | func TestHandleUpdateTeamName(t *testing.T) {
167 | tests := []struct {
168 | name string
169 | inputID int
170 | inputName string
171 | mockError error
172 | expectError bool
173 | setupParams func(request *mcp.CallToolRequest)
174 | }{
175 | {
176 | name: "successful name update",
177 | inputID: 1,
178 | inputName: "new-name",
179 | mockError: nil,
180 | expectError: false,
181 | setupParams: func(request *mcp.CallToolRequest) {
182 | request.Params.Arguments = map[string]any{
183 | "id": float64(1),
184 | "name": "new-name",
185 | }
186 | },
187 | },
188 | {
189 | name: "api error",
190 | inputID: 1,
191 | inputName: "new-name",
192 | mockError: fmt.Errorf("api error"),
193 | expectError: true,
194 | setupParams: func(request *mcp.CallToolRequest) {
195 | request.Params.Arguments = map[string]any{
196 | "id": float64(1),
197 | "name": "new-name",
198 | }
199 | },
200 | },
201 | {
202 | name: "missing id parameter",
203 | inputID: 0,
204 | inputName: "new-name",
205 | mockError: nil,
206 | expectError: true,
207 | setupParams: func(request *mcp.CallToolRequest) {
208 | request.Params.Arguments = map[string]any{
209 | "name": "new-name",
210 | }
211 | },
212 | },
213 | {
214 | name: "missing name parameter",
215 | inputID: 1,
216 | inputName: "",
217 | mockError: nil,
218 | expectError: true,
219 | setupParams: func(request *mcp.CallToolRequest) {
220 | request.Params.Arguments = map[string]any{
221 | "id": float64(1),
222 | }
223 | },
224 | },
225 | }
226 |
227 | for _, tt := range tests {
228 | t.Run(tt.name, func(t *testing.T) {
229 | mockClient := &MockPortainerClient{}
230 | if !tt.expectError || tt.mockError != nil {
231 | mockClient.On("UpdateTeamName", tt.inputID, tt.inputName).Return(tt.mockError)
232 | }
233 |
234 | server := &PortainerMCPServer{
235 | cli: mockClient,
236 | }
237 |
238 | request := CreateMCPRequest(map[string]any{})
239 | tt.setupParams(&request)
240 |
241 | handler := server.HandleUpdateTeamName()
242 | result, err := handler(context.Background(), request)
243 |
244 | if tt.expectError {
245 | assert.NoError(t, err)
246 | assert.NotNil(t, result)
247 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
248 | assert.Len(t, result.Content, 1)
249 | textContent, ok := result.Content[0].(mcp.TextContent)
250 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
251 | if tt.mockError != nil {
252 | assert.Contains(t, textContent.Text, tt.mockError.Error())
253 | } else {
254 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
255 | }
256 | } else {
257 | assert.NoError(t, err)
258 | assert.Len(t, result.Content, 1)
259 | textContent, ok := result.Content[0].(mcp.TextContent)
260 | assert.True(t, ok)
261 | assert.Contains(t, textContent.Text, "successfully")
262 | }
263 |
264 | mockClient.AssertExpectations(t)
265 | })
266 | }
267 | }
268 |
269 | func TestHandleUpdateTeamMembers(t *testing.T) {
270 | tests := []struct {
271 | name string
272 | inputID int
273 | inputUsers []int
274 | mockError error
275 | expectError bool
276 | setupParams func(request *mcp.CallToolRequest)
277 | }{
278 | {
279 | name: "successful members update",
280 | inputID: 1,
281 | inputUsers: []int{1, 2, 3},
282 | mockError: nil,
283 | expectError: false,
284 | setupParams: func(request *mcp.CallToolRequest) {
285 | request.Params.Arguments = map[string]any{
286 | "id": float64(1),
287 | "userIds": []any{float64(1), float64(2), float64(3)},
288 | }
289 | },
290 | },
291 | {
292 | name: "api error",
293 | inputID: 1,
294 | inputUsers: []int{1, 2, 3},
295 | mockError: fmt.Errorf("api error"),
296 | expectError: true,
297 | setupParams: func(request *mcp.CallToolRequest) {
298 | request.Params.Arguments = map[string]any{
299 | "id": float64(1),
300 | "userIds": []any{float64(1), float64(2), float64(3)},
301 | }
302 | },
303 | },
304 | {
305 | name: "missing id parameter",
306 | inputID: 0,
307 | inputUsers: []int{1, 2, 3},
308 | mockError: nil,
309 | expectError: true,
310 | setupParams: func(request *mcp.CallToolRequest) {
311 | request.Params.Arguments = map[string]any{
312 | "userIds": []any{float64(1), float64(2), float64(3)},
313 | }
314 | },
315 | },
316 | {
317 | name: "missing userIds parameter",
318 | inputID: 1,
319 | inputUsers: nil,
320 | mockError: nil,
321 | expectError: true,
322 | setupParams: func(request *mcp.CallToolRequest) {
323 | request.Params.Arguments = map[string]any{
324 | "id": float64(1),
325 | }
326 | },
327 | },
328 | }
329 |
330 | for _, tt := range tests {
331 | t.Run(tt.name, func(t *testing.T) {
332 | mockClient := &MockPortainerClient{}
333 | if !tt.expectError || tt.mockError != nil {
334 | mockClient.On("UpdateTeamMembers", tt.inputID, tt.inputUsers).Return(tt.mockError)
335 | }
336 |
337 | server := &PortainerMCPServer{
338 | cli: mockClient,
339 | }
340 |
341 | request := CreateMCPRequest(map[string]any{})
342 | tt.setupParams(&request)
343 |
344 | handler := server.HandleUpdateTeamMembers()
345 | result, err := handler(context.Background(), request)
346 |
347 | if tt.expectError {
348 | assert.NoError(t, err)
349 | assert.NotNil(t, result)
350 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
351 | assert.Len(t, result.Content, 1)
352 | textContent, ok := result.Content[0].(mcp.TextContent)
353 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
354 | if tt.mockError != nil {
355 | assert.Contains(t, textContent.Text, tt.mockError.Error())
356 | } else {
357 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
358 | }
359 | } else {
360 | assert.NoError(t, err)
361 | assert.Len(t, result.Content, 1)
362 | textContent, ok := result.Content[0].(mcp.TextContent)
363 | assert.True(t, ok)
364 | assert.Contains(t, textContent.Text, "successfully")
365 | }
366 |
367 | mockClient.AssertExpectations(t)
368 | })
369 | }
370 | }
371 |
```
--------------------------------------------------------------------------------
/pkg/portainer/client/access_group_test.go:
--------------------------------------------------------------------------------
```go
1 | package client
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | apimodels "github.com/portainer/client-api-go/v2/pkg/models"
8 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/mock"
11 | )
12 |
13 | func TestGetAccessGroups(t *testing.T) {
14 | tests := []struct {
15 | name string
16 | mockEndpointGroups []*apimodels.PortainerEndpointGroup
17 | mockEndpoints []*apimodels.PortainereeEndpoint
18 | mockEndpointGroupsErr error
19 | mockEndpointsErr error
20 | expected []models.AccessGroup
21 | expectedError bool
22 | }{
23 | {
24 | name: "successful retrieval",
25 | mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
26 | {
27 | ID: 1,
28 | Name: "group1",
29 | UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
30 | "1": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
31 | "2": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
32 | "3": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
33 | "4": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
34 | "5": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
35 | },
36 | TeamAccessPolicies: apimodels.PortainerTeamAccessPolicies{
37 | "6": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
38 | "7": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
39 | "8": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
40 | "9": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
41 | "10": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
42 | },
43 | },
44 | },
45 | mockEndpoints: []*apimodels.PortainereeEndpoint{
46 | {ID: 1, Name: "endpoint1", GroupID: 1},
47 | {ID: 2, Name: "endpoint2", GroupID: 1},
48 | {ID: 3, Name: "endpoint3", GroupID: 2},
49 | },
50 | expected: []models.AccessGroup{
51 | {
52 | ID: 1,
53 | Name: "group1",
54 | EnvironmentIds: []int{1, 2},
55 | UserAccesses: map[int]string{
56 | 1: "environment_administrator",
57 | 2: "helpdesk_user",
58 | 3: "standard_user",
59 | 4: "readonly_user",
60 | 5: "operator_user",
61 | },
62 | TeamAccesses: map[int]string{
63 | 6: "environment_administrator",
64 | 7: "helpdesk_user",
65 | 8: "standard_user",
66 | 9: "readonly_user",
67 | 10: "operator_user",
68 | },
69 | },
70 | },
71 | },
72 | {
73 | name: "endpoint group list error",
74 | mockEndpointGroupsErr: errors.New("failed to list groups"),
75 | expectedError: true,
76 | },
77 | {
78 | name: "endpoint list error",
79 | mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
80 | {ID: 1, Name: "group1"},
81 | },
82 | mockEndpointsErr: errors.New("failed to list endpoints"),
83 | expectedError: true,
84 | },
85 | {
86 | name: "empty groups with endpoints",
87 | mockEndpointGroups: []*apimodels.PortainerEndpointGroup{},
88 | mockEndpoints: []*apimodels.PortainereeEndpoint{
89 | {ID: 1, Name: "endpoint1", GroupID: 1},
90 | {ID: 2, Name: "endpoint2", GroupID: 2},
91 | },
92 | expected: []models.AccessGroup{},
93 | },
94 | {
95 | name: "groups with empty endpoints",
96 | mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
97 | {
98 | ID: 1,
99 | Name: "group1",
100 | UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
101 | "1": apimodels.PortainerAccessPolicy{RoleID: 1},
102 | },
103 | },
104 | },
105 | mockEndpoints: []*apimodels.PortainereeEndpoint{},
106 | expected: []models.AccessGroup{
107 | {
108 | ID: 1,
109 | Name: "group1",
110 | EnvironmentIds: []int{},
111 | UserAccesses: map[int]string{
112 | 1: "environment_administrator",
113 | },
114 | TeamAccesses: map[int]string{},
115 | },
116 | },
117 | },
118 | {
119 | name: "both empty",
120 | mockEndpointGroups: []*apimodels.PortainerEndpointGroup{},
121 | mockEndpoints: []*apimodels.PortainereeEndpoint{},
122 | expected: []models.AccessGroup{},
123 | },
124 | }
125 |
126 | for _, tt := range tests {
127 | t.Run(tt.name, func(t *testing.T) {
128 | mockAPI := new(MockPortainerAPI)
129 | mockAPI.On("ListEndpointGroups").Return(tt.mockEndpointGroups, tt.mockEndpointGroupsErr)
130 | mockAPI.On("ListEndpoints").Return(tt.mockEndpoints, tt.mockEndpointsErr)
131 |
132 | client := &PortainerClient{cli: mockAPI}
133 |
134 | groups, err := client.GetAccessGroups()
135 |
136 | if tt.expectedError {
137 | assert.Error(t, err)
138 | return
139 | }
140 | assert.NoError(t, err)
141 | assert.Equal(t, tt.expected, groups)
142 | mockAPI.AssertExpectations(t)
143 | })
144 | }
145 | }
146 |
147 | func TestCreateAccessGroup(t *testing.T) {
148 | tests := []struct {
149 | name string
150 | groupName string
151 | envIDs []int
152 | mockReturnID int64
153 | mockError error
154 | expected int
155 | expectedError bool
156 | }{
157 | {
158 | name: "successful creation",
159 | groupName: "newgroup",
160 | envIDs: []int{1, 2, 3},
161 | mockReturnID: 1,
162 | expected: 1,
163 | },
164 | {
165 | name: "creation error",
166 | groupName: "newgroup",
167 | envIDs: []int{1},
168 | mockError: errors.New("failed to create group"),
169 | expectedError: true,
170 | },
171 | }
172 |
173 | for _, tt := range tests {
174 | t.Run(tt.name, func(t *testing.T) {
175 | mockAPI := new(MockPortainerAPI)
176 | mockAPI.On("CreateEndpointGroup", tt.groupName, mock.Anything).Return(tt.mockReturnID, tt.mockError)
177 |
178 | client := &PortainerClient{cli: mockAPI}
179 |
180 | id, err := client.CreateAccessGroup(tt.groupName, tt.envIDs)
181 |
182 | if tt.expectedError {
183 | assert.Error(t, err)
184 | return
185 | }
186 | assert.NoError(t, err)
187 | assert.Equal(t, tt.expected, id)
188 | mockAPI.AssertExpectations(t)
189 | })
190 | }
191 | }
192 |
193 | func TestUpdateAccessGroupName(t *testing.T) {
194 | tests := []struct {
195 | name string
196 | groupID int
197 | newName string
198 | mockError error
199 | expectedError bool
200 | }{
201 | {
202 | name: "successful update",
203 | groupID: 1,
204 | newName: "updated-group",
205 | },
206 | {
207 | name: "update error",
208 | groupID: 1,
209 | newName: "updated-group",
210 | mockError: errors.New("failed to update group"),
211 | expectedError: true,
212 | },
213 | }
214 |
215 | for _, tt := range tests {
216 | t.Run(tt.name, func(t *testing.T) {
217 | mockAPI := new(MockPortainerAPI)
218 | mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), &tt.newName, mock.Anything, mock.Anything).Return(tt.mockError)
219 |
220 | client := &PortainerClient{cli: mockAPI}
221 |
222 | err := client.UpdateAccessGroupName(tt.groupID, tt.newName)
223 |
224 | if tt.expectedError {
225 | assert.Error(t, err)
226 | return
227 | }
228 | assert.NoError(t, err)
229 | mockAPI.AssertExpectations(t)
230 | })
231 | }
232 | }
233 |
234 | func TestUpdateAccessGroupUserAccesses(t *testing.T) {
235 | tests := []struct {
236 | name string
237 | groupID int
238 | userAccesses map[int]string
239 | mockError error
240 | expectedError bool
241 | }{
242 | {
243 | name: "successful update",
244 | groupID: 1,
245 | userAccesses: map[int]string{
246 | 1: "environment_administrator",
247 | 2: "readonly_user",
248 | },
249 | },
250 | {
251 | name: "update error",
252 | groupID: 1,
253 | userAccesses: map[int]string{
254 | 1: "environment_administrator",
255 | },
256 | mockError: errors.New("failed to update user accesses"),
257 | expectedError: true,
258 | },
259 | }
260 |
261 | for _, tt := range tests {
262 | t.Run(tt.name, func(t *testing.T) {
263 | mockAPI := new(MockPortainerAPI)
264 | mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
265 |
266 | client := &PortainerClient{cli: mockAPI}
267 |
268 | err := client.UpdateAccessGroupUserAccesses(tt.groupID, tt.userAccesses)
269 |
270 | if tt.expectedError {
271 | assert.Error(t, err)
272 | return
273 | }
274 | assert.NoError(t, err)
275 | mockAPI.AssertExpectations(t)
276 | })
277 | }
278 | }
279 |
280 | func TestUpdateAccessGroupTeamAccesses(t *testing.T) {
281 | tests := []struct {
282 | name string
283 | groupID int
284 | teamAccesses map[int]string
285 | mockError error
286 | expectedError bool
287 | }{
288 | {
289 | name: "successful update",
290 | groupID: 1,
291 | teamAccesses: map[int]string{
292 | 1: "environment_administrator",
293 | 2: "readonly_user",
294 | },
295 | },
296 | {
297 | name: "update error",
298 | groupID: 1,
299 | teamAccesses: map[int]string{
300 | 1: "environment_administrator",
301 | },
302 | mockError: errors.New("failed to update team accesses"),
303 | expectedError: true,
304 | },
305 | }
306 |
307 | for _, tt := range tests {
308 | t.Run(tt.name, func(t *testing.T) {
309 | mockAPI := new(MockPortainerAPI)
310 | mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
311 |
312 | client := &PortainerClient{cli: mockAPI}
313 |
314 | err := client.UpdateAccessGroupTeamAccesses(tt.groupID, tt.teamAccesses)
315 |
316 | if tt.expectedError {
317 | assert.Error(t, err)
318 | return
319 | }
320 | assert.NoError(t, err)
321 | mockAPI.AssertExpectations(t)
322 | })
323 | }
324 | }
325 |
326 | func TestAddEnvironmentToAccessGroup(t *testing.T) {
327 | tests := []struct {
328 | name string
329 | groupID int
330 | envID int
331 | mockError error
332 | expectedError bool
333 | }{
334 | {
335 | name: "successful addition",
336 | groupID: 1,
337 | envID: 2,
338 | },
339 | {
340 | name: "addition error",
341 | groupID: 1,
342 | envID: 2,
343 | mockError: errors.New("failed to add environment"),
344 | expectedError: true,
345 | },
346 | }
347 |
348 | for _, tt := range tests {
349 | t.Run(tt.name, func(t *testing.T) {
350 | mockAPI := new(MockPortainerAPI)
351 | mockAPI.On("AddEnvironmentToEndpointGroup", int64(tt.groupID), int64(tt.envID)).Return(tt.mockError)
352 |
353 | client := &PortainerClient{cli: mockAPI}
354 |
355 | err := client.AddEnvironmentToAccessGroup(tt.groupID, tt.envID)
356 |
357 | if tt.expectedError {
358 | assert.Error(t, err)
359 | return
360 | }
361 | assert.NoError(t, err)
362 | mockAPI.AssertExpectations(t)
363 | })
364 | }
365 | }
366 |
367 | func TestRemoveEnvironmentFromAccessGroup(t *testing.T) {
368 | tests := []struct {
369 | name string
370 | groupID int
371 | envID int
372 | mockError error
373 | expectedError bool
374 | }{
375 | {
376 | name: "successful removal",
377 | groupID: 1,
378 | envID: 2,
379 | },
380 | {
381 | name: "removal error",
382 | groupID: 1,
383 | envID: 2,
384 | mockError: errors.New("failed to remove environment"),
385 | expectedError: true,
386 | },
387 | }
388 |
389 | for _, tt := range tests {
390 | t.Run(tt.name, func(t *testing.T) {
391 | mockAPI := new(MockPortainerAPI)
392 | mockAPI.On("RemoveEnvironmentFromEndpointGroup", int64(tt.groupID), int64(tt.envID)).Return(tt.mockError)
393 |
394 | client := &PortainerClient{cli: mockAPI}
395 |
396 | err := client.RemoveEnvironmentFromAccessGroup(tt.groupID, tt.envID)
397 |
398 | if tt.expectedError {
399 | assert.Error(t, err)
400 | return
401 | }
402 | assert.NoError(t, err)
403 | mockAPI.AssertExpectations(t)
404 | })
405 | }
406 | }
407 |
```
--------------------------------------------------------------------------------
/pkg/toolgen/param_test.go:
--------------------------------------------------------------------------------
```go
1 | package toolgen
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/mark3labs/mcp-go/mcp"
8 | )
9 |
10 | // Helper function to create a ParameterParser with given arguments
11 | func newTestParser(args map[string]any) *ParameterParser {
12 | return NewParameterParser(mcp.CallToolRequest{
13 | Params: mcp.CallToolParams{
14 | Arguments: args,
15 | },
16 | })
17 | }
18 |
19 | func TestGetString(t *testing.T) {
20 | tests := []struct {
21 | name string
22 | args map[string]any
23 | param string
24 | required bool
25 | want string
26 | wantErr bool
27 | }{
28 | {
29 | name: "valid string",
30 | args: map[string]any{"name": "test"},
31 | param: "name",
32 | required: true,
33 | want: "test",
34 | wantErr: false,
35 | },
36 | {
37 | name: "missing required param",
38 | args: map[string]any{},
39 | param: "name",
40 | required: true,
41 | want: "",
42 | wantErr: true,
43 | },
44 | {
45 | name: "missing optional param",
46 | args: map[string]any{},
47 | param: "name",
48 | required: false,
49 | want: "",
50 | wantErr: false,
51 | },
52 | {
53 | name: "wrong type",
54 | args: map[string]any{"name": 123},
55 | param: "name",
56 | required: true,
57 | want: "",
58 | wantErr: true,
59 | },
60 | {
61 | name: "nil value",
62 | args: map[string]any{"name": nil},
63 | param: "name",
64 | required: true,
65 | want: "",
66 | wantErr: true,
67 | },
68 | }
69 |
70 | for _, tt := range tests {
71 | t.Run(tt.name, func(t *testing.T) {
72 | p := newTestParser(tt.args)
73 | got, err := p.GetString(tt.param, tt.required)
74 | if (err != nil) != tt.wantErr {
75 | t.Errorf("GetString() error = %v, wantErr %v", err, tt.wantErr)
76 | return
77 | }
78 | if got != tt.want {
79 | t.Errorf("GetString() = %v, want %v", got, tt.want)
80 | }
81 | })
82 | }
83 | }
84 |
85 | func TestGetNumber(t *testing.T) {
86 | tests := []struct {
87 | name string
88 | args map[string]any
89 | param string
90 | required bool
91 | want float64
92 | wantErr bool
93 | }{
94 | {
95 | name: "valid number",
96 | args: map[string]any{"num": float64(42)},
97 | param: "num",
98 | required: true,
99 | want: 42,
100 | wantErr: false,
101 | },
102 | {
103 | name: "missing required param",
104 | args: map[string]any{},
105 | param: "num",
106 | required: true,
107 | want: 0,
108 | wantErr: true,
109 | },
110 | {
111 | name: "missing optional param",
112 | args: map[string]any{},
113 | param: "num",
114 | required: false,
115 | want: 0,
116 | wantErr: false,
117 | },
118 | {
119 | name: "wrong type",
120 | args: map[string]any{"num": "123"},
121 | param: "num",
122 | required: true,
123 | want: 0,
124 | wantErr: true,
125 | },
126 | {
127 | name: "nil value",
128 | args: map[string]any{"num": nil},
129 | param: "num",
130 | required: true,
131 | want: 0,
132 | wantErr: true,
133 | },
134 | }
135 |
136 | for _, tt := range tests {
137 | t.Run(tt.name, func(t *testing.T) {
138 | p := newTestParser(tt.args)
139 | got, err := p.GetNumber(tt.param, tt.required)
140 | if (err != nil) != tt.wantErr {
141 | t.Errorf("GetNumber() error = %v, wantErr %v", err, tt.wantErr)
142 | return
143 | }
144 | if got != tt.want {
145 | t.Errorf("GetNumber() = %v, want %v", got, tt.want)
146 | }
147 | })
148 | }
149 | }
150 |
151 | func TestGetBoolean(t *testing.T) {
152 | tests := []struct {
153 | name string
154 | args map[string]any
155 | param string
156 | required bool
157 | want bool
158 | wantErr bool
159 | }{
160 | {
161 | name: "valid true",
162 | args: map[string]any{"flag": true},
163 | param: "flag",
164 | required: true,
165 | want: true,
166 | wantErr: false,
167 | },
168 | {
169 | name: "valid false",
170 | args: map[string]any{"flag": false},
171 | param: "flag",
172 | required: true,
173 | want: false,
174 | wantErr: false,
175 | },
176 | {
177 | name: "missing required param",
178 | args: map[string]any{},
179 | param: "flag",
180 | required: true,
181 | want: false,
182 | wantErr: true,
183 | },
184 | {
185 | name: "missing optional param",
186 | args: map[string]any{},
187 | param: "flag",
188 | required: false,
189 | want: false,
190 | wantErr: false,
191 | },
192 | {
193 | name: "wrong type",
194 | args: map[string]any{"flag": "true"},
195 | param: "flag",
196 | required: true,
197 | want: false,
198 | wantErr: true,
199 | },
200 | {
201 | name: "nil value",
202 | args: map[string]any{"flag": nil},
203 | param: "flag",
204 | required: true,
205 | want: false,
206 | wantErr: true,
207 | },
208 | }
209 |
210 | for _, tt := range tests {
211 | t.Run(tt.name, func(t *testing.T) {
212 | p := newTestParser(tt.args)
213 | got, err := p.GetBoolean(tt.param, tt.required)
214 | if (err != nil) != tt.wantErr {
215 | t.Errorf("GetBoolean() error = %v, wantErr %v", err, tt.wantErr)
216 | return
217 | }
218 | if got != tt.want {
219 | t.Errorf("GetBoolean() = %v, want %v", got, tt.want)
220 | }
221 | })
222 | }
223 | }
224 |
225 | func TestGetArrayOfObjects(t *testing.T) {
226 | tests := []struct {
227 | name string
228 | args map[string]any
229 | param string
230 | required bool
231 | want []any
232 | wantErr bool
233 | }{
234 | {
235 | name: "valid array of objects",
236 | args: map[string]any{"objects": []any{
237 | map[string]any{"id": 1},
238 | map[string]any{"id": 2},
239 | }},
240 | param: "objects",
241 | required: true,
242 | want: []any{
243 | map[string]any{"id": 1},
244 | map[string]any{"id": 2},
245 | },
246 | wantErr: false,
247 | },
248 | {
249 | name: "missing required param",
250 | args: map[string]any{},
251 | param: "objects",
252 | required: true,
253 | want: nil,
254 | wantErr: true,
255 | },
256 | {
257 | name: "missing optional param",
258 | args: map[string]any{},
259 | param: "objects",
260 | required: false,
261 | want: []any{},
262 | wantErr: false,
263 | },
264 | {
265 | name: "wrong type",
266 | args: map[string]any{"objects": "not an array"},
267 | param: "objects",
268 | required: true,
269 | want: nil,
270 | wantErr: true,
271 | },
272 | {
273 | name: "nil value",
274 | args: map[string]any{"objects": nil},
275 | param: "objects",
276 | required: true,
277 | want: nil,
278 | wantErr: true,
279 | },
280 | }
281 |
282 | for _, tt := range tests {
283 | t.Run(tt.name, func(t *testing.T) {
284 | p := newTestParser(tt.args)
285 | got, err := p.GetArrayOfObjects(tt.param, tt.required)
286 | if (err != nil) != tt.wantErr {
287 | t.Errorf("GetArrayOfObjects() error = %v, wantErr %v", err, tt.wantErr)
288 | return
289 | }
290 | if !reflect.DeepEqual(got, tt.want) {
291 | t.Errorf("GetArrayOfObjects() = %v, want %v", got, tt.want)
292 | }
293 | })
294 | }
295 | }
296 |
297 | func TestParseArrayOfIntegers(t *testing.T) {
298 | tests := []struct {
299 | name string
300 | input []any
301 | want []int
302 | wantErr bool
303 | }{
304 | {
305 | name: "empty array",
306 | input: []any{},
307 | want: []int{},
308 | wantErr: false,
309 | },
310 | {
311 | name: "single value",
312 | input: []any{float64(42)},
313 | want: []int{42},
314 | wantErr: false,
315 | },
316 | {
317 | name: "multiple values",
318 | input: []any{float64(1), float64(2), float64(3), float64(4), float64(5)},
319 | want: []int{1, 2, 3, 4, 5},
320 | wantErr: false,
321 | },
322 | {
323 | name: "negative values",
324 | input: []any{float64(-1), float64(-2), float64(-3)},
325 | want: []int{-1, -2, -3},
326 | wantErr: false,
327 | },
328 | {
329 | name: "mixed positive and negative values",
330 | input: []any{float64(0), float64(1), float64(-2), float64(3), float64(-4)},
331 | want: []int{0, 1, -2, 3, -4},
332 | wantErr: false,
333 | },
334 | {
335 | name: "invalid string value",
336 | input: []any{float64(1), "abc", float64(3)},
337 | want: nil,
338 | wantErr: true,
339 | },
340 | {
341 | name: "invalid boolean value",
342 | input: []any{float64(1), true, float64(3)},
343 | want: nil,
344 | wantErr: true,
345 | },
346 | {
347 | name: "invalid nil value",
348 | input: []any{float64(1), nil, float64(3)},
349 | want: nil,
350 | wantErr: true,
351 | },
352 | }
353 |
354 | for _, tt := range tests {
355 | t.Run(tt.name, func(t *testing.T) {
356 | got, err := parseArrayOfIntegers(tt.input)
357 |
358 | // Check error status
359 | if (err != nil) != tt.wantErr {
360 | t.Errorf("ParseNumericArray() error = %v, wantErr %v", err, tt.wantErr)
361 | return
362 | }
363 |
364 | // If we expect an error, no need to check the result
365 | if tt.wantErr {
366 | return
367 | }
368 |
369 | // Check result values
370 | if !reflect.DeepEqual(got, tt.want) {
371 | t.Errorf("ParseNumericArray() = %v, want %v", got, tt.want)
372 | }
373 | })
374 | }
375 | }
376 |
377 | func TestGetInt(t *testing.T) {
378 | tests := []struct {
379 | name string
380 | args map[string]any
381 | param string
382 | required bool
383 | want int
384 | wantErr bool
385 | }{
386 | {
387 | name: "valid integer",
388 | args: map[string]any{"num": float64(42)},
389 | param: "num",
390 | required: true,
391 | want: 42,
392 | wantErr: false,
393 | },
394 | {
395 | name: "valid zero",
396 | args: map[string]any{"num": float64(0)},
397 | param: "num",
398 | required: true,
399 | want: 0,
400 | wantErr: false,
401 | },
402 | {
403 | name: "valid negative",
404 | args: map[string]any{"num": float64(-42)},
405 | param: "num",
406 | required: true,
407 | want: -42,
408 | wantErr: false,
409 | },
410 | {
411 | name: "missing required param",
412 | args: map[string]any{},
413 | param: "num",
414 | required: true,
415 | want: 0,
416 | wantErr: true,
417 | },
418 | {
419 | name: "missing optional param",
420 | args: map[string]any{},
421 | param: "num",
422 | required: false,
423 | want: 0,
424 | wantErr: false,
425 | },
426 | {
427 | name: "wrong type string",
428 | args: map[string]any{"num": "123"},
429 | param: "num",
430 | required: true,
431 | want: 0,
432 | wantErr: true,
433 | },
434 | {
435 | name: "wrong type boolean",
436 | args: map[string]any{"num": true},
437 | param: "num",
438 | required: true,
439 | want: 0,
440 | wantErr: true,
441 | },
442 | {
443 | name: "nil value",
444 | args: map[string]any{"num": nil},
445 | param: "num",
446 | required: true,
447 | want: 0,
448 | wantErr: true,
449 | },
450 | }
451 |
452 | for _, tt := range tests {
453 | t.Run(tt.name, func(t *testing.T) {
454 | p := newTestParser(tt.args)
455 | got, err := p.GetInt(tt.param, tt.required)
456 | if (err != nil) != tt.wantErr {
457 | t.Errorf("GetInt() error = %v, wantErr %v", err, tt.wantErr)
458 | return
459 | }
460 | if got != tt.want {
461 | t.Errorf("GetInt() = %v, want %v", got, tt.want)
462 | }
463 | })
464 | }
465 | }
466 |
467 | func TestGetArrayOfIntegers(t *testing.T) {
468 | tests := []struct {
469 | name string
470 | args map[string]any
471 | param string
472 | required bool
473 | want []int
474 | wantErr bool
475 | }{
476 | {
477 | name: "valid array of integers",
478 | args: map[string]any{"nums": []any{
479 | float64(1), float64(2), float64(3),
480 | }},
481 | param: "nums",
482 | required: true,
483 | want: []int{1, 2, 3},
484 | wantErr: false,
485 | },
486 | {
487 | name: "valid array with negative numbers",
488 | args: map[string]any{"nums": []any{
489 | float64(-1), float64(0), float64(1),
490 | }},
491 | param: "nums",
492 | required: true,
493 | want: []int{-1, 0, 1},
494 | wantErr: false,
495 | },
496 | {
497 | name: "empty array",
498 | args: map[string]any{"nums": []any{}},
499 | param: "nums",
500 | required: true,
501 | want: []int{},
502 | wantErr: false,
503 | },
504 | {
505 | name: "missing required param",
506 | args: map[string]any{},
507 | param: "nums",
508 | required: true,
509 | want: nil,
510 | wantErr: true,
511 | },
512 | {
513 | name: "missing optional param",
514 | args: map[string]any{},
515 | param: "nums",
516 | required: false,
517 | want: []int{},
518 | wantErr: false,
519 | },
520 | {
521 | name: "invalid array with string",
522 | args: map[string]any{"nums": []any{
523 | float64(1), "2", float64(3),
524 | }},
525 | param: "nums",
526 | required: true,
527 | want: nil,
528 | wantErr: true,
529 | },
530 | {
531 | name: "invalid array with boolean",
532 | args: map[string]any{"nums": []any{
533 | float64(1), true, float64(3),
534 | }},
535 | param: "nums",
536 | required: true,
537 | want: nil,
538 | wantErr: true,
539 | },
540 | {
541 | name: "invalid array with nil",
542 | args: map[string]any{"nums": []any{
543 | float64(1), nil, float64(3),
544 | }},
545 | param: "nums",
546 | required: true,
547 | want: nil,
548 | wantErr: true,
549 | },
550 | {
551 | name: "wrong type (string instead of array)",
552 | args: map[string]any{"nums": "not an array"},
553 | param: "nums",
554 | required: true,
555 | want: nil,
556 | wantErr: true,
557 | },
558 | {
559 | name: "nil value",
560 | args: map[string]any{"nums": nil},
561 | param: "nums",
562 | required: true,
563 | want: nil,
564 | wantErr: true,
565 | },
566 | }
567 |
568 | for _, tt := range tests {
569 | t.Run(tt.name, func(t *testing.T) {
570 | p := newTestParser(tt.args)
571 | got, err := p.GetArrayOfIntegers(tt.param, tt.required)
572 | if (err != nil) != tt.wantErr {
573 | t.Errorf("GetArrayOfIntegers() error = %v, wantErr %v", err, tt.wantErr)
574 | return
575 | }
576 | if !reflect.DeepEqual(got, tt.want) {
577 | t.Errorf("GetArrayOfIntegers() = %v, want %v", got, tt.want)
578 | }
579 | })
580 | }
581 | }
582 |
```
--------------------------------------------------------------------------------
/internal/mcp/environment_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
12 | "github.com/stretchr/testify/assert"
13 | )
14 |
15 | func TestHandleGetEnvironments(t *testing.T) {
16 | tests := []struct {
17 | name string
18 | mockEnvironments []models.Environment
19 | mockError error
20 | expectError bool
21 | }{
22 | {
23 | name: "successful environments retrieval",
24 | mockEnvironments: []models.Environment{
25 | {ID: 1, Name: "env1"},
26 | {ID: 2, Name: "env2"},
27 | },
28 | mockError: nil,
29 | expectError: false,
30 | },
31 | {
32 | name: "api error",
33 | mockEnvironments: nil,
34 | mockError: fmt.Errorf("api error"),
35 | expectError: true,
36 | },
37 | }
38 |
39 | for _, tt := range tests {
40 | t.Run(tt.name, func(t *testing.T) {
41 | mockClient := &MockPortainerClient{}
42 | mockClient.On("GetEnvironments").Return(tt.mockEnvironments, tt.mockError)
43 |
44 | server := &PortainerMCPServer{
45 | cli: mockClient,
46 | }
47 |
48 | handler := server.HandleGetEnvironments()
49 | result, err := handler(context.Background(), mcp.CallToolRequest{})
50 |
51 | if tt.expectError {
52 | assert.NoError(t, err)
53 | assert.NotNil(t, result)
54 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
55 | assert.Len(t, result.Content, 1)
56 | textContent, ok := result.Content[0].(mcp.TextContent)
57 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
58 | if tt.mockError != nil {
59 | assert.Contains(t, textContent.Text, tt.mockError.Error())
60 | } else {
61 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
62 | }
63 | } else {
64 | assert.NoError(t, err)
65 | assert.Len(t, result.Content, 1)
66 | textContent, ok := result.Content[0].(mcp.TextContent)
67 | assert.True(t, ok)
68 |
69 | var environments []models.Environment
70 | err = json.Unmarshal([]byte(textContent.Text), &environments)
71 | assert.NoError(t, err)
72 | assert.Equal(t, tt.mockEnvironments, environments)
73 | }
74 |
75 | mockClient.AssertExpectations(t)
76 | })
77 | }
78 | }
79 |
80 | func TestHandleUpdateEnvironmentTags(t *testing.T) {
81 | tests := []struct {
82 | name string
83 | inputID int
84 | inputTagIDs []int
85 | mockError error
86 | expectError bool
87 | setupParams func(request *mcp.CallToolRequest)
88 | }{
89 | {
90 | name: "successful tags update",
91 | inputID: 1,
92 | inputTagIDs: []int{1, 2, 3},
93 | mockError: nil,
94 | expectError: false,
95 | setupParams: func(request *mcp.CallToolRequest) {
96 | request.Params.Arguments = map[string]any{
97 | "id": float64(1),
98 | "tagIds": []any{float64(1), float64(2), float64(3)},
99 | }
100 | },
101 | },
102 | {
103 | name: "api error",
104 | inputID: 1,
105 | inputTagIDs: []int{1, 2, 3},
106 | mockError: fmt.Errorf("api error"),
107 | expectError: true,
108 | setupParams: func(request *mcp.CallToolRequest) {
109 | request.Params.Arguments = map[string]any{
110 | "id": float64(1),
111 | "tagIds": []any{float64(1), float64(2), float64(3)},
112 | }
113 | },
114 | },
115 | {
116 | name: "missing id parameter",
117 | inputID: 0,
118 | inputTagIDs: []int{1, 2, 3},
119 | mockError: nil,
120 | expectError: true,
121 | setupParams: func(request *mcp.CallToolRequest) {
122 | request.Params.Arguments = map[string]any{
123 | "tagIds": []any{float64(1), float64(2), float64(3)},
124 | }
125 | },
126 | },
127 | {
128 | name: "missing tagIds parameter",
129 | inputID: 1,
130 | inputTagIDs: nil,
131 | mockError: nil,
132 | expectError: true,
133 | setupParams: func(request *mcp.CallToolRequest) {
134 | request.Params.Arguments = map[string]any{
135 | "id": float64(1),
136 | }
137 | },
138 | },
139 | }
140 |
141 | for _, tt := range tests {
142 | t.Run(tt.name, func(t *testing.T) {
143 | mockClient := &MockPortainerClient{}
144 | if !tt.expectError || tt.mockError != nil {
145 | mockClient.On("UpdateEnvironmentTags", tt.inputID, tt.inputTagIDs).Return(tt.mockError)
146 | }
147 |
148 | server := &PortainerMCPServer{
149 | cli: mockClient,
150 | }
151 |
152 | request := CreateMCPRequest(map[string]any{})
153 | tt.setupParams(&request)
154 |
155 | handler := server.HandleUpdateEnvironmentTags()
156 | result, err := handler(context.Background(), request)
157 |
158 | if tt.expectError {
159 | assert.NoError(t, err)
160 | assert.NotNil(t, result)
161 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
162 | assert.Len(t, result.Content, 1)
163 | textContent, ok := result.Content[0].(mcp.TextContent)
164 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
165 | if tt.mockError != nil {
166 | assert.Contains(t, textContent.Text, tt.mockError.Error())
167 | } else {
168 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
169 | }
170 | } else {
171 | assert.NoError(t, err)
172 | assert.Len(t, result.Content, 1)
173 | textContent, ok := result.Content[0].(mcp.TextContent)
174 | assert.True(t, ok)
175 | assert.Contains(t, textContent.Text, "successfully")
176 | }
177 |
178 | mockClient.AssertExpectations(t)
179 | })
180 | }
181 | }
182 |
183 | func TestHandleUpdateEnvironmentUserAccesses(t *testing.T) {
184 | tests := []struct {
185 | name string
186 | inputID int
187 | inputAccesses map[int]string
188 | mockError error
189 | expectError bool
190 | setupParams func(request *mcp.CallToolRequest)
191 | }{
192 | {
193 | name: "successful user accesses update",
194 | inputID: 1,
195 | inputAccesses: map[int]string{
196 | 1: "environment_administrator",
197 | 2: "standard_user",
198 | },
199 | mockError: nil,
200 | expectError: false,
201 | setupParams: func(request *mcp.CallToolRequest) {
202 | request.Params.Arguments = map[string]any{
203 | "id": float64(1),
204 | "userAccesses": []any{
205 | map[string]any{"id": float64(1), "access": "environment_administrator"},
206 | map[string]any{"id": float64(2), "access": "standard_user"},
207 | },
208 | }
209 | },
210 | },
211 | {
212 | name: "api error",
213 | inputID: 1,
214 | inputAccesses: map[int]string{
215 | 1: "environment_administrator",
216 | },
217 | mockError: fmt.Errorf("api error"),
218 | expectError: true,
219 | setupParams: func(request *mcp.CallToolRequest) {
220 | request.Params.Arguments = map[string]any{
221 | "id": float64(1),
222 | "userAccesses": []any{
223 | map[string]any{"id": float64(1), "access": "environment_administrator"},
224 | },
225 | }
226 | },
227 | },
228 | {
229 | name: "missing id parameter",
230 | inputID: 0,
231 | mockError: nil,
232 | expectError: true,
233 | setupParams: func(request *mcp.CallToolRequest) {
234 | request.Params.Arguments = map[string]any{
235 | "userAccesses": []any{
236 | map[string]any{"id": float64(1), "access": "environment_administrator"},
237 | },
238 | }
239 | },
240 | },
241 | {
242 | name: "missing userAccesses parameter",
243 | inputID: 1,
244 | mockError: nil,
245 | expectError: true,
246 | setupParams: func(request *mcp.CallToolRequest) {
247 | request.Params.Arguments = map[string]any{
248 | "id": float64(1),
249 | }
250 | },
251 | },
252 | {
253 | name: "invalid access level",
254 | inputID: 1,
255 | inputAccesses: map[int]string{
256 | 1: "invalid_access",
257 | },
258 | mockError: nil,
259 | expectError: true,
260 | setupParams: func(request *mcp.CallToolRequest) {
261 | request.Params.Arguments = map[string]any{
262 | "id": float64(1),
263 | "userAccesses": []any{
264 | map[string]any{"id": float64(1), "access": "invalid_access"},
265 | },
266 | }
267 | },
268 | },
269 | }
270 |
271 | for _, tt := range tests {
272 | t.Run(tt.name, func(t *testing.T) {
273 | mockClient := &MockPortainerClient{}
274 | if !tt.expectError || tt.mockError != nil {
275 | mockClient.On("UpdateEnvironmentUserAccesses", tt.inputID, tt.inputAccesses).Return(tt.mockError)
276 | }
277 |
278 | server := &PortainerMCPServer{
279 | cli: mockClient,
280 | }
281 |
282 | request := CreateMCPRequest(map[string]any{})
283 | tt.setupParams(&request)
284 |
285 | handler := server.HandleUpdateEnvironmentUserAccesses()
286 | result, err := handler(context.Background(), request)
287 |
288 | if tt.expectError {
289 | assert.NoError(t, err)
290 | assert.NotNil(t, result)
291 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
292 | assert.Len(t, result.Content, 1)
293 | textContent, ok := result.Content[0].(mcp.TextContent)
294 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
295 | if tt.mockError != nil {
296 | assert.Contains(t, textContent.Text, tt.mockError.Error())
297 | } else {
298 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
299 | if strings.Contains(tt.name, "invalid access level") {
300 | assert.Contains(t, textContent.Text, "invalid user accesses")
301 | }
302 | }
303 | } else {
304 | assert.NoError(t, err)
305 | assert.Len(t, result.Content, 1)
306 | textContent, ok := result.Content[0].(mcp.TextContent)
307 | assert.True(t, ok)
308 | assert.Contains(t, textContent.Text, "successfully")
309 | }
310 |
311 | mockClient.AssertExpectations(t)
312 | })
313 | }
314 | }
315 |
316 | func TestHandleUpdateEnvironmentTeamAccesses(t *testing.T) {
317 | tests := []struct {
318 | name string
319 | inputID int
320 | inputAccesses map[int]string
321 | mockError error
322 | expectError bool
323 | setupParams func(request *mcp.CallToolRequest)
324 | }{
325 | {
326 | name: "successful team accesses update",
327 | inputID: 1,
328 | inputAccesses: map[int]string{
329 | 1: "environment_administrator",
330 | 2: "standard_user",
331 | },
332 | mockError: nil,
333 | expectError: false,
334 | setupParams: func(request *mcp.CallToolRequest) {
335 | request.Params.Arguments = map[string]any{
336 | "id": float64(1),
337 | "teamAccesses": []any{
338 | map[string]any{"id": float64(1), "access": "environment_administrator"},
339 | map[string]any{"id": float64(2), "access": "standard_user"},
340 | },
341 | }
342 | },
343 | },
344 | {
345 | name: "api error",
346 | inputID: 1,
347 | inputAccesses: map[int]string{
348 | 1: "environment_administrator",
349 | },
350 | mockError: fmt.Errorf("api error"),
351 | expectError: true,
352 | setupParams: func(request *mcp.CallToolRequest) {
353 | request.Params.Arguments = map[string]any{
354 | "id": float64(1),
355 | "teamAccesses": []any{
356 | map[string]any{"id": float64(1), "access": "environment_administrator"},
357 | },
358 | }
359 | },
360 | },
361 | {
362 | name: "missing id parameter",
363 | inputID: 0,
364 | mockError: nil,
365 | expectError: true,
366 | setupParams: func(request *mcp.CallToolRequest) {
367 | request.Params.Arguments = map[string]any{
368 | "teamAccesses": []any{
369 | map[string]any{"id": float64(1), "access": "environment_administrator"},
370 | },
371 | }
372 | },
373 | },
374 | {
375 | name: "missing teamAccesses parameter",
376 | inputID: 1,
377 | mockError: nil,
378 | expectError: true,
379 | setupParams: func(request *mcp.CallToolRequest) {
380 | request.Params.Arguments = map[string]any{
381 | "id": float64(1),
382 | }
383 | },
384 | },
385 | {
386 | name: "invalid access level",
387 | inputID: 1,
388 | inputAccesses: map[int]string{
389 | 1: "invalid_access",
390 | },
391 | mockError: nil,
392 | expectError: true,
393 | setupParams: func(request *mcp.CallToolRequest) {
394 | request.Params.Arguments = map[string]any{
395 | "id": float64(1),
396 | "teamAccesses": []any{
397 | map[string]any{"id": float64(1), "access": "invalid_access"},
398 | },
399 | }
400 | },
401 | },
402 | }
403 |
404 | for _, tt := range tests {
405 | t.Run(tt.name, func(t *testing.T) {
406 | mockClient := &MockPortainerClient{}
407 | if !tt.expectError || tt.mockError != nil {
408 | mockClient.On("UpdateEnvironmentTeamAccesses", tt.inputID, tt.inputAccesses).Return(tt.mockError)
409 | }
410 |
411 | server := &PortainerMCPServer{
412 | cli: mockClient,
413 | }
414 |
415 | request := CreateMCPRequest(map[string]any{})
416 | tt.setupParams(&request)
417 |
418 | handler := server.HandleUpdateEnvironmentTeamAccesses()
419 | result, err := handler(context.Background(), request)
420 |
421 | if tt.expectError {
422 | assert.NoError(t, err)
423 | assert.NotNil(t, result)
424 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
425 | assert.Len(t, result.Content, 1)
426 | textContent, ok := result.Content[0].(mcp.TextContent)
427 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
428 | if tt.mockError != nil {
429 | assert.Contains(t, textContent.Text, tt.mockError.Error())
430 | } else {
431 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
432 | if strings.Contains(tt.name, "invalid access level") {
433 | assert.Contains(t, textContent.Text, "invalid team accesses")
434 | }
435 | }
436 | } else {
437 | assert.NoError(t, err)
438 | assert.Len(t, result.Content, 1)
439 | textContent, ok := result.Content[0].(mcp.TextContent)
440 | assert.True(t, ok)
441 | assert.Contains(t, textContent.Text, "successfully")
442 | }
443 |
444 | mockClient.AssertExpectations(t)
445 | })
446 | }
447 | }
448 |
```
--------------------------------------------------------------------------------
/internal/mcp/stack_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "testing"
8 |
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/portainer/portainer-mcp/pkg/portainer/models"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func TestHandleGetStacks(t *testing.T) {
15 | tests := []struct {
16 | name string
17 | mockStacks []models.Stack
18 | mockError error
19 | expectError bool
20 | }{
21 | {
22 | name: "successful stacks retrieval",
23 | mockStacks: []models.Stack{
24 | {ID: 1, Name: "stack1"},
25 | {ID: 2, Name: "stack2"},
26 | },
27 | mockError: nil,
28 | expectError: false,
29 | },
30 | {
31 | name: "api error",
32 | mockStacks: nil,
33 | mockError: fmt.Errorf("api error"),
34 | expectError: true,
35 | },
36 | }
37 |
38 | for _, tt := range tests {
39 | t.Run(tt.name, func(t *testing.T) {
40 | mockClient := &MockPortainerClient{}
41 | mockClient.On("GetStacks").Return(tt.mockStacks, tt.mockError)
42 |
43 | server := &PortainerMCPServer{
44 | cli: mockClient,
45 | }
46 |
47 | handler := server.HandleGetStacks()
48 | result, err := handler(context.Background(), mcp.CallToolRequest{})
49 |
50 | if tt.expectError {
51 | assert.NoError(t, err)
52 | assert.NotNil(t, result)
53 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
54 | assert.Len(t, result.Content, 1)
55 | textContent, ok := result.Content[0].(mcp.TextContent)
56 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
57 | if tt.mockError != nil {
58 | assert.Contains(t, textContent.Text, tt.mockError.Error())
59 | } else {
60 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
61 | }
62 | } else {
63 | assert.NoError(t, err)
64 | assert.Len(t, result.Content, 1)
65 | textContent, ok := result.Content[0].(mcp.TextContent)
66 | assert.True(t, ok)
67 |
68 | var stacks []models.Stack
69 | err = json.Unmarshal([]byte(textContent.Text), &stacks)
70 | assert.NoError(t, err)
71 | assert.Equal(t, tt.mockStacks, stacks)
72 | }
73 |
74 | mockClient.AssertExpectations(t)
75 | })
76 | }
77 | }
78 |
79 | func TestHandleGetStackFile(t *testing.T) {
80 | tests := []struct {
81 | name string
82 | inputID int
83 | mockContent string
84 | mockError error
85 | expectError bool
86 | setupParams func(request *mcp.CallToolRequest)
87 | }{
88 | {
89 | name: "successful file retrieval",
90 | inputID: 1,
91 | mockContent: "version: '3'\nservices:\n web:\n image: nginx",
92 | mockError: nil,
93 | expectError: false,
94 | setupParams: func(request *mcp.CallToolRequest) {
95 | request.Params.Arguments = map[string]any{
96 | "id": float64(1),
97 | }
98 | },
99 | },
100 | {
101 | name: "api error",
102 | inputID: 1,
103 | mockContent: "",
104 | mockError: fmt.Errorf("api error"),
105 | expectError: true,
106 | setupParams: func(request *mcp.CallToolRequest) {
107 | request.Params.Arguments = map[string]any{
108 | "id": float64(1),
109 | }
110 | },
111 | },
112 | {
113 | name: "missing id parameter",
114 | inputID: 0,
115 | mockContent: "",
116 | mockError: nil,
117 | expectError: true,
118 | setupParams: func(request *mcp.CallToolRequest) {
119 | // No need to set any parameters as the request will be invalid
120 | },
121 | },
122 | }
123 |
124 | for _, tt := range tests {
125 | t.Run(tt.name, func(t *testing.T) {
126 | mockClient := &MockPortainerClient{}
127 | if !tt.expectError || tt.mockError != nil {
128 | mockClient.On("GetStackFile", tt.inputID).Return(tt.mockContent, tt.mockError)
129 | }
130 |
131 | server := &PortainerMCPServer{
132 | cli: mockClient,
133 | }
134 |
135 | request := CreateMCPRequest(map[string]any{})
136 | tt.setupParams(&request)
137 |
138 | handler := server.HandleGetStackFile()
139 | result, err := handler(context.Background(), request)
140 |
141 | if tt.expectError {
142 | assert.NoError(t, err)
143 | assert.NotNil(t, result)
144 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
145 | assert.Len(t, result.Content, 1)
146 | textContent, ok := result.Content[0].(mcp.TextContent)
147 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
148 | if tt.mockError != nil {
149 | assert.Contains(t, textContent.Text, tt.mockError.Error())
150 | } else {
151 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
152 | }
153 | } else {
154 | assert.NoError(t, err)
155 | assert.Len(t, result.Content, 1)
156 | textContent, ok := result.Content[0].(mcp.TextContent)
157 | assert.True(t, ok)
158 | assert.Equal(t, tt.mockContent, textContent.Text)
159 | }
160 |
161 | mockClient.AssertExpectations(t)
162 | })
163 | }
164 | }
165 |
166 | func TestHandleCreateStack(t *testing.T) {
167 | tests := []struct {
168 | name string
169 | inputName string
170 | inputFile string
171 | inputEnvGroupIDs []int
172 | mockID int
173 | mockError error
174 | expectError bool
175 | setupParams func(request *mcp.CallToolRequest)
176 | }{
177 | {
178 | name: "successful stack creation",
179 | inputName: "test-stack",
180 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
181 | inputEnvGroupIDs: []int{1, 2},
182 | mockID: 1,
183 | mockError: nil,
184 | expectError: false,
185 | setupParams: func(request *mcp.CallToolRequest) {
186 | request.Params.Arguments = map[string]any{
187 | "name": "test-stack",
188 | "file": "version: '3'\nservices:\n web:\n image: nginx",
189 | "environmentGroupIds": []any{float64(1), float64(2)},
190 | }
191 | },
192 | },
193 | {
194 | name: "api error",
195 | inputName: "test-stack",
196 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
197 | inputEnvGroupIDs: []int{1, 2},
198 | mockID: 0,
199 | mockError: fmt.Errorf("api error"),
200 | expectError: true,
201 | setupParams: func(request *mcp.CallToolRequest) {
202 | request.Params.Arguments = map[string]any{
203 | "name": "test-stack",
204 | "file": "version: '3'\nservices:\n web:\n image: nginx",
205 | "environmentGroupIds": []any{float64(1), float64(2)},
206 | }
207 | },
208 | },
209 | {
210 | name: "missing name parameter",
211 | inputName: "",
212 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
213 | inputEnvGroupIDs: []int{1, 2},
214 | mockID: 0,
215 | mockError: nil,
216 | expectError: true,
217 | setupParams: func(request *mcp.CallToolRequest) {
218 | request.Params.Arguments = map[string]any{
219 | "file": "version: '3'\nservices:\n web:\n image: nginx",
220 | "environmentGroupIds": []any{float64(1), float64(2)},
221 | }
222 | },
223 | },
224 | {
225 | name: "missing file parameter",
226 | inputName: "test-stack",
227 | inputFile: "",
228 | inputEnvGroupIDs: []int{1, 2},
229 | mockID: 0,
230 | mockError: nil,
231 | expectError: true,
232 | setupParams: func(request *mcp.CallToolRequest) {
233 | request.Params.Arguments = map[string]any{
234 | "name": "test-stack",
235 | "environmentGroupIds": []any{float64(1), float64(2)},
236 | }
237 | },
238 | },
239 | {
240 | name: "missing environmentGroupIds parameter",
241 | inputName: "test-stack",
242 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
243 | inputEnvGroupIDs: nil,
244 | mockID: 0,
245 | mockError: nil,
246 | expectError: true,
247 | setupParams: func(request *mcp.CallToolRequest) {
248 | request.Params.Arguments = map[string]any{
249 | "name": "test-stack",
250 | "file": "version: '3'\nservices:\n web:\n image: nginx",
251 | }
252 | },
253 | },
254 | }
255 |
256 | for _, tt := range tests {
257 | t.Run(tt.name, func(t *testing.T) {
258 | mockClient := &MockPortainerClient{}
259 | if !tt.expectError || tt.mockError != nil {
260 | mockClient.On("CreateStack", tt.inputName, tt.inputFile, tt.inputEnvGroupIDs).Return(tt.mockID, tt.mockError)
261 | }
262 |
263 | server := &PortainerMCPServer{
264 | cli: mockClient,
265 | }
266 |
267 | request := CreateMCPRequest(map[string]any{})
268 | tt.setupParams(&request)
269 |
270 | handler := server.HandleCreateStack()
271 | result, err := handler(context.Background(), request)
272 |
273 | if tt.expectError {
274 | assert.NoError(t, err)
275 | assert.NotNil(t, result)
276 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
277 | assert.Len(t, result.Content, 1)
278 | textContent, ok := result.Content[0].(mcp.TextContent)
279 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
280 | if tt.mockError != nil {
281 | assert.Contains(t, textContent.Text, tt.mockError.Error())
282 | } else {
283 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
284 | }
285 | } else {
286 | assert.NoError(t, err)
287 | assert.Len(t, result.Content, 1)
288 | textContent, ok := result.Content[0].(mcp.TextContent)
289 | assert.True(t, ok)
290 | assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
291 | }
292 |
293 | mockClient.AssertExpectations(t)
294 | })
295 | }
296 | }
297 |
298 | func TestHandleUpdateStack(t *testing.T) {
299 | tests := []struct {
300 | name string
301 | inputID int
302 | inputFile string
303 | inputEnvGroupIDs []int
304 | mockError error
305 | expectError bool
306 | setupParams func(request *mcp.CallToolRequest)
307 | }{
308 | {
309 | name: "successful stack update",
310 | inputID: 1,
311 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
312 | inputEnvGroupIDs: []int{1, 2},
313 | mockError: nil,
314 | expectError: false,
315 | setupParams: func(request *mcp.CallToolRequest) {
316 | request.Params.Arguments = map[string]any{
317 | "id": float64(1),
318 | "file": "version: '3'\nservices:\n web:\n image: nginx",
319 | "environmentGroupIds": []any{float64(1), float64(2)},
320 | }
321 | },
322 | },
323 | {
324 | name: "api error",
325 | inputID: 1,
326 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
327 | inputEnvGroupIDs: []int{1, 2},
328 | mockError: fmt.Errorf("api error"),
329 | expectError: true,
330 | setupParams: func(request *mcp.CallToolRequest) {
331 | request.Params.Arguments = map[string]any{
332 | "id": float64(1),
333 | "file": "version: '3'\nservices:\n web:\n image: nginx",
334 | "environmentGroupIds": []any{float64(1), float64(2)},
335 | }
336 | },
337 | },
338 | {
339 | name: "missing id parameter",
340 | inputID: 0,
341 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
342 | inputEnvGroupIDs: []int{1, 2},
343 | mockError: nil,
344 | expectError: true,
345 | setupParams: func(request *mcp.CallToolRequest) {
346 | request.Params.Arguments = map[string]any{
347 | "file": "version: '3'\nservices:\n web:\n image: nginx",
348 | "environmentGroupIds": []any{float64(1), float64(2)},
349 | }
350 | },
351 | },
352 | {
353 | name: "missing file parameter",
354 | inputID: 1,
355 | inputFile: "",
356 | inputEnvGroupIDs: []int{1, 2},
357 | mockError: nil,
358 | expectError: true,
359 | setupParams: func(request *mcp.CallToolRequest) {
360 | request.Params.Arguments = map[string]any{
361 | "id": float64(1),
362 | "environmentGroupIds": []any{float64(1), float64(2)},
363 | }
364 | },
365 | },
366 | {
367 | name: "missing environmentGroupIds parameter",
368 | inputID: 1,
369 | inputFile: "version: '3'\nservices:\n web:\n image: nginx",
370 | inputEnvGroupIDs: nil,
371 | mockError: nil,
372 | expectError: true,
373 | setupParams: func(request *mcp.CallToolRequest) {
374 | request.Params.Arguments = map[string]any{
375 | "id": float64(1),
376 | "file": "version: '3'\nservices:\n web:\n image: nginx",
377 | }
378 | },
379 | },
380 | }
381 |
382 | for _, tt := range tests {
383 | t.Run(tt.name, func(t *testing.T) {
384 | mockClient := &MockPortainerClient{}
385 | if !tt.expectError || tt.mockError != nil {
386 | mockClient.On("UpdateStack", tt.inputID, tt.inputFile, tt.inputEnvGroupIDs).Return(tt.mockError)
387 | }
388 |
389 | server := &PortainerMCPServer{
390 | cli: mockClient,
391 | }
392 |
393 | request := CreateMCPRequest(map[string]any{})
394 | tt.setupParams(&request)
395 |
396 | handler := server.HandleUpdateStack()
397 | result, err := handler(context.Background(), request)
398 |
399 | if tt.expectError {
400 | assert.NoError(t, err)
401 | assert.NotNil(t, result)
402 | assert.True(t, result.IsError, "result.IsError should be true for expected errors")
403 | assert.Len(t, result.Content, 1)
404 | textContent, ok := result.Content[0].(mcp.TextContent)
405 | assert.True(t, ok, "Result content should be mcp.TextContent for errors")
406 | if tt.mockError != nil {
407 | assert.Contains(t, textContent.Text, tt.mockError.Error())
408 | } else {
409 | assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
410 | }
411 | } else {
412 | assert.NoError(t, err)
413 | assert.Len(t, result.Content, 1)
414 | textContent, ok := result.Content[0].(mcp.TextContent)
415 | assert.True(t, ok)
416 | assert.Contains(t, textContent.Text, "successfully")
417 | }
418 |
419 | mockClient.AssertExpectations(t)
420 | })
421 | }
422 | }
423 |
```