|
| 1 | +package handlers_test |
| 2 | + |
| 3 | +// deploy_ttl_claim_required_test.go — B7-P1-7 (BugBash 2026-05-20) |
| 4 | +// regression gate for the anonymous-tier walls on the deploy-TTL keeper |
| 5 | +// endpoints. |
| 6 | +// |
| 7 | +// Bug class: |
| 8 | +// POST /api/v1/deployments/:id/make-permanent and POST /:id/ttl reject |
| 9 | +// anonymous-tier callers with 402. The wall's `error` code used to be |
| 10 | +// `upgrade_required`, which is the keyword for "paid plan needed" — |
| 11 | +// not the right semantics here, where the remediation is a FREE claim. |
| 12 | +// Agents that branch on the response `error` keyword (instead of reading |
| 13 | +// the prose agent_action) routed the user to the paid pricing page when |
| 14 | +// a 30-second free claim would have cleared the wall. |
| 15 | +// |
| 16 | +// Why a registry-iterating test (rule 18 — CLAUDE.md): |
| 17 | +// This is a two-site bug: MakePermanent (deploy_ttl.go:63-69) and SetTTL |
| 18 | +// (deploy_ttl.go:137-143) both emitted the wrong code. A hand-typed |
| 19 | +// single-route assertion would re-regress the moment a third TTL-keeper |
| 20 | +// route lands and re-uses the `upgrade_required` template. The table |
| 21 | +// below iterates EVERY anon-rejected deploy-TTL route and asserts the |
| 22 | +// contract identically; adding a new route without adding a row here |
| 23 | +// makes the failure mode loud, not silent. |
| 24 | +// |
| 25 | +// Surface coverage (rule 17): |
| 26 | +// Symptom: JSON body `error: "upgrade_required"` on anon /make-permanent + /ttl |
| 27 | +// Enumeration: rg -F '"upgrade_required"' internal/handlers/deploy_ttl.go |
| 28 | +// Sites found: 2 (L65, L139) |
| 29 | +// Sites touched: 2 (both arms flipped to "claim_required" in same PR) |
| 30 | +// Coverage test: this file — iterates a 2-route table; a third arm |
| 31 | +// that emits `upgrade_required` makes the matching row fail. |
| 32 | +// Live verified: pending — anonymous deploys cannot be made permanent |
| 33 | +// on a real prod hit; the unit test exercises both code |
| 34 | +// paths against a real test DB with an "anonymous"-tier |
| 35 | +// team. Live curl awaiting deploy. |
| 36 | + |
| 37 | +import ( |
| 38 | + "context" |
| 39 | + "encoding/json" |
| 40 | + "io" |
| 41 | + "net/http" |
| 42 | + "net/http/httptest" |
| 43 | + "os" |
| 44 | + "strings" |
| 45 | + "testing" |
| 46 | + |
| 47 | + "github.com/google/uuid" |
| 48 | + "github.com/stretchr/testify/assert" |
| 49 | + "github.com/stretchr/testify/require" |
| 50 | + |
| 51 | + "instant.dev/internal/models" |
| 52 | + "instant.dev/internal/testhelpers" |
| 53 | +) |
| 54 | + |
| 55 | +// TestDeployTTL_AnonymousArmsEmitClaimRequired pins the contract for every |
| 56 | +// anonymous-tier wall on the deploy-TTL keeper endpoints. The table is the |
| 57 | +// registry — adding a new TTL route that rejects anon must add a row here. |
| 58 | +func TestDeployTTL_AnonymousArmsEmitClaimRequired(t *testing.T) { |
| 59 | + db, cleanDB := testhelpers.SetupTestDB(t) |
| 60 | + defer cleanDB() |
| 61 | + rdb, cleanRedis := testhelpers.SetupTestRedis(t) |
| 62 | + defer cleanRedis() |
| 63 | + |
| 64 | + teamID := testhelpers.MustCreateTeamDB(t, db, "anonymous") |
| 65 | + sessionJWT := testhelpers.MustSignSessionJWT(t, "u-claim-1", teamID, "anon@example.com") |
| 66 | + |
| 67 | + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy") |
| 68 | + defer cleanApp() |
| 69 | + |
| 70 | + d, err := models.CreateDeployment(context.Background(), db, models.CreateDeploymentParams{ |
| 71 | + TeamID: uuid.MustParse(teamID), |
| 72 | + AppID: "ttl-anon-" + uuid.NewString()[:6], |
| 73 | + Tier: "anonymous", |
| 74 | + }) |
| 75 | + require.NoError(t, err) |
| 76 | + defer db.Exec(`DELETE FROM deployments WHERE id = $1`, d.ID) |
| 77 | + |
| 78 | + // Registry of every anon-rejected deploy-TTL route. ADD A ROW HERE |
| 79 | + // when a new keeper endpoint lands and rejects anon — otherwise the |
| 80 | + // next emitter of `upgrade_required` will slip past this gate. |
| 81 | + type armCase struct { |
| 82 | + name string |
| 83 | + method string |
| 84 | + path string |
| 85 | + body string |
| 86 | + } |
| 87 | + arms := []armCase{ |
| 88 | + { |
| 89 | + name: "make_permanent", |
| 90 | + method: http.MethodPost, |
| 91 | + path: "/api/v1/deployments/" + d.AppID + "/make-permanent", |
| 92 | + body: "", |
| 93 | + }, |
| 94 | + { |
| 95 | + name: "set_ttl", |
| 96 | + method: http.MethodPost, |
| 97 | + path: "/api/v1/deployments/" + d.AppID + "/ttl", |
| 98 | + body: `{"hours":48}`, |
| 99 | + }, |
| 100 | + } |
| 101 | + |
| 102 | + for _, arm := range arms { |
| 103 | + t.Run(arm.name, func(t *testing.T) { |
| 104 | + var bodyReader io.Reader |
| 105 | + if arm.body != "" { |
| 106 | + bodyReader = strings.NewReader(arm.body) |
| 107 | + } |
| 108 | + req := httptest.NewRequest(arm.method, arm.path, bodyReader) |
| 109 | + if arm.body != "" { |
| 110 | + req.Header.Set("Content-Type", "application/json") |
| 111 | + } |
| 112 | + req.Header.Set("Authorization", "Bearer "+sessionJWT) |
| 113 | + |
| 114 | + resp, err := app.Test(req, 5000) |
| 115 | + require.NoError(t, err) |
| 116 | + defer resp.Body.Close() |
| 117 | + body, _ := io.ReadAll(resp.Body) |
| 118 | + |
| 119 | + assert.Equal(t, http.StatusPaymentRequired, resp.StatusCode, |
| 120 | + "%s: anon-tier must 402, got body=%s", arm.name, body) |
| 121 | + |
| 122 | + var out struct { |
| 123 | + OK bool `json:"ok"` |
| 124 | + Error string `json:"error"` |
| 125 | + Message string `json:"message"` |
| 126 | + AgentAction string `json:"agent_action"` |
| 127 | + UpgradeURL string `json:"upgrade_url"` |
| 128 | + } |
| 129 | + require.NoError(t, json.Unmarshal(body, &out), |
| 130 | + "%s: response must be JSON envelope: %s", arm.name, body) |
| 131 | + |
| 132 | + assert.False(t, out.OK, "%s: ok must be false", arm.name) |
| 133 | + |
| 134 | + // THE bug — error code keyword. `upgrade_required` routes |
| 135 | + // agents to paid pricing; `claim_required` routes them to |
| 136 | + // the free claim flow. |
| 137 | + assert.Equal(t, "claim_required", out.Error, |
| 138 | + "%s: error keyword must be claim_required (agents branching on code route by this keyword); upgrade_required mis-routes to paid pricing", arm.name) |
| 139 | + |
| 140 | + // upgrade_url is the machine-readable destination an agent |
| 141 | + // would surface as a CTA. For a FREE claim it must point at |
| 142 | + // /claim, not /pricing or /start (deprecated alias). |
| 143 | + assert.Equal(t, "https://instanode.dev/claim", out.UpgradeURL, |
| 144 | + "%s: upgrade_url must point at the free /claim flow, not /pricing", arm.name) |
| 145 | + |
| 146 | + // agent_action sentence must still pass the U3 contract and |
| 147 | + // say "claim" (the action verb). |
| 148 | + assert.NotEmpty(t, out.AgentAction, "%s: agent_action must not be empty", arm.name) |
| 149 | + assert.Contains(t, strings.ToLower(out.AgentAction), "claim", |
| 150 | + "%s: agent_action must name the next action (claim)", arm.name) |
| 151 | + }) |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +// TestDeployTTL_NoUpgradeRequiredInSource is the structural guard for the |
| 156 | +// same regression: if any future hand-edit re-introduces the |
| 157 | +// `"upgrade_required"` string into deploy_ttl.go's anon walls, this test |
| 158 | +// fails before the registry-iterating arm test even runs. Belt + braces. |
| 159 | +// |
| 160 | +// NOTE: this asserts on the deploy_ttl.go FILE (source-level grep), so it |
| 161 | +// catches the regression at compile-time-of-the-test rather than at the |
| 162 | +// HTTP boundary. The arm-iterating test above is the HTTP-boundary gate. |
| 163 | +func TestDeployTTL_NoUpgradeRequiredInSource(t *testing.T) { |
| 164 | + // Read the source file deterministically — golden-grep style. Tests |
| 165 | + // run with cwd == the package directory, so the relative path is the |
| 166 | + // source file alongside this test. |
| 167 | + const sourcePath = "deploy_ttl.go" |
| 168 | + rawBytes, err := os.ReadFile(sourcePath) |
| 169 | + require.NoError(t, err, "source file must be readable: %s", sourcePath) |
| 170 | + raw := string(rawBytes) |
| 171 | + |
| 172 | + // Any string literal `"upgrade_required"` inside deploy_ttl.go is |
| 173 | + // a regression of B7-P1-7 — the anon walls there are required to |
| 174 | + // emit `claim_required` instead. Other handlers (db.go, vector.go, |
| 175 | + // nosql.go, ...) are still allowed to emit `upgrade_required` |
| 176 | + // because their walls really are paid-plan walls. |
| 177 | + assert.NotContains(t, raw, `"upgrade_required"`, |
| 178 | + "B7-P1-7 regression: deploy_ttl.go must not emit the `upgrade_required` keyword — anon-tier walls here are FREE-claim walls and must emit `claim_required` so code-switching agents route to /claim instead of /pricing") |
| 179 | +} |
0 commit comments