|
| 1 | +package handlers_test |
| 2 | + |
| 3 | +// recycle_gate_early_test.go — coverage pin for API-7 (QA 2026-05-29): |
| 4 | +// the recycle gate now fires from the EARLIER position in storage/webhook/ |
| 5 | +// vector anonymous handlers (before checkProvisionLimit), so the existing |
| 6 | +// recycle-gate fired-branch tests at the LATER position are no longer |
| 7 | +// reachable for those handlers. This file adds the missing per-handler |
| 8 | +// pin so a regression to the old ordering immediately reds. |
| 9 | +// |
| 10 | +// The cache/nosql/queue pin lives in anon_paths_provarms_test.go |
| 11 | +// (TestAnonRecycleGate_Cache/NoSQL/Queue). The db pin lives there too |
| 12 | +// (TestAnonRecycleGate_DB). storage/webhook/vector need their own |
| 13 | +// fixtures because they're not mounted on the gRPC fixture. |
| 14 | + |
| 15 | +import ( |
| 16 | + "context" |
| 17 | + "database/sql" |
| 18 | + "encoding/json" |
| 19 | + "errors" |
| 20 | + "io" |
| 21 | + "net/http" |
| 22 | + "net/http/httptest" |
| 23 | + "strings" |
| 24 | + "testing" |
| 25 | + "time" |
| 26 | + |
| 27 | + "github.com/gofiber/fiber/v2" |
| 28 | + "github.com/google/uuid" |
| 29 | + "github.com/redis/go-redis/v9" |
| 30 | + "github.com/stretchr/testify/assert" |
| 31 | + "github.com/stretchr/testify/require" |
| 32 | + |
| 33 | + "instant.dev/internal/config" |
| 34 | + "instant.dev/internal/handlers" |
| 35 | + "instant.dev/internal/middleware" |
| 36 | + "instant.dev/internal/plans" |
| 37 | + "instant.dev/internal/testhelpers" |
| 38 | +) |
| 39 | + |
| 40 | +// recycleGateApp mounts a single anonymous-path handler with the minimum |
| 41 | +// middleware needed to drive a recycle-gate-fired path: RequestID + Fingerprint |
| 42 | +// (for fp computation) + OptionalAuth (no-op for anonymous) + the handler. |
| 43 | +// Idempotency middleware intentionally omitted — we want every POST to actually |
| 44 | +// reach the handler. |
| 45 | +func recycleGateApp(t *testing.T, mount func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config)) (*fiber.App, *sql.DB, *redis.Client) { |
| 46 | + t.Helper() |
| 47 | + db, _ := testhelpers.SetupTestDB(t) |
| 48 | + t.Cleanup(func() { db.Close() }) |
| 49 | + rdb, _ := testhelpers.SetupTestRedis(t) |
| 50 | + t.Cleanup(func() { rdb.Close() }) |
| 51 | + |
| 52 | + cfg := &config.Config{ |
| 53 | + Port: "8080", |
| 54 | + JWTSecret: testhelpers.TestJWTSecret, |
| 55 | + AESKey: testhelpers.TestAESKeyHex, |
| 56 | + EnabledServices: "postgres,redis,mongodb,queue,webhook,storage,vector", |
| 57 | + Environment: "test", |
| 58 | + } |
| 59 | + |
| 60 | + app := fiber.New(fiber.Config{ |
| 61 | + ErrorHandler: func(c *fiber.Ctx, err error) error { |
| 62 | + if errors.Is(err, handlers.ErrResponseWritten) { |
| 63 | + return nil |
| 64 | + } |
| 65 | + return fiber.DefaultErrorHandler(c, err) |
| 66 | + }, |
| 67 | + ProxyHeader: "X-Forwarded-For", |
| 68 | + }) |
| 69 | + app.Use(middleware.RequestID()) |
| 70 | + app.Use(middleware.Fingerprint()) |
| 71 | + |
| 72 | + mount(app, db, rdb, cfg) |
| 73 | + return app, db, rdb |
| 74 | +} |
| 75 | + |
| 76 | +// plantRecycleMarker computes the fingerprint via the middleware's helper |
| 77 | +// (X-Forwarded-For + ASN) and writes the recycle-seen Redis marker so the |
| 78 | +// gate will fire on the next request from the same IP. The fingerprint for |
| 79 | +// an unknown IP comes purely from /24 subnet + ASN, so two calls from the |
| 80 | +// same IP produce the same fp deterministically. |
| 81 | +func plantRecycleMarker(t *testing.T, app *fiber.App, db *sql.DB, rdb *redis.Client, probePath, ip string, probeBody string) string { |
| 82 | + t.Helper() |
| 83 | + // Issue one cache /probe call (cache is always available + doesn't depend |
| 84 | + // on a real backend) to learn the fp. The handler creates a row whose |
| 85 | + // fingerprint we read back. We use cache because it's the simplest |
| 86 | + // anonymous flow that doesn't need a real provisioner. |
| 87 | + req := httptest.NewRequest(http.MethodPost, probePath, strings.NewReader(probeBody)) |
| 88 | + req.Header.Set("Content-Type", "application/json") |
| 89 | + req.Header.Set("X-Forwarded-For", ip) |
| 90 | + req.Header.Set("Idempotency-Key", uuid.NewString()) |
| 91 | + resp, err := app.Test(req, 10000) |
| 92 | + require.NoError(t, err) |
| 93 | + raw, _ := io.ReadAll(resp.Body) |
| 94 | + defer resp.Body.Close() |
| 95 | + require.Equalf(t, http.StatusCreated, resp.StatusCode, "probe call body: %s", raw) |
| 96 | + |
| 97 | + // Extract token then look up the fingerprint from the row. |
| 98 | + var probe struct { |
| 99 | + Token string `json:"token"` |
| 100 | + } |
| 101 | + require.NoError(t, parseProbeJSON(raw, &probe)) |
| 102 | + |
| 103 | + var fp string |
| 104 | + require.NoError(t, db.QueryRowContext(context.Background(), |
| 105 | + `SELECT fingerprint FROM resources WHERE token = $1::uuid`, probe.Token).Scan(&fp)) |
| 106 | + |
| 107 | + // Soft-delete every active row for this fp so the gate's "zero active |
| 108 | + // rows" condition is satisfied. Plant the marker. |
| 109 | + _, err = db.ExecContext(context.Background(), |
| 110 | + `UPDATE resources SET status = 'deleted' WHERE fingerprint = $1`, fp) |
| 111 | + require.NoError(t, err) |
| 112 | + require.NoError(t, rdb.Set(context.Background(), |
| 113 | + handlers.RecycleSeenKeyPrefix+fp, "1", time.Hour).Err()) |
| 114 | + return fp |
| 115 | +} |
| 116 | + |
| 117 | +// parseProbeJSON is a tiny JSON decoder helper kept in this file so the test |
| 118 | +// has zero dependencies on the larger provarms helpers (which need a gRPC |
| 119 | +// fixture). We only need the token field. |
| 120 | +func parseProbeJSON(raw []byte, out *struct { |
| 121 | + Token string `json:"token"` |
| 122 | +}) error { |
| 123 | + return json.Unmarshal(raw, out) |
| 124 | +} |
| 125 | + |
| 126 | +// TestRecycleGate_EarlyFire_Storage covers the API-7 reorder: storage's |
| 127 | +// recycle gate now fires from the early position in NewStorage. Pin: with |
| 128 | +// a planted marker and zero active rows, /storage/new must 402. |
| 129 | +func TestRecycleGate_EarlyFire_Storage(t *testing.T) { |
| 130 | + provider := newDOSpacesProvider(t) |
| 131 | + app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) { |
| 132 | + // Both /cache/new (probe to learn fp) and /storage/new mounted. |
| 133 | + cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default()) |
| 134 | + storageH := handlers.NewStorageHandler(db, rdb, cfg, provider, plans.Default()) |
| 135 | + app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache) |
| 136 | + app.Post("/storage/new", middleware.OptionalAuth(cfg), storageH.NewStorage) |
| 137 | + }) |
| 138 | + ip := "10.220.0.1" |
| 139 | + plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`) |
| 140 | + |
| 141 | + // Now /storage/new from the same IP must 402. |
| 142 | + req := httptest.NewRequest(http.MethodPost, "/storage/new", strings.NewReader(`{"name":"recycle"}`)) |
| 143 | + req.Header.Set("Content-Type", "application/json") |
| 144 | + req.Header.Set("X-Forwarded-For", ip) |
| 145 | + req.Header.Set("Idempotency-Key", uuid.NewString()) |
| 146 | + resp, err := app.Test(req, 10000) |
| 147 | + require.NoError(t, err) |
| 148 | + raw, _ := io.ReadAll(resp.Body) |
| 149 | + defer resp.Body.Close() |
| 150 | + require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode, |
| 151 | + "/storage/new recycle gate must 402 (body=%s)", raw) |
| 152 | + assert.Contains(t, string(raw), "free_tier_recycle_requires_claim") |
| 153 | +} |
| 154 | + |
| 155 | +// TestRecycleGate_EarlyFire_Webhook — same shape for /webhook/new. |
| 156 | +func TestRecycleGate_EarlyFire_Webhook(t *testing.T) { |
| 157 | + app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) { |
| 158 | + cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default()) |
| 159 | + webhookH := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) |
| 160 | + app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache) |
| 161 | + app.Post("/webhook/new", middleware.OptionalAuth(cfg), webhookH.NewWebhook) |
| 162 | + }) |
| 163 | + ip := "10.221.0.1" |
| 164 | + plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`) |
| 165 | + |
| 166 | + req := httptest.NewRequest(http.MethodPost, "/webhook/new", strings.NewReader(`{"name":"recycle"}`)) |
| 167 | + req.Header.Set("Content-Type", "application/json") |
| 168 | + req.Header.Set("X-Forwarded-For", ip) |
| 169 | + req.Header.Set("Idempotency-Key", uuid.NewString()) |
| 170 | + resp, err := app.Test(req, 10000) |
| 171 | + require.NoError(t, err) |
| 172 | + raw, _ := io.ReadAll(resp.Body) |
| 173 | + defer resp.Body.Close() |
| 174 | + require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode, |
| 175 | + "/webhook/new recycle gate must 402 (body=%s)", raw) |
| 176 | + assert.Contains(t, string(raw), "free_tier_recycle_requires_claim") |
| 177 | +} |
| 178 | + |
| 179 | +// TestRecycleGate_EarlyFire_Vector — same shape for /vector/new. |
| 180 | +func TestRecycleGate_EarlyFire_Vector(t *testing.T) { |
| 181 | + app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) { |
| 182 | + cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default()) |
| 183 | + vectorH := handlers.NewVectorHandler(db, rdb, cfg, nil, plans.Default()) |
| 184 | + app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache) |
| 185 | + app.Post("/vector/new", middleware.OptionalAuth(cfg), vectorH.NewVector) |
| 186 | + }) |
| 187 | + ip := "10.222.0.1" |
| 188 | + plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`) |
| 189 | + |
| 190 | + req := httptest.NewRequest(http.MethodPost, "/vector/new", strings.NewReader(`{"name":"recycle"}`)) |
| 191 | + req.Header.Set("Content-Type", "application/json") |
| 192 | + req.Header.Set("X-Forwarded-For", ip) |
| 193 | + req.Header.Set("Idempotency-Key", uuid.NewString()) |
| 194 | + resp, err := app.Test(req, 10000) |
| 195 | + require.NoError(t, err) |
| 196 | + raw, _ := io.ReadAll(resp.Body) |
| 197 | + defer resp.Body.Close() |
| 198 | + require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode, |
| 199 | + "/vector/new recycle gate must 402 (body=%s)", raw) |
| 200 | + assert.Contains(t, string(raw), "free_tier_recycle_requires_claim") |
| 201 | +} |
0 commit comments