From 48dcb43d84ca848d97eeac07012cda22f012a05b Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 14:07:53 +0530 Subject: [PATCH] fix(api): /auth/email/confirm-deletion JSON envelope (BUG-API-047/204/273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/handlers/deletion_confirm.go | 18 ++++++- .../deletion_confirm_helpers_coverage_test.go | 53 ++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/internal/handlers/deletion_confirm.go b/internal/handlers/deletion_confirm.go index 7bdbf6ec..7ce5aa06 100644 --- a/internal/handlers/deletion_confirm.go +++ b/internal/handlers/deletion_confirm.go @@ -507,7 +507,23 @@ func EmailConfirmDeletionRedirectHandler(dashboardBaseURL string) fiber.Handler return func(c *fiber.Ctx) error { token := c.Query("t") if strings.TrimSpace(token) == "" { - return c.Status(http.StatusBadRequest).SendString("Missing token") + // BUG-API-047 / BUG-API-204 / BUG-API-273 (QA 2026-05-29): + // the missing-token branch 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 `missing_token` codeToAgentAction entry land here + // too. The handler is browser-initiated (email-link click) but + // the response shape needs to match the rest of the API for + // agent-driven probes (e.g. an MCP tool unwrapping the JSON + // envelope before opening the dashboard). + return respondError(c, http.StatusBadRequest, "missing_token", + "Sign-in link is missing its `t=...` token. Open the link from the email exactly as we sent it.") } // We deliberately encode the token as a query param on the // dashboard URL so the dashboard's React router picks it up diff --git a/internal/handlers/deletion_confirm_helpers_coverage_test.go b/internal/handlers/deletion_confirm_helpers_coverage_test.go index 6cb6a4f7..8da93c77 100644 --- a/internal/handlers/deletion_confirm_helpers_coverage_test.go +++ b/internal/handlers/deletion_confirm_helpers_coverage_test.go @@ -9,6 +9,9 @@ package handlers // white-box test exercises every branch deterministically. import ( + "encoding/json" + "errors" + "io" "net/http/httptest" "strings" "testing" @@ -130,10 +133,27 @@ func TestShouldSkipEmailConfirmation_bvwave(t *testing.T) { func TestEmailConfirmDeletionRedirectHandler(t *testing.T) { h := EmailConfirmDeletionRedirectHandler("https://dash.local/") - app := fiber.New() + // BUG-API-047/204/273: respondError returns ErrResponseWritten as a + // sentinel — fiber's default ErrorHandler would otherwise overwrite + // the JSON body with the sentinel string. Mirror the production + // router's swallow-sentinel handler so the assertions read the body + // the handler actually wrote. + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, ErrResponseWritten) { + return nil + } + return fiber.DefaultErrorHandler(c, err) + }, + }) app.Get("/auth/email/confirm-deletion", h) - // Missing token → 400. + // BUG-API-047 / BUG-API-204 / BUG-API-273 (QA 2026-05-29): + // missing-token branch must return the canonical {ok,error,message, + // request_id,agent_action} envelope on Content-Type application/json, + // NOT text/plain "Missing token" which broke agents grepping on + // `error: missing_token`. Pin both the wire format and the canonical + // error code so a future revert to SendString fails this test. resp, err := app.Test(httptest.NewRequest("GET", "/auth/email/confirm-deletion", nil)) if err != nil { t.Fatal(err) @@ -141,7 +161,36 @@ func TestEmailConfirmDeletionRedirectHandler(t *testing.T) { if resp.StatusCode != 400 { t.Errorf("missing token status = %d; want 400", resp.StatusCode) } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("BUG-API-204/273: missing-token Content-Type = %q; want application/json (envelope, not text/plain)", ct) + } + body, _ := io.ReadAll(resp.Body) resp.Body.Close() + var env struct { + OK bool `json:"ok"` + Error string `json:"error"` + Message string `json:"message"` + RequestID string `json:"request_id"` + AgentAction string `json:"agent_action"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("BUG-API-204/273: missing-token body not JSON: %v (body=%q)", err, string(body)) + } + if env.OK { + t.Errorf("BUG-API-204/273: envelope.ok = true; want false on 400") + } + if env.Error != "missing_token" { + 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") + } + if env.Message == "" { + t.Errorf("BUG-API-204/273: envelope.message empty") + } + // codeToAgentAction has a `missing_token` entry, so the envelope + // MUST carry a non-empty agent_action through respondError. + if env.AgentAction == "" { + t.Errorf("BUG-API-204/273: envelope.agent_action empty — codeToAgentAction[missing_token] should populate this") + } // With token → 302 to the dashboard confirm page. resp, err = app.Test(httptest.NewRequest("GET", "/auth/email/confirm-deletion?t=abc", nil))