Skip to content

Commit 4e36b7a

Browse files
fix(api): /auth/email/confirm-deletion JSON envelope (BUG-API-047/204/273)
The missing-token branch on /auth/email/confirm-deletion used to `SendString("Missing token")` which served `Content-Type: text/plain` with no envelope, no request_id, no agent_action — an envelope-bypass that broke the universal {ok,error,message,request_id,agent_action} contract every other 4xx in the api carries. Agents grepping on `error: missing_token` (the canonical code already used across onboarding.go, deletion_confirm.go:275, magic_link.go) silently failed because this surface returned a plain string. Route through respondError so the envelope shape + the existing codeToAgentAction[missing_token] entry land here too. The handler is browser-initiated (email-link click) but the response shape must match the rest of the API for agent-driven probes (e.g. an MCP tool unwrapping the JSON envelope before opening the dashboard). Coverage block: Symptom: /auth/email/confirm-deletion returns text/plain 400 with no envelope (BUG-API-047, BUG-API-204, BUG-API-273) Enumeration: rg -F 'SendString' internal/handlers/deletion_confirm.go (1 site) rg -F 'confirm-deletion' internal/router/router.go (route bind) Sites found: 1 (deletion_confirm.go:510 SendString) Sites touched: 1 Coverage test: TestEmailConfirmDeletionRedirectHandler now asserts: - status 400 - Content-Type application/json - envelope.ok == false - envelope.error == "missing_token" - envelope.message non-empty - envelope.agent_action non-empty (from codeToAgentAction) so a future revert to SendString fails before merge. Live verified: pending auto-deploy + curl -sI 'https://api.instanode.dev/auth/email/confirm-deletion' | grep -i content-type Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eedce75 commit 4e36b7a

2 files changed

Lines changed: 68 additions & 3 deletions

File tree

internal/handlers/deletion_confirm.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,23 @@ func EmailConfirmDeletionRedirectHandler(dashboardBaseURL string) fiber.Handler
507507
return func(c *fiber.Ctx) error {
508508
token := c.Query("t")
509509
if strings.TrimSpace(token) == "" {
510-
return c.Status(http.StatusBadRequest).SendString("Missing token")
510+
// BUG-API-047 / BUG-API-204 / BUG-API-273 (QA 2026-05-29):
511+
// the missing-token branch used to SendString("Missing token")
512+
// which served Content-Type: text/plain with no envelope,
513+
// no request_id, no agent_action — an envelope-bypass that
514+
// broke the universal {ok,error,message,request_id,agent_action}
515+
// contract every other 4xx in the api carries. Agents grepping
516+
// on `error: missing_token` (the canonical code already used
517+
// across onboarding.go, deletion_confirm.go:275, magic_link.go)
518+
// silently failed because this surface returned a plain string.
519+
// Route through respondError so the envelope shape + the
520+
// existing `missing_token` codeToAgentAction entry land here
521+
// too. The handler is browser-initiated (email-link click) but
522+
// the response shape needs to match the rest of the API for
523+
// agent-driven probes (e.g. an MCP tool unwrapping the JSON
524+
// envelope before opening the dashboard).
525+
return respondError(c, http.StatusBadRequest, "missing_token",
526+
"Sign-in link is missing its `t=...` token. Open the link from the email exactly as we sent it.")
511527
}
512528
// We deliberately encode the token as a query param on the
513529
// dashboard URL so the dashboard's React router picks it up

internal/handlers/deletion_confirm_helpers_coverage_test.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ package handlers
99
// white-box test exercises every branch deterministically.
1010

1111
import (
12+
"encoding/json"
13+
"errors"
14+
"io"
1215
"net/http/httptest"
1316
"strings"
1417
"testing"
@@ -130,18 +133,64 @@ func TestShouldSkipEmailConfirmation_bvwave(t *testing.T) {
130133

131134
func TestEmailConfirmDeletionRedirectHandler(t *testing.T) {
132135
h := EmailConfirmDeletionRedirectHandler("https://dash.local/")
133-
app := fiber.New()
136+
// BUG-API-047/204/273: respondError returns ErrResponseWritten as a
137+
// sentinel — fiber's default ErrorHandler would otherwise overwrite
138+
// the JSON body with the sentinel string. Mirror the production
139+
// router's swallow-sentinel handler so the assertions read the body
140+
// the handler actually wrote.
141+
app := fiber.New(fiber.Config{
142+
ErrorHandler: func(c *fiber.Ctx, err error) error {
143+
if errors.Is(err, ErrResponseWritten) {
144+
return nil
145+
}
146+
return fiber.DefaultErrorHandler(c, err)
147+
},
148+
})
134149
app.Get("/auth/email/confirm-deletion", h)
135150

136-
// Missing token → 400.
151+
// BUG-API-047 / BUG-API-204 / BUG-API-273 (QA 2026-05-29):
152+
// missing-token branch must return the canonical {ok,error,message,
153+
// request_id,agent_action} envelope on Content-Type application/json,
154+
// NOT text/plain "Missing token" which broke agents grepping on
155+
// `error: missing_token`. Pin both the wire format and the canonical
156+
// error code so a future revert to SendString fails this test.
137157
resp, err := app.Test(httptest.NewRequest("GET", "/auth/email/confirm-deletion", nil))
138158
if err != nil {
139159
t.Fatal(err)
140160
}
141161
if resp.StatusCode != 400 {
142162
t.Errorf("missing token status = %d; want 400", resp.StatusCode)
143163
}
164+
ct := resp.Header.Get("Content-Type")
165+
if !strings.HasPrefix(ct, "application/json") {
166+
t.Errorf("BUG-API-204/273: missing-token Content-Type = %q; want application/json (envelope, not text/plain)", ct)
167+
}
168+
body, _ := io.ReadAll(resp.Body)
144169
resp.Body.Close()
170+
var env struct {
171+
OK bool `json:"ok"`
172+
Error string `json:"error"`
173+
Message string `json:"message"`
174+
RequestID string `json:"request_id"`
175+
AgentAction string `json:"agent_action"`
176+
}
177+
if err := json.Unmarshal(body, &env); err != nil {
178+
t.Fatalf("BUG-API-204/273: missing-token body not JSON: %v (body=%q)", err, string(body))
179+
}
180+
if env.OK {
181+
t.Errorf("BUG-API-204/273: envelope.ok = true; want false on 400")
182+
}
183+
if env.Error != "missing_token" {
184+
t.Errorf("BUG-API-047: envelope.error = %q; want %q (canonical code used across onboarding.go/magic_link.go/deletion_confirm.go)", env.Error, "missing_token")
185+
}
186+
if env.Message == "" {
187+
t.Errorf("BUG-API-204/273: envelope.message empty")
188+
}
189+
// codeToAgentAction has a `missing_token` entry, so the envelope
190+
// MUST carry a non-empty agent_action through respondError.
191+
if env.AgentAction == "" {
192+
t.Errorf("BUG-API-204/273: envelope.agent_action empty — codeToAgentAction[missing_token] should populate this")
193+
}
145194

146195
// With token → 302 to the dashboard confirm page.
147196
resp, err = app.Test(httptest.NewRequest("GET", "/auth/email/confirm-deletion?t=abc", nil))

0 commit comments

Comments
 (0)