Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions internal/handlers/anon_paths_provarms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,47 @@ func TestAnonRecycleGate_Cache(t *testing.T) { recycleGateOnce(t, "/cache/new",
func TestAnonRecycleGate_NoSQL(t *testing.T) { recycleGateOnce(t, "/nosql/new", "10.215.0.1", "mongodb") }
func TestAnonRecycleGate_Queue(t *testing.T) { recycleGateOnce(t, "/queue/new", "10.216.0.1", "queue") }

// TestAnonRecycleGate_DB covers the API-7 (QA 2026-05-29) reorder: the recycle
// gate now fires from the EARLIER position in NewDB (before checkProvisionLimit),
// so a fresh fingerprint with a planted recycle marker and zero active rows
// must still 402 free_tier_recycle_requires_claim on the /db/new path. Pinned
// here rather than in redis_fault_provarms_test.go because that test depends
// on a live postgres-customers backend (which the coverage CI job doesn't
// provide); the gRPC fixture's fakeProvisioner is good enough since recycleGate
// fires BEFORE any backend dispatch.
func TestAnonRecycleGate_DB(t *testing.T) {
fake := &fakeProvisioner{}
fx := setupGRPCProvFixture(t, fake, false)

ip := "10.217.0.1"
// Plant a recycle marker for the fingerprint this IP will produce and
// ensure zero active rows. We do NOT need to provision first to learn the
// fingerprint — the middleware computes it the same way every request, so
// we can plant by replicating the exact fingerprint calc OR by using a
// throwaway provision to discover it (mirrors recycleGateOnce above).
resp, body := doProvisionKeyed(t, fx, "/cache/new", ip, "", uuid.NewString(),
map[string]any{"name": "rg-db-probe"})
resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)

var fp string
require.NoError(t, fx.db.QueryRowContext(context.Background(),
`SELECT fingerprint FROM resources WHERE token = $1::uuid`, body.Token).Scan(&fp))
_, err := fx.db.ExecContext(context.Background(),
`UPDATE resources SET status = 'deleted' WHERE fingerprint = $1`, fp)
require.NoError(t, err)
require.NoError(t, fx.rdb.Set(context.Background(),
handlers.RecycleSeenKeyPrefix+fp, "1", time.Hour).Err())

// Next /db/new from the same IP must 402 from the EARLY recycle gate.
resp2, body2 := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(),
map[string]any{"name": "rg-db-fire"})
defer resp2.Body.Close()
require.Equal(t, http.StatusPaymentRequired, resp2.StatusCode,
"/db/new recycle gate must 402 (early-gate API-7 reorder)")
assert.Equal(t, "free_tier_recycle_requires_claim", body2.Error)
}

// dedupDecryptFailOnce: provision 5 of a type, corrupt the row's stored
// connection_url, force over-cap, and assert the 6th over-cap call hits the
// dedup branch, fails to decrypt, and provisions FRESH (never returns
Expand Down
10 changes: 6 additions & 4 deletions internal/handlers/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error {
}

// ── Anonymous path ─────────────────────────────────────────────────────────
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
if h.recycleGate(c, fp, "redis") {
return nil
}

limitExceeded, err := h.checkProvisionLimit(ctx, fp)
if err != nil {
slog.Error("cache.new.provision_limit_check_failed",
Expand Down Expand Up @@ -182,10 +187,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error {
}
}

// Free-tier recycle gate (see provision_helper.go for rationale).
if h.recycleGate(c, fp, "redis") {
return nil
}
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)

expiresAt := time.Now().UTC().Add(24 * time.Hour)
resource, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{
Expand Down
21 changes: 12 additions & 9 deletions internal/handlers/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error {
}

// ── Anonymous path ─────────────────────────────────────────────────────────
// Free-tier recycle gate runs BEFORE the daily-cap check so the 402
// `free_tier_recycle_requires_claim` envelope (with claim_url + agent_action
// upsell signal) wins over the more generic 429 `provision_limit_reached`
// when both apply. API-7 (QA 2026-05-29): an over-cap fingerprint whose
// previous resources have aged out used to get the bare daily-cap 429
// envelope, losing the recycle-gate upsell entirely. Gate fails open (returns
// false on any error) so the early call cannot block an honest first-touch.
if h.recycleGate(c, fp, "postgres") {
return nil
}

limitExceeded, err := h.checkProvisionLimit(ctx, fp)
if err != nil {
slog.Error("db.new.provision_limit_check_failed",
Expand Down Expand Up @@ -209,15 +220,7 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error {
}
}

// Free-tier recycle gate (Option B / FREE-TIER-RECYCLE-2026-05-12). If
// this fingerprint has provisioned anonymously before AND no active row
// exists today, require a one-time email claim instead of silently
// handing out another 24h free resource. Anonymous-only — the
// authenticated path returned above. Fails open on Redis/DB errors so
// the magic-first-touch wedge is never collateral damage.
if h.recycleGate(c, fp, "postgres") {
return nil
}
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)

// Provision new anonymous Postgres resource (expires in 24h).
expiresAt := time.Now().UTC().Add(24 * time.Hour)
Expand Down
6 changes: 5 additions & 1 deletion internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,11 @@ var codeToAgentAction = map[string]errorCodeMeta{
AgentAction: "Tell the user the object key is invalid. Use a non-empty UTF-8 path without traversal (../) — see https://instanode.dev/docs/storage.",
},
"invalid_operation": {
AgentAction: "Tell the user the operation value is invalid. Use GET or PUT for /storage/:token/presign — see https://instanode.dev/docs/storage.",
// API-8 (QA 2026-05-29): agent_action enum must match the error message
// enum exactly. Error message lists GET, PUT, HEAD as accepted — so
// must this. Drift surfaces as agent advice that contradicts the
// actual contract.
AgentAction: "Tell the user the operation value is invalid. Use GET, PUT, or HEAD for /storage/:token/presign — see https://instanode.dev/docs/storage.",
},
"path_unsafe": {
AgentAction: "Tell the user the object path contains unsafe characters. Use a clean UTF-8 path with no '..', leading slash, or empty segments — see https://instanode.dev/docs/storage.",
Expand Down
10 changes: 6 additions & 4 deletions internal/handlers/nosql.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error {
}

// ── Anonymous path ─────────────────────────────────────────────────────────
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
if h.recycleGate(c, fp, "mongodb") {
return nil
}

limitExceeded, err := h.checkProvisionLimit(ctx, fp)
if err != nil {
slog.Error("nosql.new.provision_limit_check_failed",
Expand Down Expand Up @@ -178,10 +183,7 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error {
}
}

// Free-tier recycle gate (see provision_helper.go for rationale).
if h.recycleGate(c, fp, "mongodb") {
return nil
}
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)

expiresAt := time.Now().UTC().Add(24 * time.Hour)
resource, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{
Expand Down
10 changes: 6 additions & 4 deletions internal/handlers/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error {
}

// ── Anonymous path ─────────────────────────────────────────────────────────
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
if h.recycleGate(c, fp, "queue") {
return nil
}

limitExceeded, err := h.checkProvisionLimit(ctx, fp)
if err != nil {
slog.Error("queue.new.provision_limit_check_failed",
Expand Down Expand Up @@ -258,10 +263,7 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error {
}
}

// Free-tier recycle gate (see provision_helper.go for rationale).
if h.recycleGate(c, fp, "queue") {
return nil
}
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)

expiresAt := time.Now().UTC().Add(24 * time.Hour)
resource, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{
Expand Down
201 changes: 201 additions & 0 deletions internal/handlers/recycle_gate_early_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package handlers_test

// recycle_gate_early_test.go — coverage pin for API-7 (QA 2026-05-29):
// the recycle gate now fires from the EARLIER position in storage/webhook/
// vector anonymous handlers (before checkProvisionLimit), so the existing
// recycle-gate fired-branch tests at the LATER position are no longer
// reachable for those handlers. This file adds the missing per-handler
// pin so a regression to the old ordering immediately reds.
//
// The cache/nosql/queue pin lives in anon_paths_provarms_test.go
// (TestAnonRecycleGate_Cache/NoSQL/Queue). The db pin lives there too
// (TestAnonRecycleGate_DB). storage/webhook/vector need their own
// fixtures because they're not mounted on the gRPC fixture.

import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"instant.dev/internal/config"
"instant.dev/internal/handlers"
"instant.dev/internal/middleware"
"instant.dev/internal/plans"
"instant.dev/internal/testhelpers"
)

// recycleGateApp mounts a single anonymous-path handler with the minimum
// middleware needed to drive a recycle-gate-fired path: RequestID + Fingerprint
// (for fp computation) + OptionalAuth (no-op for anonymous) + the handler.
// Idempotency middleware intentionally omitted — we want every POST to actually
// reach the handler.
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) {
t.Helper()
db, _ := testhelpers.SetupTestDB(t)
t.Cleanup(func() { db.Close() })
rdb, _ := testhelpers.SetupTestRedis(t)
t.Cleanup(func() { rdb.Close() })

cfg := &config.Config{
Port: "8080",
JWTSecret: testhelpers.TestJWTSecret,
AESKey: testhelpers.TestAESKeyHex,
EnabledServices: "postgres,redis,mongodb,queue,webhook,storage,vector",
Environment: "test",
}

app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
if errors.Is(err, handlers.ErrResponseWritten) {
return nil
}
return fiber.DefaultErrorHandler(c, err)
},
ProxyHeader: "X-Forwarded-For",
})
app.Use(middleware.RequestID())
app.Use(middleware.Fingerprint())

mount(app, db, rdb, cfg)
return app, db, rdb
}

// plantRecycleMarker computes the fingerprint via the middleware's helper
// (X-Forwarded-For + ASN) and writes the recycle-seen Redis marker so the
// gate will fire on the next request from the same IP. The fingerprint for
// an unknown IP comes purely from /24 subnet + ASN, so two calls from the
// same IP produce the same fp deterministically.
func plantRecycleMarker(t *testing.T, app *fiber.App, db *sql.DB, rdb *redis.Client, probePath, ip string, probeBody string) string {
t.Helper()
// Issue one cache /probe call (cache is always available + doesn't depend
// on a real backend) to learn the fp. The handler creates a row whose
// fingerprint we read back. We use cache because it's the simplest
// anonymous flow that doesn't need a real provisioner.
req := httptest.NewRequest(http.MethodPost, probePath, strings.NewReader(probeBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ip)
req.Header.Set("Idempotency-Key", uuid.NewString())
resp, err := app.Test(req, 10000)
require.NoError(t, err)
raw, _ := io.ReadAll(resp.Body)
defer resp.Body.Close()
require.Equalf(t, http.StatusCreated, resp.StatusCode, "probe call body: %s", raw)

// Extract token then look up the fingerprint from the row.
var probe struct {
Token string `json:"token"`
}
require.NoError(t, parseProbeJSON(raw, &probe))

var fp string
require.NoError(t, db.QueryRowContext(context.Background(),
`SELECT fingerprint FROM resources WHERE token = $1::uuid`, probe.Token).Scan(&fp))

// Soft-delete every active row for this fp so the gate's "zero active
// rows" condition is satisfied. Plant the marker.
_, err = db.ExecContext(context.Background(),
`UPDATE resources SET status = 'deleted' WHERE fingerprint = $1`, fp)
require.NoError(t, err)
require.NoError(t, rdb.Set(context.Background(),
handlers.RecycleSeenKeyPrefix+fp, "1", time.Hour).Err())
return fp
}

// parseProbeJSON is a tiny JSON decoder helper kept in this file so the test
// has zero dependencies on the larger provarms helpers (which need a gRPC
// fixture). We only need the token field.
func parseProbeJSON(raw []byte, out *struct {
Token string `json:"token"`
}) error {
return json.Unmarshal(raw, out)
}

// TestRecycleGate_EarlyFire_Storage covers the API-7 reorder: storage's
// recycle gate now fires from the early position in NewStorage. Pin: with
// a planted marker and zero active rows, /storage/new must 402.
func TestRecycleGate_EarlyFire_Storage(t *testing.T) {
provider := newDOSpacesProvider(t)
app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) {
// Both /cache/new (probe to learn fp) and /storage/new mounted.
cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default())
storageH := handlers.NewStorageHandler(db, rdb, cfg, provider, plans.Default())
app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache)
app.Post("/storage/new", middleware.OptionalAuth(cfg), storageH.NewStorage)
})
ip := "10.220.0.1"
plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`)

// Now /storage/new from the same IP must 402.
req := httptest.NewRequest(http.MethodPost, "/storage/new", strings.NewReader(`{"name":"recycle"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ip)
req.Header.Set("Idempotency-Key", uuid.NewString())
resp, err := app.Test(req, 10000)
require.NoError(t, err)
raw, _ := io.ReadAll(resp.Body)
defer resp.Body.Close()
require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode,
"/storage/new recycle gate must 402 (body=%s)", raw)
assert.Contains(t, string(raw), "free_tier_recycle_requires_claim")
}

// TestRecycleGate_EarlyFire_Webhook — same shape for /webhook/new.
func TestRecycleGate_EarlyFire_Webhook(t *testing.T) {
app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) {
cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default())
webhookH := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default())
app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache)
app.Post("/webhook/new", middleware.OptionalAuth(cfg), webhookH.NewWebhook)
})
ip := "10.221.0.1"
plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`)

req := httptest.NewRequest(http.MethodPost, "/webhook/new", strings.NewReader(`{"name":"recycle"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ip)
req.Header.Set("Idempotency-Key", uuid.NewString())
resp, err := app.Test(req, 10000)
require.NoError(t, err)
raw, _ := io.ReadAll(resp.Body)
defer resp.Body.Close()
require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode,
"/webhook/new recycle gate must 402 (body=%s)", raw)
assert.Contains(t, string(raw), "free_tier_recycle_requires_claim")
}

// TestRecycleGate_EarlyFire_Vector — same shape for /vector/new.
func TestRecycleGate_EarlyFire_Vector(t *testing.T) {
app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) {
cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default())
vectorH := handlers.NewVectorHandler(db, rdb, cfg, nil, plans.Default())
app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache)
app.Post("/vector/new", middleware.OptionalAuth(cfg), vectorH.NewVector)
})
ip := "10.222.0.1"
plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`)

req := httptest.NewRequest(http.MethodPost, "/vector/new", strings.NewReader(`{"name":"recycle"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ip)
req.Header.Set("Idempotency-Key", uuid.NewString())
resp, err := app.Test(req, 10000)
require.NoError(t, err)
raw, _ := io.ReadAll(resp.Body)
defer resp.Body.Close()
require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode,
"/vector/new recycle gate must 402 (body=%s)", raw)
assert.Contains(t, string(raw), "free_tier_recycle_requires_claim")
}
10 changes: 6 additions & 4 deletions internal/handlers/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error {
}

// ── Anonymous path ─────────────────────────────────────────────────────────
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
if h.recycleGate(c, fp, "storage") {
return nil
}

limitExceeded, err := h.checkProvisionLimit(ctx, fp)
if err != nil {
slog.Error("storage.new.provision_limit_check_failed",
Expand Down Expand Up @@ -264,10 +269,7 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error {
}
}

// Free-tier recycle gate (see provision_helper.go for rationale).
if h.recycleGate(c, fp, "storage") {
return nil
}
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)

// P1-B: enforce the anonymous-tier storage byte cap. The authenticated path
// (newStorageAuthenticated) sums SumStorageBytesByTeamAndType vs the tier
Expand Down
Loading
Loading