Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1b5a161
test(mcp): add shared invoke/describe/search helpers for e2e suite
pcfreak30 Aug 22, 2026
ec7b505
test(mcp): lock progressive-disclosure tools/list contract
pcfreak30 Aug 22, 2026
d467b09
test(mcp): assert account/pins tools with sunpeak matchers
pcfreak30 Aug 22, 2026
654b4a2
test(mcp): cover search/describe/invoke discovery meta-tools
pcfreak30 Aug 22, 2026
7e7098c
fix(mcp): invoke_tool args field is 'arguments' not 'args'
pcfreak30 Aug 22, 2026
692b429
test(mcp): cover pins add/list/status/rm incl destructive gate
pcfreak30 Aug 22, 2026
0ffdbcb
test(mcp): cover auth_status/login/logout domain tools
pcfreak30 Aug 22, 2026
4d1c675
feat(mcptest): implement account subscription/email/password endpoints
pcfreak30 Aug 22, 2026
338e943
feat(mcptest): implement dns zones/records endpoints
pcfreak30 Aug 22, 2026
eae9585
feat(mcptest): implement websites crud/domains endpoints
pcfreak30 Aug 22, 2026
7161319
feat(mcptest): implement ipns keys/publish/resolve endpoints
pcfreak30 Aug 22, 2026
d704efc
feat(mcptest): implement pins update/fetch/list-filter behavior
pcfreak30 Aug 22, 2026
938936f
test(mcp): cover resources list/read surface
pcfreak30 Aug 22, 2026
0c423dd
test(mcp): cover wizard session lifecycle
pcfreak30 Aug 22, 2026
e6e92f2
test(mcp): fix websites_ssl_status to assert pending ssl for fresh sites
pcfreak30 Aug 22, 2026
caa06d1
test(mcp): fix shared-state races + kody findings in e2e suite
pcfreak30 Aug 22, 2026
218d08f
test(mcp): fix account wrong-password assertion + shared-state restor…
pcfreak30 Aug 22, 2026
4c45da2
fix(mcp): correct pins CID multihash length byte (regression from ent…
pcfreak30 Aug 22, 2026
5ab7a36
test(mcp): self-heal shared account state in account tests
pcfreak30 Aug 22, 2026
532c144
test(mcp): immediately restore shared config after auth_login persist…
pcfreak30 Aug 22, 2026
afb6373
test(mcp): remove unsupported mcp-fixture beforeAll/afterAll, fix ran…
pcfreak30 Aug 22, 2026
fc7aac1
fix(mcptest): lock dns zone/record id mutation, fix stale resp in pin…
pcfreak30 Aug 22, 2026
cc2a750
fix(mcptest): serialize zone snapshot under lock in GET/PUT DNS handlers
pcfreak30 Aug 22, 2026
27467a2
feat(mcptest): implement operations fake + fix 3 shared-state races f…
pcfreak30 Aug 22, 2026
37e1c24
test(mcp): strengthen pins_status to assert cid echo (fake cid filter…
pcfreak30 Aug 22, 2026
ecb7ca8
test(mcp): drop fragile operations status-filter assertion (serialize…
pcfreak30 Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 266 additions & 4 deletions internal/mcptest/account/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules low

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

File internal/mcptest/account/server.go:

Line 41:

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")`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Member Author

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.


// 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,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules low

Sensitive token exposure: the token variable derives from the Authorization header, which may contain secrets, and storing or exposing the raw token in the response violates security rules. Return only a boolean or a hashed/redacted value, and ensure any token handling uses secure methods for configuration rather than request-derived secrets.

Kody rule violation: Ban hard-coded secrets in Go source

Prompt for LLM

File internal/mcptest/account/server.go:

Line 133:

Sensitive token exposure: the `token` variable derives from the Authorization header, which may contain secrets, and storing or exposing the raw token in the response violates security rules. Return only a boolean or a hashed/redacted value, and ensure any token handling uses secure methods for configuration rather than request-derived secrets.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.
Expand Down Expand Up @@ -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.
Expand All @@ -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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Bug high

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 LLM

File internal/mcptest/account/server.go:

Line 344:

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.

Suggested Code:

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})
}

Talk 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Bug high

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 LLM

File internal/mcptest/account/server.go:

Line 350:

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.

Suggested Code:

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)

Talk 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 }
Loading
Loading