Skip to content

Commit 0b094e1

Browse files
committed
fix(cursor): Fix members collector infinite loop
* Set collectMembers PageSize to zero so teams/members is fetched once instead of re-inserting the full roster forever. * Extend connection test to probe members, spend, and usage events Admin API permissions before pipelines run.
1 parent 740425b commit 0b094e1

3 files changed

Lines changed: 203 additions & 30 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package service
19+
20+
import (
21+
"io"
22+
"net/http"
23+
"strings"
24+
"testing"
25+
26+
"github.com/stretchr/testify/require"
27+
)
28+
29+
func TestBuildPermissionFailureMessage(t *testing.T) {
30+
message := buildPermissionFailureMessage([]string{
31+
"Cannot read team billing spend (teams/spend): forbidden.",
32+
"Cannot read filtered usage events (teams/filtered-usage-events): unauthorized.",
33+
})
34+
require.Contains(t, message, "missing required permissions")
35+
require.Contains(t, message, "teams/spend")
36+
require.Contains(t, message, "filtered-usage-events")
37+
}
38+
39+
func TestBuildAdminApiError_Unauthorized(t *testing.T) {
40+
res := &http.Response{
41+
StatusCode: http.StatusUnauthorized,
42+
Body: io.NopCloser(strings.NewReader(`{"message":"invalid key"}`)),
43+
}
44+
err := buildAdminApiError(spendEndpoint, "read team billing spend", res)
45+
require.Error(t, err)
46+
require.Contains(t, err.Error(), "Team Admin API key")
47+
require.Contains(t, err.Error(), "teams/spend")
48+
}
49+
50+
func TestBuildAdminApiError_Forbidden(t *testing.T) {
51+
res := &http.Response{
52+
StatusCode: http.StatusForbidden,
53+
Body: io.NopCloser(strings.NewReader(`{"message":"forbidden"}`)),
54+
}
55+
err := buildAdminApiError(usageEventsEndpoint, "read filtered usage events", res)
56+
require.Error(t, err)
57+
require.Contains(t, err.Error(), "forbidden")
58+
require.Contains(t, err.Error(), "filtered-usage-events")
59+
}

backend/plugins/cursor/service/connection_test_helper.go

Lines changed: 143 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,37 @@ import (
2424
"io"
2525
"net/http"
2626
"strings"
27+
"time"
2728

2829
corectx "github.com/apache/incubator-devlake/core/context"
2930
"github.com/apache/incubator-devlake/core/errors"
3031
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
3132
"github.com/apache/incubator-devlake/plugins/cursor/models"
3233
)
3334

35+
const (
36+
membersEndpoint = "teams/members"
37+
spendEndpoint = "teams/spend"
38+
usageEventsEndpoint = "teams/filtered-usage-events"
39+
)
40+
41+
// AdminApiPermissions reports which Cursor Admin API endpoints the key can access.
42+
type AdminApiPermissions struct {
43+
Members bool `json:"members"`
44+
Spend bool `json:"spend"`
45+
UsageEvents bool `json:"usageEvents"`
46+
}
47+
3448
// TestConnectionResult represents the payload returned by the connection test endpoints.
3549
type TestConnectionResult struct {
36-
Success bool `json:"success"`
37-
Message string `json:"message"`
38-
MemberCount int `json:"memberCount,omitempty"`
39-
HasEnterpriseAnalytics bool `json:"hasEnterpriseAnalytics,omitempty"`
50+
Success bool `json:"success"`
51+
Message string `json:"message"`
52+
MemberCount int `json:"memberCount,omitempty"`
53+
Permissions AdminApiPermissions `json:"permissions,omitempty"`
54+
HasEnterpriseAnalytics bool `json:"hasEnterpriseAnalytics,omitempty"`
4055
}
4156

42-
// TestConnection exercises the Cursor Admin API to validate credentials.
57+
// TestConnection exercises the Cursor Admin API to validate credentials and permissions.
4358
func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models.CursorConnection) (*TestConnectionResult, errors.Error) {
4459
if connection == nil {
4560
return nil, errors.BadInput.New("connection is required")
@@ -54,6 +69,10 @@ func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models.
5469
if err != nil {
5570
return nil, err
5671
}
72+
apiClient.SetHeaders(map[string]string{
73+
"Accept": "application/json",
74+
"Content-Type": "application/json",
75+
})
5776

5877
if userKeyErr := detectUserApiKey(apiClient); userKeyErr != nil {
5978
return &TestConnectionResult{
@@ -62,50 +81,145 @@ func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models.
6281
}, nil
6382
}
6483

65-
res, err := apiClient.Get("teams/members", nil, nil)
66-
if err != nil {
67-
return nil, errors.Default.Wrap(err, "failed to reach Cursor Admin API")
84+
permissions := AdminApiPermissions{}
85+
var failures []string
86+
87+
memberCount, membersErr := probeMembers(apiClient)
88+
permissions.Members = membersErr == nil
89+
if membersErr != nil {
90+
failures = append(failures, membersErr.Error())
6891
}
69-
defer res.Body.Close()
7092

71-
if res.StatusCode == http.StatusUnauthorized {
93+
if spendErr := probeSpend(apiClient); spendErr != nil {
94+
permissions.Spend = false
95+
failures = append(failures, spendErr.Error())
96+
} else {
97+
permissions.Spend = true
98+
}
99+
100+
if usageErr := probeUsageEvents(apiClient); usageErr != nil {
101+
permissions.UsageEvents = false
102+
failures = append(failures, usageErr.Error())
103+
} else {
104+
permissions.UsageEvents = true
105+
}
106+
107+
if len(failures) > 0 {
72108
return &TestConnectionResult{
73-
Success: false,
74-
Message: "Invalid Team API key. Create a Team Admin key in Dashboard → API Keys (admin:* scope), not a User API key from Settings → Integrations.",
109+
Success: false,
110+
Message: buildPermissionFailureMessage(failures),
111+
MemberCount: memberCount,
112+
Permissions: permissions,
75113
}, nil
76114
}
77115

116+
hasEnterpriseAnalytics := probeEnterpriseAnalytics(apiClient)
117+
118+
return &TestConnectionResult{
119+
Success: true,
120+
Message: "Team Admin API key validated. Members, spend, and usage events are accessible.",
121+
MemberCount: memberCount,
122+
Permissions: permissions,
123+
HasEnterpriseAnalytics: hasEnterpriseAnalytics,
124+
}, nil
125+
}
126+
127+
func probeMembers(apiClient *helper.ApiClient) (int, errors.Error) {
128+
res, err := apiClient.Get(membersEndpoint, nil, nil)
129+
if err != nil {
130+
return 0, errors.Default.Wrap(err, "failed to reach Cursor Admin API")
131+
}
132+
defer res.Body.Close()
133+
78134
if res.StatusCode != http.StatusOK {
79-
body, _ := io.ReadAll(res.Body)
80-
return &TestConnectionResult{
81-
Success: false,
82-
Message: fmt.Sprintf("unexpected status %d: %s", res.StatusCode, string(body)),
83-
}, nil
135+
return 0, buildAdminApiError(membersEndpoint, "list team members", res)
84136
}
85137

86138
body, readErr := io.ReadAll(res.Body)
87139
if readErr != nil {
88-
return nil, errors.Default.Wrap(readErr, "failed to read response body")
140+
return 0, errors.Default.Wrap(readErr, "failed to read members response")
89141
}
90142

91143
var response struct {
92144
TeamMembers []json.RawMessage `json:"teamMembers"`
93145
}
94146
if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
95-
return &TestConnectionResult{
96-
Success: false,
97-
Message: fmt.Sprintf("failed to parse response: %v", jsonErr),
98-
}, nil
147+
return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to parse members response")
99148
}
100149

101-
hasEnterpriseAnalytics := probeEnterpriseAnalytics(apiClient)
150+
return len(response.TeamMembers), nil
151+
}
102152

103-
return &TestConnectionResult{
104-
Success: true,
105-
Message: "Connection validated successfully",
106-
MemberCount: len(response.TeamMembers),
107-
HasEnterpriseAnalytics: hasEnterpriseAnalytics,
108-
}, nil
153+
func probeSpend(apiClient *helper.ApiClient) errors.Error {
154+
res, err := apiClient.Post(spendEndpoint, nil, map[string]interface{}{
155+
"page": 1,
156+
"pageSize": 1,
157+
}, nil)
158+
if err != nil {
159+
return errors.Default.Wrap(err, "failed to reach Cursor spend API")
160+
}
161+
defer res.Body.Close()
162+
163+
if res.StatusCode != http.StatusOK {
164+
return buildAdminApiError(spendEndpoint, "read team billing spend", res)
165+
}
166+
return nil
167+
}
168+
169+
func probeUsageEvents(apiClient *helper.ApiClient) errors.Error {
170+
endMs := time.Now().UTC().UnixMilli()
171+
startMs := endMs - int64(7*24*time.Hour/time.Millisecond)
172+
173+
res, err := apiClient.Post(usageEventsEndpoint, nil, map[string]interface{}{
174+
"startDate": startMs,
175+
"endDate": endMs,
176+
"page": 1,
177+
"pageSize": 1,
178+
}, nil)
179+
if err != nil {
180+
return errors.Default.Wrap(err, "failed to reach Cursor usage events API")
181+
}
182+
defer res.Body.Close()
183+
184+
if res.StatusCode != http.StatusOK {
185+
return buildAdminApiError(usageEventsEndpoint, "read filtered usage events", res)
186+
}
187+
return nil
188+
}
189+
190+
func buildAdminApiError(endpoint, action string, res *http.Response) errors.Error {
191+
body, _ := io.ReadAll(res.Body)
192+
detail := strings.TrimSpace(string(body))
193+
if detail != "" && len(detail) > 200 {
194+
detail = detail[:200] + "..."
195+
}
196+
197+
switch res.StatusCode {
198+
case http.StatusUnauthorized:
199+
return errors.BadInput.New(fmt.Sprintf(
200+
"Cannot %s (%s): unauthorized. Use a Team Admin API key from Dashboard → API Keys (admin:* scope), not a User API key from Settings → Integrations.",
201+
action, endpoint,
202+
))
203+
case http.StatusForbidden:
204+
msg := fmt.Sprintf("Cannot %s (%s): forbidden. The key may lack admin permissions for this team.", action, endpoint)
205+
if detail != "" {
206+
msg = fmt.Sprintf("%s Details: %s", msg, detail)
207+
}
208+
return errors.BadInput.New(msg)
209+
default:
210+
msg := fmt.Sprintf("Cannot %s (%s): unexpected status %d", action, endpoint, res.StatusCode)
211+
if detail != "" {
212+
msg = fmt.Sprintf("%s. Details: %s", msg, detail)
213+
}
214+
return errors.BadInput.New(msg)
215+
}
216+
}
217+
218+
func buildPermissionFailureMessage(failures []string) string {
219+
if len(failures) == 1 {
220+
return failures[0]
221+
}
222+
return "Team Admin API key is missing required permissions:\n- " + strings.Join(failures, "\n- ")
109223
}
110224

111225
func detectUserApiKey(apiClient *helper.ApiClient) errors.Error {

backend/plugins/cursor/tasks/members_collector.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func CollectMembers(taskCtx plugin.SubTaskContext) errors.Error {
5050
collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{
5151
RawDataSubTaskArgs: rawArgs,
5252
ApiClient: apiClient,
53-
PageSize: 1,
53+
PageSize: 0,
5454
UrlTemplate: "teams/members",
5555
ResponseParser: parseMembersResponse,
5656
})

0 commit comments

Comments
 (0)