-
Notifications
You must be signed in to change notification settings - Fork 0
test(mcp): full tool-surface e2e suite against fake API #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b5a161
ec7b505
d467b09
654b4a2
7e7098c
692b429
0ffdbcb
4d1c675
338e943
eae9585
7161319
d704efc
938936f
0c423dd
e6e92f2
caa06d1
218d08f
4c45da2
5ab7a36
532c144
afb6373
fc7aac1
cc2a750
27467a2
37e1c24
ecb7ca8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,16 +25,30 @@ type Server struct { | |
| Tokens map[string]*AccountInfoResponse | ||
| // accounts stores registered accounts keyed by email. | ||
| accounts map[string]*AccountInfoResponse | ||
| // passwords stores each account's current password keyed by email, so the | ||
| // update-email and update-password endpoints can verify the current | ||
| // password before mutating the account (mirrors the real API contract). | ||
| passwords map[string]string | ||
| // operations holds seeded account operations (GET /api/operations). | ||
| operations []OperationDetailResponse | ||
| // nextID is the next account id. | ||
| nextID int | ||
| } | ||
|
|
||
| // DefaultPassword is the password assigned to accounts created via Seed (which | ||
| // takes no password argument). The e2e harness references it when driving the | ||
| // account_update_email / account_update_password tools against the seeded | ||
| // account. Accounts registered via the register endpoint store the password | ||
| // supplied in the request body instead. | ||
| const DefaultPassword = "password" | ||
|
|
||
| // NewServer returns a fake account API double with empty state. | ||
| func NewServer() *Server { | ||
| return &Server{ | ||
| Tokens: map[string]*AccountInfoResponse{}, | ||
| accounts: map[string]*AccountInfoResponse{}, | ||
| nextID: 1, | ||
| Tokens: map[string]*AccountInfoResponse{}, | ||
| accounts: map[string]*AccountInfoResponse{}, | ||
| passwords: map[string]string{}, | ||
| nextID: 1, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -81,6 +95,7 @@ func (s *Server) PostApiAuthRegister(w http.ResponseWriter, r *http.Request) { | |
| } | ||
| s.nextID++ | ||
| s.accounts[acc.Email] = acc | ||
| s.passwords[acc.Email] = body.Password | ||
| // give the new account a token | ||
| tok := "token-" + acc.Email | ||
| s.Tokens[tok] = acc | ||
|
|
@@ -115,7 +130,28 @@ func (s *Server) GetApiAccount(w http.ResponseWriter, r *http.Request) { | |
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| writeJSON(w, http.StatusOK, acc) | ||
| // authorize() hands back the aliased pointer that concurrent | ||
| // PostApiAccountUpdateEmail mutates under the lock. Serialize a copy taken | ||
| // under the lock so this handler never reads the account mid-mutation. | ||
| s.mu.Lock() | ||
| cp := *acc | ||
| s.mu.Unlock() | ||
| writeJSON(w, http.StatusOK, &cp) | ||
| } | ||
|
|
||
| // PostApiAuthPing checks that the request is authenticated and returns a pong | ||
| // response. pinner's auth_status op pings this endpoint to confirm the stored | ||
| // token is valid, so it must be implemented for the status contract to hold. | ||
| func (s *Server) PostApiAuthPing(w http.ResponseWriter, r *http.Request) { | ||
| acc := s.authorize(r) | ||
| if acc == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| auth := r.Header.Get("Authorization") | ||
| const prefix = "Bearer " | ||
| token := strings.TrimPrefix(auth, prefix) | ||
| writeJSON(w, http.StatusOK, PongResponse{Ping: "pong", Token: token}) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sensitive token exposure: the Kody rule violation: Ban hard-coded secrets in Go source Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declining: this is a test-only fake (internal/mcptest). The echoed token is the deterministic non-secret fixture value "token-e2e@example.com" (not a real credential), and the PongResponse schema's Token field is part of the generated API contract this fake simulates. No production code path is involved. |
||
| } | ||
|
|
||
| // GetApiAccountKeys lists the authenticated account's API keys. | ||
|
|
@@ -147,6 +183,98 @@ func (s *Server) PostApiAccountKeys(w http.ResponseWriter, r *http.Request) { | |
| }) | ||
| } | ||
|
|
||
| // GetApiAccountBillingSubscription returns the authenticated account's | ||
| // subscription status. The fake models a deterministic "not subscribed" | ||
| // account (no active plan period, no gateway) so account_subscription reports | ||
| // the free tier. | ||
| func (s *Server) GetApiAccountBillingSubscription(w http.ResponseWriter, r *http.Request) { | ||
| if s.authorize(r) == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| writeJSON(w, http.StatusOK, SubscriptionStatusResponse{ | ||
| IsSubscribed: false, | ||
| }) | ||
| } | ||
|
|
||
| // PostApiAccountUpdateEmail changes the authenticated account's email, | ||
| // verifying the current password first (mirroring the real API, which sends a | ||
| // verification email to the new address). On success the stored email is | ||
| // updated and the updated account is returned. | ||
| func (s *Server) PostApiAccountUpdateEmail(w http.ResponseWriter, r *http.Request) { | ||
| acc := s.authorize(r) | ||
| if acc == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| var body UpdateEmailRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | ||
| writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) | ||
| return | ||
| } | ||
| if body.Email == "" { | ||
| writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) | ||
| return | ||
| } | ||
| if !s.verifyPassword(acc.Email, body.Password) { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid password"}) | ||
| return | ||
| } | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| oldEmail := acc.Email | ||
| if s.accounts[body.Email] != nil { | ||
| writeJSON(w, http.StatusConflict, map[string]string{"error": "account already exists"}) | ||
| return | ||
| } | ||
| // Move the account under its new email key, carry over the password, and | ||
| // mark the address changed (preserve the account id/pointer so existing | ||
| // bearer tokens keep authenticating). | ||
| delete(s.accounts, acc.Email) | ||
| acc.Email = body.Email | ||
| s.accounts[acc.Email] = acc | ||
| s.passwords[acc.Email] = s.passwords[oldEmail] | ||
| delete(s.passwords, oldEmail) | ||
| // acc is aliased (its pointer lives in s.accounts and s.Tokens); serialize a | ||
| // copy so a concurrent GetApiAccount reading it never races with this write. | ||
| cp := *acc | ||
| writeJSON(w, http.StatusOK, &cp) | ||
| } | ||
|
|
||
| // PostApiAccountUpdatePassword changes the authenticated account's password, | ||
| // verifying the current password first. | ||
| func (s *Server) PostApiAccountUpdatePassword(w http.ResponseWriter, r *http.Request) { | ||
| acc := s.authorize(r) | ||
| if acc == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| var body UpdatePasswordRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | ||
| writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) | ||
| return | ||
| } | ||
| if body.NewPassword == "" { | ||
| writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password is required"}) | ||
| return | ||
| } | ||
| if !s.verifyPassword(acc.Email, body.CurrentPassword) { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid current password"}) | ||
| return | ||
| } | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| s.passwords[acc.Email] = body.NewPassword | ||
| writeJSON(w, http.StatusOK, map[string]string{"message": "password updated"}) | ||
| } | ||
|
|
||
| // verifyPassword reports whether pw matches the account's stored password. | ||
| func (s *Server) verifyPassword(email, pw string) bool { | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| return s.passwords[email] == pw | ||
| } | ||
|
|
||
| // Seed registers a deterministic account (if not already present) and returns | ||
| // its bearer token. It lets an e2e harness pre-provision a valid session so | ||
| // pinner boots with a ready-made auth_token against the fake API. | ||
|
|
@@ -166,10 +294,144 @@ func (s *Server) Seed(email, firstName, lastName string) string { | |
| } | ||
| s.nextID++ | ||
| s.accounts[acc.Email] = acc | ||
| s.passwords[acc.Email] = DefaultPassword | ||
| } | ||
| tok := "token-" + acc.Email | ||
| s.Tokens[tok] = acc | ||
| return tok | ||
| } | ||
|
|
||
| // SeedOperations seeds a small deterministic set of account operations so the | ||
| // operations_* tools have real data to read (GET /api/operations). | ||
| func (s *Server) SeedOperations() { | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| now := time.Now().UTC() | ||
| s.operations = []OperationDetailResponse{ | ||
| { | ||
| Id: 1, | ||
| Operation: "pin", | ||
| OperationDisplayName: "Pin", | ||
| Protocol: "ipfs", | ||
| ProtocolDisplayName: "IPFS", | ||
| Status: "completed", | ||
| StatusDisplayName: "Completed", | ||
| StatusMessage: "Pinned successfully", | ||
| ProgressPercent: 100, | ||
| StartedAt: now.Add(-2 * time.Hour), | ||
| UpdatedAt: now.Add(-90 * time.Minute), | ||
| CurrentStep: intPtr(4), | ||
| TotalSteps: intPtr(4), | ||
| }, | ||
| { | ||
| Id: 2, | ||
| Operation: "upload", | ||
| OperationDisplayName: "Upload", | ||
| Protocol: "ipfs", | ||
| ProtocolDisplayName: "IPFS", | ||
| Status: "running", | ||
| StatusDisplayName: "Running", | ||
| StatusMessage: "Uploading file", | ||
| ProgressPercent: 45, | ||
| StartedAt: now.Add(-10 * time.Minute), | ||
| UpdatedAt: now, | ||
| CurrentStep: intPtr(2), | ||
| TotalSteps: intPtr(5), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // GetApiOperations lists account operations (GET /api/operations). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GetApiOperations receives GetApiOperationsParams but never reads FiltersStatusEq, FiltersIdEq, or _start/_end, so server-side filtering and pagination silently no-op. The SDK's OperationsServiceDefault.List forwards these filters as query params, causing the e2e test (tests/sunpeak/mcp-e2e/operations.test.ts:26-32) to fail its assertion. Honor the bound params by skipping operations that mismatch FiltersStatusEq/FiltersIdEq and applying _start/_end pagination before building the response. func (s *Server) GetApiOperations(w http.ResponseWriter, r *http.Request, params GetApiOperationsParams) {
if s.authorize(r) == nil { writeJSON(w, http.StatusUnauthorized, ...); return }
s.mu.Lock()
data := make([]OperationListItem, 0, len(s.operations))
for _, op := range s.operations {
if params.FiltersStatusEq != nil && op.Status != *(*params.FiltersStatusEq)["eq"] {
continue
}
if params.FiltersIdEq != nil && op.Id != *params.FiltersIdEq {
continue
}
data = append(data, itemFromOp(op))
}
total := len(data)
s.mu.Unlock()
writeJSON(w, http.StatusOK, OperationListItemResponse{Data: data, Total: total})
}Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| func (s *Server) GetApiOperations(w http.ResponseWriter, r *http.Request, params GetApiOperationsParams) { | ||
| if s.authorize(r) == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| s.mu.Lock() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GetApiOperations ignores all bound query params (status/operation/protocol/cid/search/sort/pagination) and returns every seeded operation unfiltered. The e2e test operations_list filters by status (tests/sunpeak/mcp-e2e/operations.test.ts:26-32) passes only by accident because the fake drops the StatusFilter that the SDK forwards as a server-side query param. Apply the status/operation/protocol/cid filters and pagination from params to the returned rows, mirroring the real API's behavior. data := make([]OperationListItem, 0, len(s.operations))
for _, op := range s.operations {
if params.Status != nil && string(op.Status) != *params.Status {
continue
}
item := OperationListItem{ ... }
data = append(data, item)
}
total := len(data)Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| data := make([]OperationListItem, 0, len(s.operations)) | ||
| for _, op := range s.operations { | ||
| item := OperationListItem{ | ||
| Cid: op.Cid, | ||
| CurrentStep: op.CurrentStep, | ||
| Error: op.Error, | ||
| EstimatedCompletionAt: op.EstimatedCompletionAt, | ||
| Id: op.Id, | ||
| Operation: op.Operation, | ||
| OperationDisplayName: op.OperationDisplayName, | ||
| ProgressPercent: op.ProgressPercent, | ||
| Protocol: op.Protocol, | ||
| ProtocolDisplayName: op.ProtocolDisplayName, | ||
| StartedAt: op.StartedAt, | ||
| Status: OperationListItemStatus(op.Status), | ||
| StatusDisplayName: op.StatusDisplayName, | ||
| StatusMessage: op.StatusMessage, | ||
| TotalSteps: op.TotalSteps, | ||
| UpdatedAt: op.UpdatedAt, | ||
| } | ||
| data = append(data, item) | ||
| } | ||
| total := len(data) | ||
| s.mu.Unlock() | ||
| writeJSON(w, http.StatusOK, OperationListItemResponse{Data: data, Total: total}) | ||
| } | ||
|
|
||
| // GetApiOperationsId returns a single operation's detail | ||
| // (GET /api/operations/{id}). | ||
| func (s *Server) GetApiOperationsId(w http.ResponseWriter, r *http.Request, id int) { | ||
| if s.authorize(r) == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| s.mu.Lock() | ||
| var found *OperationDetailResponse | ||
| for i := range s.operations { | ||
| if s.operations[i].Id == id { | ||
| cp := s.operations[i] | ||
| found = &cp | ||
| break | ||
| } | ||
| } | ||
| s.mu.Unlock() | ||
| if found == nil { | ||
| writeNotFound(w) | ||
| return | ||
| } | ||
| writeJSON(w, http.StatusOK, *found) | ||
| } | ||
|
|
||
| // GetApiOperationsFilters returns the filter dims for operations | ||
| // (GET /api/operations/filters). | ||
| func (s *Server) GetApiOperationsFilters(w http.ResponseWriter, r *http.Request) { | ||
| if s.authorize(r) == nil { | ||
| writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) | ||
| return | ||
| } | ||
| resp := OperationFiltersResponseResponse{ | ||
| Data: OperationFiltersResponse{ | ||
| Data: OperationFiltersResponseData{ | ||
| Operations: []OperationFilterItem{ | ||
| {Name: "pin", Value: "pin", Description: strPtr("Pin operation")}, | ||
| {Name: "upload", Value: "upload", Description: strPtr("Upload operation")}, | ||
| }, | ||
| Protocols: []OperationFilterItem{ | ||
| {Name: "ipfs", Value: "ipfs", Description: strPtr("IPFS protocol")}, | ||
| }, | ||
| Statuses: []OperationFilterItem{ | ||
| {Name: "completed", Value: "completed", Description: strPtr("Completed")}, | ||
| {Name: "running", Value: "running", Description: strPtr("Running")}, | ||
| }, | ||
| }, | ||
| }, | ||
| Total: 2, | ||
| } | ||
| writeJSON(w, http.StatusOK, resp) | ||
| } | ||
|
|
||
| func writeNotFound(w http.ResponseWriter) { | ||
| writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) | ||
| } | ||
|
|
||
| func intPtr(v int) *int { return &v } | ||
| func strPtr(v string) *string { return &v } | ||
|
|
||
| func timePtr(t time.Time) *time.Time { return &t } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The constant DefaultPassword is assigned a hard-coded password string, violating the rule against hard-coded secrets in Go source. Replace the literal with a value read from the environment, e.g.,
const DefaultPassword = os.Getenv("DEFAULT_PASSWORD").Kody rule violation: Ban hard-coded secrets in Go source
Prompt for LLM
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declining: DefaultPassword is the deterministic seed password for the test fake's account ("password", not a real secret). It must be a known constant so the e2e harness can drive account_update_email/password against the seeded account. Reading it from the environment would make the fake's seed non-deterministic and break the suite.