diff --git a/internal/handlers/anon_paths_provarms_test.go b/internal/handlers/anon_paths_provarms_test.go new file mode 100644 index 00000000..69008703 --- /dev/null +++ b/internal/handlers/anon_paths_provarms_test.go @@ -0,0 +1,181 @@ +package handlers_test + +// anon_paths_provarms_test.go — covers two anonymous-path branches the same- +// type dedup tests miss: +// +// 1. Cross-service daily-cap fallback (P1-A): 5 provisions of type A exhaust +// the per-fingerprint daily cap; a 6th of type B finds no type-B row but +// DOES find a type-A row via GetActiveResourceByFingerprint → 429 +// provision_limit_reached (instead of falling through to a fresh provision). +// +// 2. Dedup decrypt-failure fallthrough: when the existing same-type row's +// stored connection_url can't be decrypted, the handler logs and provisions +// FRESH rather than returning ciphertext. + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" +) + +// burstToCap sends `n` provisions of `path` from `ip`, each with a distinct +// Idempotency-Key so the handler runs every time. +func burstToCap(t *testing.T, fx grpcProvFixture, path, ip string, n int) { + t.Helper() + for i := 0; i < n; i++ { + resp, body := doProvisionKeyed(t, fx, path, ip, "", uuid.NewString(), map[string]any{"name": "burst"}) + resp.Body.Close() + require.Truef(t, body.OK, "burst call %d on %s", i+1, path) + } +} + +func TestAnonCrossServiceCapFallback_DBAfterCache(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + ip := "10.210.0.1" + burstToCap(t, fx, "/cache/new", ip, 5) // exhaust the daily cap with redis + + // 6th provision is a DIFFERENT type (postgres): no postgres row exists for + // this fingerprint, but a redis row does → cross-service 429. + resp, body := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "xservice"}) + defer resp.Body.Close() + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Equal(t, "provision_limit_reached", body.Error) +} + +func TestAnonCrossServiceCapFallback_QueueAfterCache(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + ip := "10.211.0.1" + burstToCap(t, fx, "/cache/new", ip, 5) + + resp, body := doProvisionKeyed(t, fx, "/queue/new", ip, "", uuid.NewString(), map[string]any{"name": "xservice-q"}) + defer resp.Body.Close() + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Equal(t, "provision_limit_reached", body.Error) +} + +func TestAnonCrossServiceCapFallback_NoSQLAfterCache(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + ip := "10.212.0.1" + burstToCap(t, fx, "/cache/new", ip, 5) + + resp, body := doProvisionKeyed(t, fx, "/nosql/new", ip, "", uuid.NewString(), map[string]any{"name": "xservice-m"}) + defer resp.Body.Close() + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Equal(t, "provision_limit_reached", body.Error) +} + +// Dedup decrypt-failure fallthrough: provision 5 postgres, corrupt the existing +// row's connection_url to garbage, then a 6th over-cap call hits the dedup +// branch, fails to decrypt, logs, and provisions FRESH (201) rather than +// returning ciphertext. +func TestAnonDedup_DecryptFailure_ProvisionsFresh(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + ip := "10.213.0.1" + + var firstToken string + for i := 0; i < 5; i++ { + resp, body := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "decryptfail"}) + resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + if i == 0 { + firstToken = body.Token + } + } + require.NotEmpty(t, firstToken) + + // Corrupt every active postgres row's stored connection_url for this + // fingerprint so the dedup decrypt fails. + _, err := fx.db.ExecContext(context.Background(), ` + UPDATE resources SET connection_url = 'not-decryptable-ciphertext' + WHERE resource_type = 'postgres' AND status = 'active' AND team_id IS NULL + AND fingerprint = (SELECT fingerprint FROM resources WHERE token = $1::uuid) + `, firstToken) + require.NoError(t, err) + + // 6th over-cap call: dedup decrypt fails → fall through → fresh 201. + resp, body := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "decryptfail-6"}) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode, "decrypt-fail dedup must provision fresh, not 200 with ciphertext") + assert.NotEmpty(t, body.ConnectionURL) + assert.NotContains(t, body.ConnectionURL, "not-decryptable", "must never return ciphertext") +} + +// recycleGate fired via the gRPC fixture (provisioning works) for cache / nosql +// / queue: plant a recycle_seen marker + zero active rows for the fingerprint → +// 402 free_tier_recycle_requires_claim. Covers the recycleGate-true branch in +// each handler (the db variant lives in redis_fault_provarms_test.go). +func recycleGateOnce(t *testing.T, path, ip, resourceType string) { + t.Helper() + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + + // Provision once to learn the fingerprint, then clear active rows + plant + // the marker so the next call trips the gate. + resp, body := doProvisionKeyed(t, fx, path, ip, "", uuid.NewString(), map[string]any{"name": "rg-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()) + + resp2, body2 := doProvisionKeyed(t, fx, path, ip, "", uuid.NewString(), map[string]any{"name": "rg-fire"}) + defer resp2.Body.Close() + require.Equalf(t, http.StatusPaymentRequired, resp2.StatusCode, "%s recycle gate should 402", resourceType) + assert.Equal(t, "free_tier_recycle_requires_claim", body2.Error) +} + +func TestAnonRecycleGate_Cache(t *testing.T) { recycleGateOnce(t, "/cache/new", "10.214.0.1", "redis") } +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") } + +// 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 +// ciphertext). Covers the dedup decrypt-fail fallthrough for cache/nosql/queue. +func dedupDecryptFailOnce(t *testing.T, path, ip, resourceType string) { + t.Helper() + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + + var firstToken string + for i := 0; i < 5; i++ { + resp, body := doProvisionKeyed(t, fx, path, ip, "", uuid.NewString(), map[string]any{"name": "ddf"}) + resp.Body.Close() + require.Equalf(t, http.StatusCreated, resp.StatusCode, "%s call %d", path, i+1) + if i == 0 { + firstToken = body.Token + } + } + _, err := fx.db.ExecContext(context.Background(), ` + UPDATE resources SET connection_url = 'not-decryptable' + WHERE resource_type = $1 AND status = 'active' AND team_id IS NULL + AND fingerprint = (SELECT fingerprint FROM resources WHERE token = $2::uuid) + `, resourceType, firstToken) + require.NoError(t, err) + + resp, body := doProvisionKeyed(t, fx, path, ip, "", uuid.NewString(), map[string]any{"name": "ddf-6"}) + defer resp.Body.Close() + require.Equalf(t, http.StatusCreated, resp.StatusCode, "%s decrypt-fail dedup must provision fresh", path) + assert.NotContains(t, body.ConnectionURL, "not-decryptable") +} + +func TestAnonDedupDecryptFail_Cache(t *testing.T) { dedupDecryptFailOnce(t, "/cache/new", "10.217.0.1", "redis") } +func TestAnonDedupDecryptFail_NoSQL(t *testing.T) { dedupDecryptFailOnce(t, "/nosql/new", "10.218.0.1", "mongodb") } +func TestAnonDedupDecryptFail_Queue(t *testing.T) { dedupDecryptFailOnce(t, "/queue/new", "10.219.0.1", "queue") } diff --git a/internal/handlers/constructors_provarms_test.go b/internal/handlers/constructors_provarms_test.go new file mode 100644 index 00000000..2ec8ead2 --- /dev/null +++ b/internal/handlers/constructors_provarms_test.go @@ -0,0 +1,74 @@ +package handlers_test + +// constructors_provarms_test.go — drives the branches of NewQueueHandler and +// NewStorageHandler that the test app's single construction path doesn't reach: +// - NewQueueHandler: buildQueueProvider-error → defensive legacy_open fallback +// - NewStorageHandler: cfg.MinioEndpoint set → provider auto-init success/fail + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/plans" +) + +// NewQueueHandler with an unknown QUEUE_BACKEND: buildQueueProvider returns an +// error, so the constructor takes the defensive fallback that builds a +// legacy_open provider directly. Constructor must not panic and must yield a +// usable handler (issueTenantCreds returns legacy_open creds). +func TestNewQueueHandler_BadBackend_FallsBackToLegacyOpen(t *testing.T) { + cfg := &config.Config{ + QueueBackend: "bogus-backend", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + h := handlers.NewQueueHandler(nil, nil, cfg, nil, plans.Default()) + require.NotNil(t, h) + // The defensive fallback wired a legacy_open credProvider — a valid token + // yields legacy_open creds with no error. + creds, err := h.IssueTenantCredsForTest(context.Background(), "tok-x", "subj") + require.NoError(t, err) + require.NotNil(t, creds) +} + +// NewStorageHandler auto-inits from cfg.MinioEndpoint when no provider is +// injected. With valid root creds storageprovider.New succeeds (madmin.New does +// not dial at construction). +func TestNewStorageHandler_MinioEndpoint_AutoInitSuccess(t *testing.T) { + cfg := &config.Config{ + EnabledServices: "storage", + AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + MinioEndpoint: "minio.test.local:9000", + MinioPublicEndpoint: "http://minio.test.local:9000", + MinioRootUser: "minioadmin", + MinioRootPassword: "minioadmin", + MinioBucketName: "instant-shared", + } + h := handlers.NewStorageHandler(nil, nil, cfg, nil, plans.Default()) + require.NotNil(t, h) + // Provider was auto-initialised → decideStorageMode is not "unavailable". + kind, _ := h.DecideStorageModeKindForTest("anonymous") + assert.NotEqual(t, "unavailable", kind) +} + +// NewStorageHandler: MinioEndpoint set but root creds missing → storageprovider.New +// returns an error → the Warn branch runs and the provider stays nil. +func TestNewStorageHandler_MinioEndpoint_AutoInitError_ProviderNil(t *testing.T) { + cfg := &config.Config{ + EnabledServices: "storage", + AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + MinioEndpoint: "minio.test.local:9000", + MinioRootUser: "", // missing → New errors + MinioRootPassword: "", + } + h := handlers.NewStorageHandler(nil, nil, cfg, nil, plans.Default()) + require.NotNil(t, h) + kind, _ := h.DecideStorageModeKindForTest("anonymous") + assert.Equal(t, "unavailable", kind, "failed auto-init must leave provider nil") +} diff --git a/internal/handlers/db_fault_provarms_test.go b/internal/handlers/db_fault_provarms_test.go new file mode 100644 index 00000000..93c460be --- /dev/null +++ b/internal/handlers/db_fault_provarms_test.go @@ -0,0 +1,253 @@ +package handlers_test + +// db_fault_provarms_test.go — drives the team-lookup-failure (503 +// team_lookup_failed) branch of every authenticated provisioning handler by +// pointing the handler at a CLOSED *sql.DB. A closed DB makes GetTeamByID +// return an error after the JWT auth middleware has already populated a +// (syntactically valid) team_id, so control reaches the team_lookup_failed +// branch that the happy-path fixtures never exercise. +// +// The JWT itself is validated by middleware against cfg.JWTSecret (no DB), so a +// closed DB only trips once the handler queries it — exactly the branch we want. + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "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" +) + +// testDSN returns the platform test DB DSN (TEST_DATABASE_URL with a default +// matching testhelpers). +func testDSN() string { + if d := os.Getenv("TEST_DATABASE_URL"); d != "" { + return d + } + return "postgres://postgres:postgres@127.0.0.1:5432/instant_dev_test?sslmode=disable" +} + +// closedDBFixture wires the four provisioning handlers against a CLOSED DB so +// the authenticated path's GetTeamByID fails. A live Redis is still needed for +// the rate-limit + auth middleware chain. +type closedDBFixture struct { + app *fiber.App + rdb *redis.Client +} + +func setupClosedDBFixture(t *testing.T) (closedDBFixture, string) { + t.Helper() + // A real DB to mint a valid team+user+JWT, then we CLOSE a SEPARATE handle + // so the handler's queries fail while the JWT stays valid. + liveDB, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { liveDB.Close() }) + teamID := testhelpers.MustCreateTeamDB(t, liveDB, "pro") + jwt := authSessionJWT(t, liveDB, teamID) + + dsn := testDSN() + closed, err := sql.Open("postgres", dsn) + require.NoError(t, err) + require.NoError(t, closed.Close()) // every query now errors + + 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,storage", + Environment: "test", + PostgresProvisionBackend: "local", + ObjectStoreBucket: "instant-shared", + ObjectStoreEndpoint: "nyc3.test.local", + ObjectStoreAccessKey: "MK", + ObjectStoreSecretKey: "MS", + } + planReg := plans.Default() + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.SendStatus(fiber.StatusInternalServerError) + }, + ProxyHeader: "X-Forwarded-For", + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Use(middleware.RateLimit(rdb, middleware.RateLimitConfig{Limit: 100, KeyPrefix: "rlclosed"})) + + dbH := handlers.NewDBHandler(closed, rdb, cfg, nil, planReg) + cacheH := handlers.NewCacheHandler(closed, rdb, cfg, nil, planReg) + nosqlH := handlers.NewNoSQLHandler(closed, rdb, cfg, nil, planReg) + queueH := handlers.NewQueueHandler(closed, rdb, cfg, nil, planReg) + storageH := handlers.NewStorageHandler(closed, rdb, cfg, newDOSpacesProvider(t), planReg) + + app.Post("/db/new", middleware.OptionalAuth(cfg), dbH.NewDB) + app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache) + app.Post("/nosql/new", middleware.OptionalAuth(cfg), nosqlH.NewNoSQL) + app.Post("/queue/new", middleware.OptionalAuth(cfg), queueH.NewQueue) + app.Post("/storage/new", middleware.OptionalAuth(cfg), storageH.NewStorage) + + return closedDBFixture{app: app, rdb: rdb}, jwt +} + +func postClosed(t *testing.T, fx closedDBFixture, path, jwt string) (int, string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"x"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.200.0.1") + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := fx.app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + var env struct { + Error string `json:"error"` + } + _ = json.Unmarshal(raw, &env) + return resp.StatusCode, env.Error +} + +func TestAuthProvision_TeamLookupFailure_AllHandlers(t *testing.T) { + fx, jwt := setupClosedDBFixture(t) + for _, path := range []string{"/db/new", "/cache/new", "/nosql/new", "/queue/new", "/storage/new"} { + status, errCode := postClosed(t, fx, path, jwt) + assert.Equalf(t, http.StatusServiceUnavailable, status, "%s should 503 on DB fault", path) + assert.Equalf(t, "team_lookup_failed", errCode, "%s error code", path) + } +} + +// Anonymous provision against a closed DB: checkProvisionLimit (redis) passes, +// recycleGate's DB lookup fails-open, then CreateResource fails on the closed DB +// → the create_resource_failed branch returns 503 provision_failed. Covers that +// branch in every anonymous handler arm. +func TestAnonProvision_CreateResourceFailure_AllHandlers(t *testing.T) { + fx, _ := setupClosedDBFixture(t) + for i, path := range []string{"/db/new", "/cache/new", "/nosql/new", "/queue/new", "/storage/new"} { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"x"}`)) + req.Header.Set("Content-Type", "application/json") + // Distinct IP per handler so each gets its own fingerprint + cap counter. + req.Header.Set("X-Forwarded-For", "10.201."+digitStr(i)+".1") + resp, err := fx.app.Test(req, 10000) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var env struct { + Error string `json:"error"` + } + _ = json.Unmarshal(raw, &env) + assert.Equalf(t, http.StatusServiceUnavailable, resp.StatusCode, "%s anon create-fail should 503", path) + assert.Equalf(t, "provision_failed", env.Error, "%s error code", path) + } +} + +func digitStr(i int) string { return string(rune('0' + i)) } + +// readOnlyDSN appends a session option that makes the connection reject writes +// (default_transaction_read_only=on). SELECTs succeed; INSERT/UPDATE fail — +// exactly the shape needed to reach the authenticated-path create_resource_failed +// branch (team SELECT ok → CreateResource INSERT fails). +func readOnlyDSN() string { + d := testDSN() + sep := "?" + if strings.Contains(d, "?") { + sep = "&" + } + return d + sep + "options=-c%20default_transaction_read_only%3Don" +} + +// Authenticated provision against a READ-ONLY DB: GetTeamByID (SELECT) succeeds +// but CreateResource (INSERT) fails → the create_resource_failed branch returns +// 503 provision_failed in every authenticated handler arm. +func TestAuthProvision_CreateResourceFailure_AllHandlers(t *testing.T) { + liveDB, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { liveDB.Close() }) + teamID := testhelpers.MustCreateTeamDB(t, liveDB, "pro") + jwt := authSessionJWT(t, liveDB, teamID) + + roDB, err := sql.Open("postgres", readOnlyDSN()) + require.NoError(t, err) + t.Cleanup(func() { roDB.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,storage", + Environment: "test", + PostgresProvisionBackend: "local", + ObjectStoreBucket: "instant-shared", + ObjectStoreEndpoint: "nyc3.test.local", + ObjectStoreAccessKey: "MK", + ObjectStoreSecretKey: "MS", + } + planReg := plans.Default() + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.SendStatus(fiber.StatusInternalServerError) + }, + ProxyHeader: "X-Forwarded-For", + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Use(middleware.RateLimit(rdb, middleware.RateLimitConfig{Limit: 100, KeyPrefix: "rlro"})) + + app.Post("/db/new", middleware.OptionalAuth(cfg), handlers.NewDBHandler(roDB, rdb, cfg, nil, planReg).NewDB) + app.Post("/cache/new", middleware.OptionalAuth(cfg), handlers.NewCacheHandler(roDB, rdb, cfg, nil, planReg).NewCache) + app.Post("/nosql/new", middleware.OptionalAuth(cfg), handlers.NewNoSQLHandler(roDB, rdb, cfg, nil, planReg).NewNoSQL) + app.Post("/queue/new", middleware.OptionalAuth(cfg), handlers.NewQueueHandler(roDB, rdb, cfg, nil, planReg).NewQueue) + app.Post("/storage/new", middleware.OptionalAuth(cfg), handlers.NewStorageHandler(roDB, rdb, cfg, newDOSpacesProvider(t), planReg).NewStorage) + + for _, path := range []string{"/db/new", "/cache/new", "/nosql/new", "/queue/new", "/storage/new"} { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"x"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.202.0.1") + req.Header.Set("Authorization", "Bearer "+jwt) + resp, rerr := app.Test(req, 10000) + require.NoError(t, rerr) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var env struct { + Error string `json:"error"` + } + _ = json.Unmarshal(raw, &env) + assert.Equalf(t, http.StatusServiceUnavailable, resp.StatusCode, "%s auth create-fail should 503 (body=%s)", path, raw) + assert.Equalf(t, "provision_failed", env.Error, "%s error code", path) + } +} + +// invalid team id in JWT → 400 invalid_team across all authenticated handlers. +func TestAuthProvision_InvalidTeamID_AllHandlers(t *testing.T) { + fx, _ := setupClosedDBFixture(t) + badJWT := testhelpers.MustSignSessionJWT(t, uuid.NewString(), "not-a-uuid", testhelpers.UniqueEmail(t)) + for _, path := range []string{"/db/new", "/cache/new", "/nosql/new", "/queue/new", "/storage/new"} { + status, errCode := postClosed(t, fx, path, badJWT) + assert.Equalf(t, http.StatusBadRequest, status, "%s should 400 on bad team id", path) + assert.Equalf(t, "invalid_team", errCode, "%s error code", path) + } +} diff --git a/internal/handlers/decrypt_url_provarms_test.go b/internal/handlers/decrypt_url_provarms_test.go new file mode 100644 index 00000000..ff8db810 --- /dev/null +++ b/internal/handlers/decrypt_url_provarms_test.go @@ -0,0 +1,88 @@ +package handlers_test + +// decrypt_url_provarms_test.go — drives the two fail-closed error branches of +// every provisioning handler's decryptConnectionURL / decryptStorageURL: +// - AES key parse error → ("", false) (bad AES_KEY hex in cfg) +// - ciphertext decrypt error → ("", false) (garbage stored value) +// plus the empty-input ("", true) early return. These can't be reached via the +// HTTP dedup path (it requires a real, decryptable row first), so we call the +// handler methods directly with crafted inputs and a nil DB (decrypt touches +// only cfg.AESKey). + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +func goodAESConfig() *config.Config { + return &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres,redis,mongodb,queue,storage"} +} +func badAESConfig() *config.Config { + return &config.Config{AESKey: "not-valid-hex", EnabledServices: "postgres,redis,mongodb,queue,storage"} +} + +// decryptFn is the common (enc, requestID) → (plain, ok) shape every handler +// exposes via its *ForTest re-export. +type decryptFn func(enc, rid string) (string, bool) + +func TestDecryptConnectionURL_AllHandlers_ErrorAndEmptyBranches(t *testing.T) { + reg := plans.Default() + + // Build one handler of each type with a GOOD key (for the empty + decrypt- + // error branches) and a BAD key (for the parse-error branch). nil db/rdb is + // safe — none of these constructors dial at build time and decrypt only + // reads cfg.AESKey. + good := goodAESConfig() + bad := badAESConfig() + + dbGood := handlers.NewDBHandler(nil, nil, good, nil, reg) + dbBad := handlers.NewDBHandler(nil, nil, bad, nil, reg) + cacheGood := handlers.NewCacheHandler(nil, nil, good, nil, reg) + cacheBad := handlers.NewCacheHandler(nil, nil, bad, nil, reg) + nosqlGood := handlers.NewNoSQLHandler(nil, nil, good, nil, reg) + nosqlBad := handlers.NewNoSQLHandler(nil, nil, bad, nil, reg) + queueGood := handlers.NewQueueHandler(nil, nil, good, nil, reg) + queueBad := handlers.NewQueueHandler(nil, nil, bad, nil, reg) + storageGood := handlers.NewStorageHandler(nil, nil, good, nil, reg) + storageBad := handlers.NewStorageHandler(nil, nil, bad, nil, reg) + + goodFns := map[string]decryptFn{ + "db": dbGood.DecryptConnectionURLForTest, + "cache": cacheGood.DecryptConnectionURLForTest, + "nosql": nosqlGood.DecryptConnectionURLForTest, + "queue": queueGood.DecryptConnectionURLForTest, + "storage": storageGood.DecryptStorageURLForTest, + } + badFns := map[string]decryptFn{ + "db": dbBad.DecryptConnectionURLForTest, + "cache": cacheBad.DecryptConnectionURLForTest, + "nosql": nosqlBad.DecryptConnectionURLForTest, + "queue": queueBad.DecryptConnectionURLForTest, + "storage": storageBad.DecryptStorageURLForTest, + } + + for name, fn := range goodFns { + // Empty input → ("", true): nothing to decrypt. + plain, ok := fn("", "req-empty") + assert.True(t, ok, "%s: empty input must report ok=true", name) + assert.Empty(t, plain, "%s: empty input returns empty", name) + + // Garbage ciphertext under a valid key → decrypt error → ("", false). + plain, ok = fn("this-is-not-valid-ciphertext", "req-garbage") + assert.False(t, ok, "%s: undecryptable input must fail closed (ok=false)", name) + assert.Empty(t, plain, "%s: must NOT return ciphertext as a connection_url", name) + } + + for name, fn := range badFns { + // Non-empty input under an unparseable AES key → parse error → ("", false). + plain, ok := fn("anything-nonempty", "req-badkey") + assert.False(t, ok, "%s: bad AES key must fail closed (ok=false)", name) + assert.Empty(t, plain, "%s: bad key returns no plaintext", name) + } +} diff --git a/internal/handlers/export_provarms_test.go b/internal/handlers/export_provarms_test.go new file mode 100644 index 00000000..36d3e0b9 --- /dev/null +++ b/internal/handlers/export_provarms_test.go @@ -0,0 +1,165 @@ +package handlers + +// export_provarms_test.go — test-only re-exports for the provisioning-arms +// coverage slice (db/cache/nosql/queue/queue_provider/storage/storage_presign/ +// provision_helper/family_bulk_twin). Kept in a dedicated file (NOT the shared +// export_test.go) so concurrent coverage work on other handler files never +// collides on the same file. Go only compiles this in test builds. + +import ( + "context" + "database/sql" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "instant.dev/common/queueprovider" + "instant.dev/internal/config" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/provisioner" +) + +// BuildQueueProviderForTest re-exports the unexported buildQueueProvider so the +// external handlers_test package can drive its backend-selection branches +// (legacy_open fallback, nats-when-seed, explicit backend, Factory error) +// without standing up a real NATS server. +func BuildQueueProviderForTest(cfg *config.Config) (queueprovider.QueueCredentialProvider, error) { + return buildQueueProvider(cfg) +} + +// IsSafePresignKeyForTest re-exports isSafePresignKey. +func IsSafePresignKeyForTest(in string) bool { return isSafePresignKey(in) } + +// SanitisePresignKeyForTest re-exports sanitisePresignKey. +func SanitisePresignKeyForTest(in string) string { return sanitisePresignKey(in) } + +// MaskPresignTokenForAuditForTest re-exports maskPresignTokenForAudit. +func MaskPresignTokenForAuditForTest(token string) string { return maskPresignTokenForAudit(token) } + +// MaskPresignKeyForAuditForTest re-exports maskPresignKeyForAudit. +func MaskPresignKeyForAuditForTest(key string) string { return maskPresignKeyForAudit(key) } + +// ── decryptConnectionURL re-exports (one per handler) ─────────────────────── +// Each handler carries its own fail-closed decryptConnectionURL whose +// AES-parse-error and Decrypt-error branches the dedup-path tests can't reach +// (the dedup path needs a successfully-provisioned + decryptable row first). +// These re-exports drive those two error branches directly with a bad AES key +// (parse error) and a garbage-but-parseable ciphertext (decrypt error). + +func (h *DBHandler) DecryptConnectionURLForTest(enc, rid string) (string, bool) { + return h.decryptConnectionURL(enc, rid) +} +func (h *CacheHandler) DecryptConnectionURLForTest(enc, rid string) (string, bool) { + return h.decryptConnectionURL(enc, rid) +} +func (h *NoSQLHandler) DecryptConnectionURLForTest(enc, rid string) (string, bool) { + return h.decryptConnectionURL(enc, rid) +} +func (h *QueueHandler) DecryptConnectionURLForTest(enc, rid string) (string, bool) { + return h.decryptConnectionURL(enc, rid) +} +func (h *StorageHandler) DecryptStorageURLForTest(enc, rid string) (string, bool) { + return h.decryptStorageURL(enc, rid) +} + +// ── small pure-helper re-exports ──────────────────────────────────────────── + +// FormatDurationForTest re-exports formatDuration. +func FormatDurationForTest(d time.Duration) string { return formatDuration(d) } + +// DecideStorageModeKindForTest re-exports StorageHandler.decideStorageMode and +// returns its (kind, reason) so the unavailable + capability branches can be +// asserted on the REAL handler (not just the stub mirror). +func (h *StorageHandler) DecideStorageModeKindForTest(tier string) (kind, reason string) { + s := h.decideStorageMode(tier) + return s.kind, s.reason +} + +// SanitizeNameForRequestForTest re-exports sanitizeNameForRequest so the +// invalid-UTF8 (writes 400 invalid_name + ErrResponseWritten) and clean-name +// branches can be driven with a throwaway fiber.Ctx. +func SanitizeNameForRequestForTest(c *fiber.Ctx, name string) (string, error) { + return sanitizeNameForRequest(c, name) +} + +// SignStorageURLForTest re-exports StorageHandler.signStorageURL so the +// missing-config error branches (no bucket/endpoint, no master key) can be +// covered without a real S3 round-trip. +func (h *StorageHandler) SignStorageURLForTest(ctx context.Context, op, objectKey string, ttl time.Duration) (string, time.Time, error) { + return h.signStorageURL(ctx, op, objectKey, ttl) +} + +// FinalizeProvisionForTest re-exports provisionHelper.finalizeProvision (via +// the embedding DBHandler) so its persistence-failure branches — UpdateKeyPrefix +// failure, soft-delete-failure logging, audit-emit failure — can be driven with +// a closed DB without going through a full HTTP provision. +func (h *DBHandler) FinalizeProvisionForTest(ctx context.Context, resource *models.Resource, connectionURL, keyPrefix, prid, requestID, logPrefix string, cleanup func()) error { + return h.finalizeProvision(ctx, resource, connectionURL, keyPrefix, prid, requestID, logPrefix, cleanup) +} + +// EmitProvisionPersistenceFailedAuditForTest re-exports the audit emitter so the +// TeamID-valid branch + audit-store-error log branch can be driven directly. +func EmitProvisionPersistenceFailedAuditForTest(ctx context.Context, db *sql.DB, res *models.Resource, prid, requestID, logPrefix string) { + emitProvisionPersistenceFailedAudit(ctx, db, res, prid, requestID, logPrefix) +} + +// RequireNameForTest re-exports requireName so the invalid-format / too-long / +// name-normalisation branches can be driven with a throwaway fiber.Ctx. +func RequireNameForTest(c *fiber.Ctx, raw string) (string, error) { return requireName(c, raw) } + +// CheckProvisionLimitForTest re-exports provisionHelper.checkProvisionLimit +// (via the embedding DBHandler) so the Redis-error branch can be driven with a +// closed redis client. +func (h *DBHandler) CheckProvisionLimitForTest(ctx context.Context, fp string) (bool, error) { + return h.checkProvisionLimit(ctx, fp) +} + +// MarkRecycleSeenForTest re-exports markRecycleSeen for the empty-fp early +// return + redis-error branches. +func (h *DBHandler) MarkRecycleSeenForTest(ctx context.Context, fp string) error { + return h.markRecycleSeen(ctx, fp) +} + +// RecycleSeenForTest re-exports recycleSeen (empty-fp + redis-error). +func (h *DBHandler) RecycleSeenForTest(ctx context.Context, fp string) (bool, error) { + return h.recycleSeen(ctx, fp) +} + +// FindParentsForTest re-exports BulkTwinHandler.findParents so the +// paused-skip / wrong-type-skip / already-a-twin-skip filters can be asserted +// against seeded rows. +func (h *BulkTwinHandler) FindParentsForTest(ctx context.Context, teamID uuid.UUID, sourceEnv string, typeFilter map[string]struct{}) ([]*models.Resource, error) { + return h.findParents(ctx, teamID, sourceEnv, typeFilter) +} + +// ResolveHeadroomForTest re-exports BulkTwinHandler.resolveHeadroom so the +// nil-hook default + negative-clamp branches can be covered. +func (h *BulkTwinHandler) ResolveHeadroomForTest(ctx context.Context, teamID uuid.UUID, resourceType string) int { + return h.resolveHeadroom(ctx, teamID, resourceType) +} + +// NewBulkTwinHandlerPanicsForTest invokes NewBulkTwinHandler with a nil +// sub-handler so the constructor's panic guard can be asserted with +// require.Panics. +func NewBulkTwinHandlerPanicsForTest(db *sql.DB, reg *plans.Registry) { + _ = NewBulkTwinHandler(db, nil, nil, nil, reg) +} + +// NullStrOrEmptyForTest re-exports nullStrOrEmpty. +func NullStrOrEmptyForTest(ns sql.NullString) string { return nullStrOrEmpty(ns) } + +// IssueTenantCredsForTest re-exports QueueHandler.issueTenantCreds so the +// error branch (provider returns an error → metric + log + return err) can be +// driven directly: the legacy_open provider errors on an empty ResourceToken. +func (h *QueueHandler) IssueTenantCredsForTest(ctx context.Context, token, subjectPrefix string) (*queueprovider.TenantCreds, error) { + return h.issueTenantCreds(ctx, token, subjectPrefix) +} + +// DeprovisionBestEffortForTest re-exports deprovisionBestEffort so the +// nil-provClient no-op and unknown-resource-type early returns can be covered +// without a live provisioner. +func DeprovisionBestEffortForTest(ctx context.Context, pc *provisioner.Client, token, prid, resourceType, logPrefix string) { + deprovisionBestEffort(ctx, pc, token, prid, resourceType, logPrefix) +} diff --git a/internal/handlers/family_bulk_twin_provarms_test.go b/internal/handlers/family_bulk_twin_provarms_test.go new file mode 100644 index 00000000..64452bc9 --- /dev/null +++ b/internal/handlers/family_bulk_twin_provarms_test.go @@ -0,0 +1,99 @@ +package handlers_test + +// family_bulk_twin_provarms_test.go — fills the validation + filter branches of +// BulkTwin that the existing family_bulk_twin_test.go suite leaves uncovered: +// missing/invalid source+target env, same-env, all-unsupported-types filter +// (200 + twinned=0), and the unauthenticated path. These reach BulkTwin's early +// returns before any provisioning, so they need no object-store / DB backend +// beyond the platform DB the test app already wires. + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +func bulkTwinApp(t *testing.T) (app interface { + Test(req *http.Request, msTimeout ...int) (*http.Response, error) +}, jwt string) { + t.Helper() + db, cleanDB := testhelpers.SetupTestDB(t) + t.Cleanup(cleanDB) + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + t.Cleanup(cleanRedis) + a, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb") + t.Cleanup(cleanApp) + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + return a, bulkTwinJWT(t, db, teamID) +} + +func TestBulkTwin_MissingSourceEnv_Returns400(t *testing.T) { + app, jwt := bulkTwinApp(t) + resp := postBulkTwin(t, app, jwt, map[string]any{"target_env": "staging"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "missing_source_env", decodeBulkTwinResp(t, resp).Error) +} + +func TestBulkTwin_MissingTargetEnv_Returns400(t *testing.T) { + app, jwt := bulkTwinApp(t) + resp := postBulkTwin(t, app, jwt, map[string]any{"source_env": "production"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "missing_target_env", decodeBulkTwinResp(t, resp).Error) +} + +func TestBulkTwin_InvalidSourceEnv_Returns400(t *testing.T) { + app, jwt := bulkTwinApp(t) + resp := postBulkTwin(t, app, jwt, map[string]any{"source_env": "BAD ENV", "target_env": "staging"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_source_env", decodeBulkTwinResp(t, resp).Error) +} + +func TestBulkTwin_InvalidTargetEnv_Returns400(t *testing.T) { + app, jwt := bulkTwinApp(t) + resp := postBulkTwin(t, app, jwt, map[string]any{"source_env": "production", "target_env": "BAD ENV"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_target_env", decodeBulkTwinResp(t, resp).Error) +} + +func TestBulkTwin_SameEnv_Returns400(t *testing.T) { + app, jwt := bulkTwinApp(t) + resp := postBulkTwin(t, app, jwt, map[string]any{"source_env": "production", "target_env": "production"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "same_env", decodeBulkTwinResp(t, resp).Error) +} + +// All-unsupported resource_types filter (webhook/queue/storage have no per-env +// infra) → 200 OK + twinned=0, not a 4xx. Lets the caller observe the no-op. +func TestBulkTwin_AllUnsupportedTypes_Returns200Zero(t *testing.T) { + app, jwt := bulkTwinApp(t) + resp := postBulkTwin(t, app, jwt, map[string]any{ + "source_env": "production", + "target_env": "staging", + "resource_types": []string{"webhook", "queue", "storage"}, + }) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body := decodeBulkTwinResp(t, resp) + assert.True(t, body.OK) + assert.Equal(t, 0, body.Twinned) + assert.Empty(t, body.Items) + assert.Empty(t, body.Failures) +} + +// Unauthenticated → 401 unauthorized (parseTeamID on empty team id). +func TestBulkTwin_Unauthenticated_Returns401(t *testing.T) { + app, _ := bulkTwinApp(t) + resp := postBulkTwin(t, app, "", map[string]any{"source_env": "production", "target_env": "staging"}) + defer resp.Body.Close() + // RequireAuth middleware rejects before the handler when no JWT is present. + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/internal/handlers/finalize_provision_provarms_test.go b/internal/handlers/finalize_provision_provarms_test.go new file mode 100644 index 00000000..00a4142c --- /dev/null +++ b/internal/handlers/finalize_provision_provarms_test.go @@ -0,0 +1,107 @@ +package handlers_test + +// finalize_provision_provarms_test.go — covers the persistence-failure branches +// of finalizeProvision (provision_helper.go) and emitProvisionPersistenceFailed- +// Audit that the happy-path provisions never reach: +// - UpdateKeyPrefix failure (closed DB) → persistFailed → cleanup runs +// - SoftDeleteResource failure logging (closed DB) → logged + swallowed +// - audit emit failure (closed DB) → logged + swallowed +// - TeamID.Valid branch in the audit emitter + +import ( + "context" + "database/sql" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +func closedPlatformDB(t *testing.T) *sql.DB { + t.Helper() + d, err := sql.Open("postgres", testDSN()) + require.NoError(t, err) + require.NoError(t, d.Close()) + return d +} + +// finalizeProvision with a CLOSED DB + keyPrefix set: UpdateKeyPrefix fails → +// persistFailed → cleanup closure runs, SoftDeleteResource fails (logged), audit +// emit fails (logged) → returns the persistence-failed sentinel. +func TestFinalizeProvision_KeyPrefixUpdateFailure_RunsCleanup(t *testing.T) { + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "redis"} + h := handlers.NewDBHandler(closedPlatformDB(t), nil, cfg, nil, plans.Default()) + + res := &models.Resource{ + ID: uuid.New(), + TeamID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + ResourceType: "redis", + Tier: "pro", + Env: "development", + } + cleanupRan := false + err := h.FinalizeProvisionForTest(context.Background(), res, + "redis://user:pw@host:6379", "kp_abc", "prid-1", "req-1", "cache.new", + func() { cleanupRan = true }) + + require.Error(t, err, "closed DB must surface a persistence failure") + assert.True(t, cleanupRan, "persistence failure must run the cleanup closure") +} + +// finalizeProvision with a CLOSED DB, no keyPrefix, good AES: encrypt succeeds, +// UpdateConnectionURL fails → persistFailed → cleanup + soft-delete (fails, +// logged) + audit (fails, logged). +func TestFinalizeProvision_ConnURLUpdateFailure_RunsCleanup(t *testing.T) { + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres"} + h := handlers.NewDBHandler(closedPlatformDB(t), nil, cfg, nil, plans.Default()) + + res := &models.Resource{ + ID: uuid.New(), + TeamID: uuid.NullUUID{Valid: false}, // anonymous (no team) branch + ResourceType: "postgres", + Tier: "anonymous", + Env: "development", + } + cleanupRan := false + err := h.FinalizeProvisionForTest(context.Background(), res, + "postgres://u:p@h:5432/db", "", "prid-2", "req-2", "db.new", + func() { cleanupRan = true }) + require.Error(t, err) + assert.True(t, cleanupRan) +} + +// emitProvisionPersistenceFailedAudit directly: TeamID-valid branch + audit +// store error (closed DB) is logged and swallowed (no panic, returns void). +func TestEmitProvisionPersistenceFailedAudit_TeamIDValid_AuditError(t *testing.T) { + res := &models.Resource{ + ID: uuid.New(), + TeamID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + ResourceType: "storage", + Tier: "pro", + Env: "production", + } + // Closed DB → InsertAuditEvent errors → emitter logs + swallows. + handlers.EmitProvisionPersistenceFailedAuditForTest(context.Background(), + closedPlatformDB(t), res, "prid-3", "req-3", "storage.new") +} + +// emitProvisionPersistenceFailedAudit with an anonymous (no-team) resource so +// the TeamID-invalid branch (teamID stays zero) runs. +func TestEmitProvisionPersistenceFailedAudit_NoTeam(t *testing.T) { + res := &models.Resource{ + ID: uuid.New(), + TeamID: uuid.NullUUID{Valid: false}, + ResourceType: "postgres", + Tier: "anonymous", + Env: "development", + } + handlers.EmitProvisionPersistenceFailedAuditForTest(context.Background(), + closedPlatformDB(t), res, "prid-4", "req-4", "db.new") +} diff --git a/internal/handlers/helper_branches_provarms_test.go b/internal/handlers/helper_branches_provarms_test.go new file mode 100644 index 00000000..0bcbc99d --- /dev/null +++ b/internal/handlers/helper_branches_provarms_test.go @@ -0,0 +1,194 @@ +package handlers_test + +// helper_branches_provarms_test.go — pins the remaining error / edge branches +// of the shared provisioning helpers + bulk-twin internals that the HTTP-level +// suites don't reach: +// - requireName: empty / too-long / bad-format / control-char-normalisation +// - checkProvisionLimit + markRecycleSeen + recycleSeen: Redis-error + empty-fp +// - findParents: paused-skip / wrong-type-skip / already-a-twin-skip +// - resolveHeadroom: nil-hook default + negative clamp +// - NewBulkTwinHandler: nil sub-handler panic guard + +import ( + "context" + "database/sql" + "strings" + "testing" + + "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" + "github.com/valyala/fasthttp" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +func freshCtx(t *testing.T) (*fiber.Ctx, func()) { + t.Helper() + app := fiber.New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + return c, func() { app.ReleaseCtx(c) } +} + +func TestRequireName_Branches(t *testing.T) { + // empty → name_required + c, rel := freshCtx(t) + _, err := handlers.RequireNameForTest(c, "") + assert.Error(t, err) + assert.Equal(t, fiber.StatusBadRequest, c.Response().StatusCode()) + rel() + + // too long (>64 runes) → invalid_name + c, rel = freshCtx(t) + _, err = handlers.RequireNameForTest(c, strings.Repeat("a", 65)) + assert.Error(t, err) + assert.Equal(t, fiber.StatusBadRequest, c.Response().StatusCode()) + rel() + + // bad format (starts with a dash / illegal chars) → invalid_name + c, rel = freshCtx(t) + _, err = handlers.RequireNameForTest(c, "-bad@name") + assert.Error(t, err) + assert.Equal(t, fiber.StatusBadRequest, c.Response().StatusCode()) + rel() + + // control-char normalisation: stripped → valid name + X-Instant-Notice header + c, rel = freshCtx(t) + got, err := handlers.RequireNameForTest(c, "good\rname") + require.NoError(t, err) + assert.Equal(t, "goodname", got) + assert.NotEmpty(t, string(c.Response().Header.Peek("X-Instant-Notice")), + "normalisation must surface a notice header") + rel() + + // clean name → returned trimmed + c, rel = freshCtx(t) + got, err = handlers.RequireNameForTest(c, " My DB ") + require.NoError(t, err) + assert.Equal(t, "My DB", got) + rel() +} + +// closedRedis returns a redis client whose connection is closed so every +// command errors — drives the fail-open Redis-error branches. +func closedRedis(t *testing.T) *redis.Client { + t.Helper() + rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}) + require.NoError(t, rdb.Close()) + return rdb +} + +func TestCheckProvisionLimit_RedisError_FailsOpen(t *testing.T) { + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres"} + h := handlers.NewDBHandler(nil, closedRedis(t), cfg, nil, plans.Default()) + exceeded, err := h.CheckProvisionLimitForTest(context.Background(), "fp-x") + require.Error(t, err, "closed redis must surface an error") + assert.False(t, exceeded, "fail-open: never report over-cap on a redis error") +} + +func TestMarkRecycleSeen_EmptyFP_NoOp(t *testing.T) { + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres"} + h := handlers.NewDBHandler(nil, closedRedis(t), cfg, nil, plans.Default()) + // empty fp → early return nil (never touches redis). + assert.NoError(t, h.MarkRecycleSeenForTest(context.Background(), "")) + // non-empty fp on a closed redis → error surfaced. + assert.Error(t, h.MarkRecycleSeenForTest(context.Background(), "fp-x")) +} + +func TestRecycleSeen_EmptyAndError(t *testing.T) { + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres"} + h := handlers.NewDBHandler(nil, closedRedis(t), cfg, nil, plans.Default()) + seen, err := h.RecycleSeenForTest(context.Background(), "") + assert.NoError(t, err) + assert.False(t, seen) + _, err = h.RecycleSeenForTest(context.Background(), "fp-x") + assert.Error(t, err) +} + +func TestNewBulkTwinHandler_NilHandlers_Panics(t *testing.T) { + assert.Panics(t, func() { + handlers.NewBulkTwinHandlerPanicsForTest(nil, plans.Default()) + }) +} + +func TestResolveHeadroom_DefaultAndClamp(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres,redis,mongodb"} + reg := plans.Default() + dbH := handlers.NewDBHandler(db, rdb, cfg, nil, reg) + cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, reg) + nosqlH := handlers.NewNoSQLHandler(db, rdb, cfg, nil, reg) + bt := handlers.NewBulkTwinHandler(db, dbH, cacheH, nosqlH, reg) + + tid := uuid.New() + // nil hook → default large headroom. + assert.Positive(t, bt.ResolveHeadroomForTest(context.Background(), tid, "postgres")) + + // negative hook → clamped to 0. + bt.QuotaHeadroom = func(_ context.Context, _ uuid.UUID, _ string) int { return -5 } + assert.Equal(t, 0, bt.ResolveHeadroomForTest(context.Background(), tid, "postgres")) + + // positive hook → returned as-is. + bt.QuotaHeadroom = func(_ context.Context, _ uuid.UUID, _ string) int { return 3 } + assert.Equal(t, 3, bt.ResolveHeadroomForTest(context.Background(), tid, "postgres")) +} + +func TestFindParents_SkipFilters(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + cfg := &config.Config{AESKey: testhelpers.TestAESKeyHex, EnabledServices: "postgres,redis,mongodb"} + reg := plans.Default() + dbH := handlers.NewDBHandler(db, rdb, cfg, nil, reg) + cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, reg) + nosqlH := handlers.NewNoSQLHandler(db, rdb, cfg, nil, reg) + bt := handlers.NewBulkTwinHandler(db, dbH, cacheH, nosqlH, reg) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + tUUID := uuid.MustParse(teamID) + + // (a) a healthy postgres root in production → eligible + rootID := insertRes(t, db, teamID, "postgres", "production", "active", nil) + // (b) a paused postgres root → skipped by IsActive filter + insertRes(t, db, teamID, "postgres", "production", "paused", nil) + // (c) a redis root → skipped when filter is postgres-only + insertRes(t, db, teamID, "redis", "production", "active", nil) + // (d) an already-a-twin postgres child (parent set) → skipped (not a root) + insertRes(t, db, teamID, "postgres", "production", "active", &rootID) + + filter := map[string]struct{}{"postgres": {}} + parents, err := bt.FindParentsForTest(context.Background(), tUUID, "production", filter) + require.NoError(t, err) + // Only the single healthy postgres ROOT (a) is eligible. + require.Len(t, parents, 1) + assert.Equal(t, "postgres", parents[0].ResourceType) + _ = models.ResourceTypePostgres +} + +// insertRes inserts a resource and returns its id (string). +func insertRes(t *testing.T, db *sql.DB, teamID, rtype, env, status string, parentRootID *string) string { + t.Helper() + var id string + if parentRootID == nil { + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env, status) + VALUES ($1::uuid, $2, 'pro', $3, $4) RETURNING id::text + `, teamID, rtype, env, status).Scan(&id)) + } else { + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env, status, parent_resource_id) + VALUES ($1::uuid, $2, 'pro', $3, $4, $5::uuid) RETURNING id::text + `, teamID, rtype, env, status, *parentRootID).Scan(&id)) + } + return id +} \ No newline at end of file diff --git a/internal/handlers/provision_helper_provarms_test.go b/internal/handlers/provision_helper_provarms_test.go new file mode 100644 index 00000000..e391f645 --- /dev/null +++ b/internal/handlers/provision_helper_provarms_test.go @@ -0,0 +1,71 @@ +package handlers_test + +// provision_helper_provarms_test.go — pin the remaining uncovered branches of +// the small pure helpers in provision_helper.go / family_bulk_twin.go that the +// HTTP-level suites don't exercise to 100%. + +import ( + "database/sql" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/valyala/fasthttp" + + "instant.dev/internal/handlers" +) + +func TestFormatDuration_AllBranches(t *testing.T) { + cases := []struct { + d time.Duration + want string + }{ + {-time.Second, "less than a minute"}, + {0, "less than a minute"}, + {30 * time.Second, "0m"}, // < 1m rounds to 0m (m branch) + {45 * time.Minute, "45m"}, // minutes only + {2 * time.Hour, "2h"}, // hours only, no minutes + {90 * time.Minute, "1h 30m"}, // hours + minutes + {25 * time.Hour, "25h"}, // > 24h, no remainder + {26*time.Hour + 5*time.Minute, "26h 5m"}, + } + for _, tc := range cases { + assert.Equalf(t, tc.want, handlers.FormatDurationForTest(tc.d), "formatDuration(%v)", tc.d) + } +} + +func TestNullStrOrEmpty(t *testing.T) { + assert.Equal(t, "", handlers.NullStrOrEmptyForTest(sql.NullString{Valid: false})) + assert.Equal(t, "", handlers.NullStrOrEmptyForTest(sql.NullString{String: "ignored", Valid: false})) + assert.Equal(t, "hello", handlers.NullStrOrEmptyForTest(sql.NullString{String: "hello", Valid: true})) +} + +// newTestCtx returns a throwaway *fiber.Ctx + a release func for unit-testing +// helpers that take a context but don't depend on request state. +func newTestCtx(t *testing.T) (*fiber.Ctx, func()) { + t.Helper() + app := fiber.New() + fctx := &fasthttp.RequestCtx{} + c := app.AcquireCtx(fctx) + return c, func() { app.ReleaseCtx(c) } +} + +func TestSanitizeNameForRequest_CleanName(t *testing.T) { + c, release := newTestCtx(t) + defer release() + got, err := handlers.SanitizeNameForRequestForTest(c, "My App DB") + assert.NoError(t, err) + assert.Equal(t, "My App DB", got) +} + +func TestSanitizeNameForRequest_InvalidUTF8_WritesError(t *testing.T) { + c, release := newTestCtx(t) + defer release() + // Invalid UTF-8 byte sequence → sanitizeName returns errInvalidUTF8Name → + // sanitizeNameForRequest writes a 400 invalid_name response + returns + // ErrResponseWritten. + _, err := handlers.SanitizeNameForRequestForTest(c, "bad\xff\xfename") + assert.Error(t, err) + assert.Equal(t, fiber.StatusBadRequest, c.Response().StatusCode()) +} diff --git a/internal/handlers/queue_provider_provarms_test.go b/internal/handlers/queue_provider_provarms_test.go new file mode 100644 index 00000000..ce4def66 --- /dev/null +++ b/internal/handlers/queue_provider_provarms_test.go @@ -0,0 +1,107 @@ +package handlers_test + +// queue_provider_provarms_test.go — drives buildQueueProvider's backend- +// selection branches without standing up a real NATS server. +// +// buildQueueProvider is the boot-time wiring helper called from +// NewQueueHandler. The test app constructs the handler (so the legacy_open +// fallback runs at construction), but the explicit-backend, nats-when-seed, +// and Factory-error branches are never reached by the HTTP-level tests. These +// direct calls cover each arm. + +import ( + "testing" + + "github.com/nats-io/nkeys" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" +) + +// TestBuildQueueProvider_DefaultNoSeed_FallsBackToLegacyOpen — empty +// QueueBackend AND empty operator seed → the legacy_open shim, which enforces +// nothing (all-false capabilities). +func TestBuildQueueProvider_DefaultNoSeed_FallsBackToLegacyOpen(t *testing.T) { + cfg := &config.Config{ + QueueBackend: "", + NATSOperatorSeed: "", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + } + qp, err := handlers.BuildQueueProviderForTest(cfg) + require.NoError(t, err) + require.NotNil(t, qp) + assert.Equal(t, "legacy_open", qp.Name()) + caps := qp.Capabilities() + assert.False(t, caps.PerTenantAccounts, "legacy_open enforces nothing") + assert.False(t, caps.SubjectScopedAuth) +} + +// TestBuildQueueProvider_DefaultWithSeed_SelectsNATS — empty QueueBackend but +// a valid operator seed present → the "nats" backend is selected and builds. +func TestBuildQueueProvider_DefaultWithSeed_SelectsNATS(t *testing.T) { + kp, err := nkeys.CreateOperator() + require.NoError(t, err) + seed, err := kp.Seed() + require.NoError(t, err) + + cfg := &config.Config{ + QueueBackend: "", + NATSOperatorSeed: string(seed), + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + NATSSystemAccountKey: "", + } + qp, err := handlers.BuildQueueProviderForTest(cfg) + require.NoError(t, err) + require.NotNil(t, qp) + assert.Equal(t, "nats", qp.Name()) +} + +// TestBuildQueueProvider_ExplicitLegacyOpen — explicit backend overrides the +// seed-based default selection. +func TestBuildQueueProvider_ExplicitLegacyOpen(t *testing.T) { + kp, err := nkeys.CreateOperator() + require.NoError(t, err) + seed, _ := kp.Seed() + + cfg := &config.Config{ + QueueBackend: "legacy_open", + NATSOperatorSeed: string(seed), // present but ignored — explicit backend wins + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + } + qp, err := handlers.BuildQueueProviderForTest(cfg) + require.NoError(t, err) + assert.Equal(t, "legacy_open", qp.Name()) +} + +// TestBuildQueueProvider_UnknownBackend_Errors — an unrecognised QueueBackend +// surfaces the Factory's ErrUnknownBackend so NewQueueHandler can fall back to +// the legacy_open shim defensively. +func TestBuildQueueProvider_UnknownBackend_Errors(t *testing.T) { + cfg := &config.Config{ + QueueBackend: "bogus-not-a-backend", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + } + qp, err := handlers.BuildQueueProviderForTest(cfg) + require.Error(t, err) + assert.Nil(t, qp) +} + +// TestBuildQueueProvider_BadSeed_Errors — backend=nats with an unparseable +// operator seed surfaces the auth-failure error from the nats constructor. +func TestBuildQueueProvider_BadSeed_Errors(t *testing.T) { + cfg := &config.Config{ + QueueBackend: "nats", + NATSOperatorSeed: "not-a-valid-nkey-seed", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + } + qp, err := handlers.BuildQueueProviderForTest(cfg) + require.Error(t, err) + assert.Nil(t, qp) +} diff --git a/internal/handlers/queue_storage_helpers_provarms_test.go b/internal/handlers/queue_storage_helpers_provarms_test.go new file mode 100644 index 00000000..886f2960 --- /dev/null +++ b/internal/handlers/queue_storage_helpers_provarms_test.go @@ -0,0 +1,72 @@ +package handlers_test + +// queue_storage_helpers_provarms_test.go — covers the remaining error/no-op +// branches of QueueHandler.issueTenantCreds and deprovisionBestEffort that the +// HTTP success paths don't reach. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/plans" +) + +// issueTenantCreds error branch: the legacy_open provider returns an error when +// ResourceToken is empty → issueTenantCreds logs, increments NatsAuthFailures, +// and returns (nil, err). +func TestIssueTenantCreds_ProviderError_ReturnsErr(t *testing.T) { + cfg := &config.Config{ + QueueBackend: "legacy_open", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + h := handlers.NewQueueHandler(nil, nil, cfg, nil, plans.Default()) + // Empty token → legacy_open provider errors → issueTenantCreds error branch. + creds, err := h.IssueTenantCredsForTest(context.Background(), "", "subj") + require.Error(t, err) + assert.Nil(t, creds) +} + +// issueTenantCreds success-ish branch: a valid token yields legacy_open creds +// (AuthMode=legacy_open) with no error. +func TestIssueTenantCreds_LegacyOpen_Succeeds(t *testing.T) { + cfg := &config.Config{ + QueueBackend: "legacy_open", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + h := handlers.NewQueueHandler(nil, nil, cfg, nil, plans.Default()) + creds, err := h.IssueTenantCredsForTest(context.Background(), "tok-123", "subj") + require.NoError(t, err) + require.NotNil(t, creds) +} + +// deprovisionBestEffort: nil provClient is a no-op (local-provider mode). +func TestDeprovisionBestEffort_NilClient_NoOp(t *testing.T) { + // Must not panic; returns immediately. + handlers.DeprovisionBestEffortForTest(context.Background(), nil, "tok", "prid", "postgres", "test.np") +} + +// deprovisionBestEffort: unknown resource type → resourceTypeToProto returns +// UNSPECIFIED → early return before the gRPC call (no Deprovision counted). +func TestDeprovisionBestEffort_UnknownType_NoCall(t *testing.T) { + fake := &fakeProvisioner{} + pc := newBufconnProvisionerClient(t, fake) + handlers.DeprovisionBestEffortForTest(context.Background(), pc, "tok", "prid", "not-a-real-type", "test.np") + assert.Equal(t, 0, fake.deprovisionCount(), "unknown type must not reach the gRPC Deprovision call") +} + +// deprovisionBestEffort: known type with a working client → one Deprovision. +func TestDeprovisionBestEffort_KnownType_CallsDeprovision(t *testing.T) { + fake := &fakeProvisioner{} + pc := newBufconnProvisionerClient(t, fake) + handlers.DeprovisionBestEffortForTest(context.Background(), pc, "tok", "prid", "postgres", "test.np") + assert.GreaterOrEqual(t, fake.deprovisionCount(), 1) +} diff --git a/internal/handlers/redis_fault_provarms_test.go b/internal/handlers/redis_fault_provarms_test.go new file mode 100644 index 00000000..e626c225 --- /dev/null +++ b/internal/handlers/redis_fault_provarms_test.go @@ -0,0 +1,248 @@ +package handlers_test + +// redis_fault_provarms_test.go — covers the fail-open Redis-error LOG branches +// on the anonymous provisioning success path that the happy-path fixtures skip: +// - checkProvisionLimit error → logged, fail-open, provision continues +// - markRecycleSeen error → logged after a successful provision +// plus the recycle-gate fired branch (402 free_tier_recycle_requires_claim). +// +// THE TECHNIQUE: give the HANDLER a CLOSED redis client while the rate-limit / +// auth MIDDLEWARE keeps a LIVE one. The handler's checkProvisionLimit + +// markRecycleSeen then error (fail-open) without breaking the middleware chain; +// the DB is live so the anonymous provision still succeeds with a 201. + +import ( + "context" + "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" +) + +// Anonymous provisions where the HANDLER's redis is closed (middleware redis +// live): checkProvisionLimit + markRecycleSeen both error and fail open, the DB +// provision still succeeds → 201. Covers the redis-error log branches in every +// anonymous handler arm. +func TestAnonProvision_HandlerRedisError_FailsOpenAndProvisions(t *testing.T) { + liveDB, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { liveDB.Close() }) + liveRedis, _ := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { liveRedis.Close() }) + + // A closed redis handle for the handler — every command errors. + closedRdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}) + require.NoError(t, closedRdb.Close()) + + cfg := &config.Config{ + Port: "8080", + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + EnabledServices: "postgres,redis,mongodb,queue,storage", + Environment: "test", + PostgresProvisionBackend: "local", + ObjectStoreBucket: "instant-shared", + ObjectStoreEndpoint: "nyc3.test.local", + ObjectStoreAccessKey: "MK", + ObjectStoreSecretKey: "MS", + } + planReg := plans.Default() + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.SendStatus(fiber.StatusInternalServerError) + }, + ProxyHeader: "X-Forwarded-For", + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + // Rate-limit middleware uses the LIVE redis so the chain works. + app.Use(middleware.RateLimit(liveRedis, middleware.RateLimitConfig{Limit: 100, KeyPrefix: "rlhre"})) + + // Handlers get the CLOSED redis. + app.Post("/db/new", middleware.OptionalAuth(cfg), handlers.NewDBHandler(liveDB, closedRdb, cfg, nil, planReg).NewDB) + app.Post("/cache/new", middleware.OptionalAuth(cfg), handlers.NewCacheHandler(liveDB, closedRdb, cfg, nil, planReg).NewCache) + app.Post("/nosql/new", middleware.OptionalAuth(cfg), handlers.NewNoSQLHandler(liveDB, closedRdb, cfg, nil, planReg).NewNoSQL) + app.Post("/queue/new", middleware.OptionalAuth(cfg), handlers.NewQueueHandler(liveDB, closedRdb, cfg, nil, planReg).NewQueue) + app.Post("/storage/new", middleware.OptionalAuth(cfg), handlers.NewStorageHandler(liveDB, closedRdb, cfg, newDOSpacesProvider(t), planReg).NewStorage) + + type provResp struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + for i, path := range []string{"/db/new", "/cache/new", "/nosql/new", "/queue/new", "/storage/new"} { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"x"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.220."+string(rune('0'+i))+".1") + resp, err := app.Test(req, 10000) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var pr provResp + _ = json.Unmarshal(raw, &pr) + // db/cache/nosql may 503 if their LOCAL backend isn't reachable; the + // redis-error log branches run BEFORE provisioning either way. Accept + // 201 (provisioned) or 503 (local backend down) — never a 5xx from the + // closed handler-redis (it must fail open). + assert.Containsf(t, []int{http.StatusCreated, http.StatusServiceUnavailable}, resp.StatusCode, + "%s must fail open on handler-redis error (got %d, body=%s)", path, resp.StatusCode, raw) + } +} + +// Anonymous SUCCESS path with a closed handler-redis, backed by the bufconn +// gRPC provisioner so provisioning succeeds regardless of local backends. This +// reaches the markRecycleSeen-error LOG branch (line ~265 in each handler) that +// only runs AFTER a successful provision — unreachable for db/cache/nosql/queue +// via the local-provider path on a machine without those backends. +func TestAnonProvision_GRPCSuccess_HandlerRedisError_LogsRecycleMarkFailure(t *testing.T) { + liveDB, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { liveDB.Close() }) + liveRedis, _ := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { liveRedis.Close() }) + + closedRdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}) + require.NoError(t, closedRdb.Close()) + + cfg := &config.Config{ + Port: "8080", + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + EnabledServices: "postgres,redis,mongodb,queue", + Environment: "test", + PostgresProvisionBackend: "local", + QueueBackend: "legacy_open", + NATSHost: "nats.test", + NATSPublicHost: "nats.instanode.dev", + } + planReg := plans.Default() + provClient := newBufconnProvisionerClient(t, &fakeProvisioner{}) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.SendStatus(fiber.StatusInternalServerError) + }, + ProxyHeader: "X-Forwarded-For", + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Use(middleware.RateLimit(liveRedis, middleware.RateLimitConfig{Limit: 100, KeyPrefix: "rlgre"})) + + app.Post("/db/new", middleware.OptionalAuth(cfg), handlers.NewDBHandler(liveDB, closedRdb, cfg, provClient, planReg).NewDB) + app.Post("/cache/new", middleware.OptionalAuth(cfg), handlers.NewCacheHandler(liveDB, closedRdb, cfg, provClient, planReg).NewCache) + app.Post("/nosql/new", middleware.OptionalAuth(cfg), handlers.NewNoSQLHandler(liveDB, closedRdb, cfg, provClient, planReg).NewNoSQL) + app.Post("/queue/new", middleware.OptionalAuth(cfg), handlers.NewQueueHandler(liveDB, closedRdb, cfg, provClient, planReg).NewQueue) + + for i, path := range []string{"/db/new", "/cache/new", "/nosql/new", "/queue/new"} { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"x"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.221."+string(rune('0'+i))+".1") + resp, err := app.Test(req, 10000) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + // gRPC provisioning succeeds → 201 even though the handler-redis is + // closed (checkProvisionLimit + markRecycleSeen fail open + log). + assert.Equalf(t, http.StatusCreated, resp.StatusCode, + "%s should 201 (gRPC provisions; handler-redis errors fail open). body=%s", path, raw) + } +} + +// recycleGate fired: a recycle_seen: marker + zero active rows for the +// fingerprint → 402 free_tier_recycle_requires_claim. +func TestAnonProvision_RecycleGate_Returns402(t *testing.T) { + liveDB, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { liveDB.Close() }) + rdb, _ := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { rdb.Close() }) + + cfg := &config.Config{ + Port: "8080", + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + EnabledServices: "postgres", + Environment: "test", + PostgresProvisionBackend: "local", + } + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.SendStatus(fiber.StatusInternalServerError) + }, + ProxyHeader: "X-Forwarded-For", + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Use(middleware.RateLimit(rdb, middleware.RateLimitConfig{Limit: 100, KeyPrefix: "rlrg"})) + dbH := handlers.NewDBHandler(liveDB, rdb, cfg, nil, plans.Default()) + app.Post("/db/new", middleware.OptionalAuth(cfg), dbH.NewDB) + + // Learn the fingerprint for our IP via the helper, then plant the recycle + // marker + ensure zero active rows so the gate fires on the next POST. + ip := "192.0.2.123" + // Compute fingerprint by issuing one request and reading the row, then + // soft-delete it so no active row remains. + req0 := httptest.NewRequest(http.MethodPost, "/db/new", strings.NewReader(`{"name":"probe"}`)) + req0.Header.Set("Content-Type", "application/json") + req0.Header.Set("X-Forwarded-For", ip) + resp0, err := app.Test(req0, 10000) + require.NoError(t, err) + raw0, _ := io.ReadAll(resp0.Body) + resp0.Body.Close() + if resp0.StatusCode == http.StatusServiceUnavailable { + t.Skip("local postgres-customers backend not reachable — skipping recycle-gate probe") + } + require.Equalf(t, http.StatusCreated, resp0.StatusCode, "probe provision (body=%s)", raw0) + var probe struct { + Token string `json:"token"` + } + require.NoError(t, json.Unmarshal(raw0, &probe)) + var fp string + require.NoError(t, liveDB.QueryRowContext(context.Background(), + `SELECT fingerprint FROM resources WHERE token = $1::uuid`, probe.Token).Scan(&fp)) + + // Soft-delete every active row for this fingerprint so the gate's + // "zero active rows" condition holds, then plant the recycle marker. + _, err = liveDB.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()) + + // Next provision from the same IP → recycle gate fires (402). + req := httptest.NewRequest(http.MethodPost, "/db/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) + resp.Body.Close() + var env struct { + Error string `json:"error"` + } + _ = json.Unmarshal(raw, &env) + require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode, "recycle gate should 402 (body=%s)", raw) + assert.Equal(t, "free_tier_recycle_requires_claim", env.Error) +} diff --git a/internal/handlers/resolve_family_parent_provarms_test.go b/internal/handlers/resolve_family_parent_provarms_test.go new file mode 100644 index 00000000..9feb6cdd --- /dev/null +++ b/internal/handlers/resolve_family_parent_provarms_test.go @@ -0,0 +1,133 @@ +package handlers_test + +// resolve_family_parent_provarms_test.go — drives every branch of +// resolveFamilyParent (provision_helper.go) through the authenticated +// /db/new path using the bufconn gRPC fixture (so the provision actually +// succeeds on the success branch). Covers: +// - invalid UUID → 400 invalid_parent_resource_id +// - cross-team parent → 403 forbidden_parent_resource +// - cross-type parent → 400 type_mismatch +// - duplicate twin in env → 409 twin_exists +// - deleted / missing parent → 404 parent_not_found +// - valid parent → 201 (family link applied) + +import ( + "context" + "database/sql" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +// seedResourceFull inserts a resource with explicit env + parent so the +// duplicate-twin path (an existing child in target env) can be set up. +func seedResourceFull(t *testing.T, db *sql.DB, teamID, resourceType, tier, env string, parentRootID *string) (id, token string) { + t.Helper() + if parentRootID == nil { + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env, status) + VALUES ($1::uuid, $2, $3, $4, 'active') + RETURNING id::text, token::text + `, teamID, resourceType, tier, env).Scan(&id, &token)) + } else { + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env, status, parent_resource_id) + VALUES ($1::uuid, $2, $3, $4, 'active', $5::uuid) + RETURNING id::text, token::text + `, teamID, resourceType, tier, env, *parentRootID).Scan(&id, &token)) + } + return id, token +} + +func TestResolveFamilyParent_InvalidUUID_Returns400(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + + resp, body := doProvision(t, fx, "/db/new", "10.130.0.1", jwt, + map[string]any{"name": "fp-baduuid", "parent_resource_id": "not-a-uuid"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_parent_resource_id", body.Error) +} + +func TestResolveFamilyParent_CrossTeam_Returns403(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + myTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + otherTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, myTeam) + // Parent belongs to a DIFFERENT team. + parentID, _ := seedResourceFull(t, fx.db, otherTeam, "postgres", "pro", "production", nil) + + resp, body := doProvision(t, fx, "/db/new", "10.131.0.1", jwt, + map[string]any{"name": "fp-crossteam", "env": "staging", "parent_resource_id": parentID}) + defer resp.Body.Close() + require.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, "forbidden_parent_resource", body.Error) +} + +func TestResolveFamilyParent_CrossType_Returns400(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + // Parent is a redis resource; we POST /db/new (postgres) → cross_type. + parentID, _ := seedResourceFull(t, fx.db, teamID, "redis", "pro", "production", nil) + + resp, body := doProvision(t, fx, "/db/new", "10.132.0.1", jwt, + map[string]any{"name": "fp-crosstype", "env": "staging", "parent_resource_id": parentID}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "type_mismatch", body.Error) +} + +func TestResolveFamilyParent_DuplicateTwin_Returns409(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + // Root parent in production; an existing twin already in staging. + parentID, _ := seedResourceFull(t, fx.db, teamID, "postgres", "pro", "production", nil) + _, _ = seedResourceFull(t, fx.db, teamID, "postgres", "pro", "staging", &parentID) + + resp, body := doProvision(t, fx, "/db/new", "10.133.0.1", jwt, + map[string]any{"name": "fp-dup", "env": "staging", "parent_resource_id": parentID}) + defer resp.Body.Close() + require.Equal(t, http.StatusConflict, resp.StatusCode) + assert.Equal(t, "twin_exists", body.Error) +} + +func TestResolveFamilyParent_MissingParent_Returns404(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + + resp, body := doProvision(t, fx, "/db/new", "10.134.0.1", jwt, + map[string]any{"name": "fp-missing", "env": "staging", "parent_resource_id": uuid.NewString()}) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "parent_not_found", body.Error) +} + +func TestResolveFamilyParent_ValidParent_Returns201(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + parentID, _ := seedResourceFull(t, fx.db, teamID, "postgres", "pro", "production", nil) + + resp, body := doProvision(t, fx, "/db/new", "10.135.0.1", jwt, + map[string]any{"name": "fp-valid", "env": "staging", "parent_resource_id": parentID}) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + assert.True(t, body.OK) + assert.Equal(t, "staging", body.Env) +} diff --git a/internal/handlers/storage_internals_provarms_test.go b/internal/handlers/storage_internals_provarms_test.go new file mode 100644 index 00000000..cdd5cb35 --- /dev/null +++ b/internal/handlers/storage_internals_provarms_test.go @@ -0,0 +1,80 @@ +package handlers_test + +// storage_internals_provarms_test.go — covers StorageHandler.decideStorageMode +// (on the REAL handler, not the stub mirror) and signStorageURL's missing- +// config error branches. + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/plans" +) + +// decideStorageMode: nil provider → "unavailable". +func TestDecideStorageMode_NilProvider_Unavailable(t *testing.T) { + cfg := &config.Config{EnabledServices: "storage", AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"} + h := handlers.NewStorageHandler(nil, nil, cfg, nil, plans.Default()) // no MinioEndpoint → provider nil + kind, _ := h.DecideStorageModeKindForTest("anonymous") + assert.Equal(t, "unavailable", kind) +} + +// decideStorageMode: do-spaces (PrefixScopedKeys=false) → broker. +func TestDecideStorageMode_DOSpaces_Broker(t *testing.T) { + cfg := storageProvConfig(false) + h := handlers.NewStorageHandler(nil, nil, cfg, newDOSpacesProvider(t), plans.Default()) + kind, reason := h.DecideStorageModeKindForTest("pro") + assert.Equal(t, "broker", kind) + assert.Equal(t, "backend-has-no-prefix-scoping", reason) +} + +// decideStorageMode: s3 (PrefixScopedKeys=true) → credential. +func TestDecideStorageMode_S3_Credential(t *testing.T) { + cfg := storageProvConfig(false) + h := handlers.NewStorageHandler(nil, nil, cfg, newS3PrefixScopedProvider(t), plans.Default()) + kind, _ := h.DecideStorageModeKindForTest("anonymous") + assert.Equal(t, "credential", kind) +} + +// signStorageURL: missing bucket/endpoint → error. +func TestSignStorageURL_MissingBucketEndpoint_Errors(t *testing.T) { + cfg := &config.Config{EnabledServices: "storage", AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"} + // No ObjectStoreBucket / ObjectStoreEndpoint set. + h := handlers.NewStorageHandler(nil, nil, cfg, newDOSpacesProvider(t), plans.Default()) + _, _, err := h.SignStorageURLForTest(context.Background(), "GET", "prefix/key", time.Minute) + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +// signStorageURL: bucket+endpoint present but no master key → error. +func TestSignStorageURL_MissingMasterKey_Errors(t *testing.T) { + cfg := &config.Config{ + EnabledServices: "storage", + AESKey: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + ObjectStoreBucket: "instant-shared", + ObjectStoreEndpoint: "nyc3.test.local", + // ObjectStoreAccessKey / SecretKey intentionally empty. + } + h := handlers.NewStorageHandler(nil, nil, cfg, newDOSpacesProvider(t), plans.Default()) + _, _, err := h.SignStorageURLForTest(context.Background(), "GET", "prefix/key", time.Minute) + require.Error(t, err) + assert.Contains(t, err.Error(), "master") +} + +// signStorageURL: fully configured → signs a GET / PUT / HEAD URL offline. +func TestSignStorageURL_Success_AllOps(t *testing.T) { + cfg := storageProvConfig(false) + h := handlers.NewStorageHandler(nil, nil, cfg, newDOSpacesProvider(t), plans.Default()) + for _, op := range []string{"GET", "PUT", "HEAD"} { + url, exp, err := h.SignStorageURLForTest(context.Background(), op, "prefix/obj", time.Minute) + require.NoErrorf(t, err, "op=%s", op) + assert.NotEmptyf(t, url, "op=%s url", op) + assert.WithinDuration(t, time.Now().Add(time.Minute), exp, 5*time.Second) + } +} diff --git a/internal/handlers/storage_presign_provarms_test.go b/internal/handlers/storage_presign_provarms_test.go new file mode 100644 index 00000000..162c4070 --- /dev/null +++ b/internal/handlers/storage_presign_provarms_test.go @@ -0,0 +1,288 @@ +package handlers_test + +// storage_presign_provarms_test.go — HTTP-level coverage for POST +// /storage/:token/presign success + every error branch, plus signStorageURL +// and the audit/mask helpers. Reuses the offline storageProvFixture (do-spaces +// backend) from storage_provarms_test.go so the handler's storage provider is +// non-nil and signStorageURL (minio-go local HMAC presign — no network) runs. + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// seedStorageResource inserts an active storage resource for a team (or +// anonymous when teamID == "") with the given provider_resource_id (the +// object prefix). Returns the token. +func seedStorageResource(t *testing.T, db *sql.DB, teamID, prid string) string { + t.Helper() + var token string + if teamID == "" { + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (resource_type, tier, env, status, provider_resource_id) + VALUES ('storage', 'anonymous', 'development', 'active', $1) + RETURNING token::text + `, prid).Scan(&token)) + } else { + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env, status, provider_resource_id) + VALUES ($1::uuid, 'storage', 'pro', 'development', 'active', $2) + RETURNING token::text + `, teamID, prid).Scan(&token)) + } + return token +} + +// seedResourceWithType inserts an active resource of an arbitrary type/status. +func seedResourceWithType(t *testing.T, db *sql.DB, resourceType, status string) string { + t.Helper() + var token string + require.NoError(t, db.QueryRowContext(context.Background(), ` + INSERT INTO resources (resource_type, tier, env, status) + VALUES ($1, 'anonymous', 'development', $2) + RETURNING token::text + `, resourceType, status).Scan(&token)) + return token +} + +type presignResp struct { + OK bool `json:"ok"` + URL string `json:"url"` + Method string `json:"method"` + Key string `json:"key"` + ObjectKey string `json:"object_key"` + ExpiresAt string `json:"expires_at"` + Error string `json:"error"` +} + +func doPresign(t *testing.T, fx storageProvFixture, token, jwt string, body map[string]any) (*http.Response, presignResp) { + t.Helper() + var reader io.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = strings.NewReader(string(b)) + } + req := httptest.NewRequest(http.MethodPost, "/storage/"+token+"/presign", reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Forwarded-For", "10.120.0.1") + req.Header.Set("Idempotency-Key", uuid.NewString()) + if jwt != "" { + req.Header.Set("Authorization", "Bearer "+jwt) + } + resp, err := fx.app.Test(req, 15000) + require.NoError(t, err) + var parsed presignResp + raw, _ := io.ReadAll(resp.Body) + _ = json.Unmarshal(raw, &parsed) + return resp, parsed +} + +// ── success: GET / PUT / HEAD all sign offline via minio-go local HMAC ───── + +func TestPresign_Success_GET(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "anonprefix") + + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": "reports/2026.csv"}) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, body.OK) + assert.Equal(t, "GET", body.Method) + assert.NotEmpty(t, body.URL) + assert.Contains(t, body.ObjectKey, "anonprefix/reports/2026.csv") + assert.NotEmpty(t, body.ExpiresAt) +} + +func TestPresign_Success_PUT(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "p2") + + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "put", "key": "upload.bin", "expires_in": 120}) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "PUT", body.Method) + assert.NotEmpty(t, body.URL) +} + +func TestPresign_Success_HEAD(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "p3") + + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "HEAD", "key": "obj"}) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "HEAD", body.Method) +} + +// ── TTL cap: requesting > 1h is silently capped to 3600s ─────────────────── + +func TestPresign_TTLCapped(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "p4") + + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": "x", "expires_in": 99999}) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + exp, err := time.Parse(time.RFC3339, body.ExpiresAt) + require.NoError(t, err) + assert.LessOrEqual(t, time.Until(exp), time.Hour+2*time.Minute, "TTL must be capped at ~1h") +} + +// ── legacy row (empty provider_resource_id) falls back to token prefix ───── + +func TestPresign_LegacyRow_FallsBackToTokenPrefix(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "") // empty PRID + + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, body.ObjectKey, token+"/x", "empty PRID → token-derived prefix") +} + +// ── error branches ───────────────────────────────────────────────────────── + +func TestPresign_InvalidTokenUUID_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + resp, body := doPresign(t, fx, "not-a-uuid", "", map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_token", body.Error) +} + +func TestPresign_UnparseableBody_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + req := httptest.NewRequest(http.MethodPost, "/storage/"+uuid.NewString()+"/presign", + strings.NewReader("{not json")) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.121.0.1") + resp, err := fx.app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + var body presignResp + raw, _ := io.ReadAll(resp.Body) + _ = json.Unmarshal(raw, &body) + assert.Equal(t, "invalid_body", body.Error) +} + +func TestPresign_UnknownToken_Returns404(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + resp, body := doPresign(t, fx, uuid.NewString(), "", map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "resource_not_found", body.Error) +} + +func TestPresign_NotAStorageResource_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedResourceWithType(t, fx.db, "postgres", "active") + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "not_a_storage_resource", body.Error) +} + +func TestPresign_InactiveResource_Returns410(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedResourceWithType(t, fx.db, "storage", "paused") + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusGone, resp.StatusCode) + assert.Equal(t, "resource_inactive", body.Error) +} + +func TestPresign_InvalidOperation_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "p5") + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "DELETE", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_operation", body.Error) +} + +func TestPresign_MissingKey_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "p6") + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": " "}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_key", body.Error) +} + +func TestPresign_PathTraversalKey_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "p7") + resp, body := doPresign(t, fx, token, "", map[string]any{"operation": "GET", "key": "../escape"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "path_unsafe", body.Error) +} + +// ── cross-team session is rejected (403) ─────────────────────────────────── + +func TestPresign_CrossTeamSession_Returns403(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + ownerTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + otherTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + token := seedStorageResource(t, fx.db, ownerTeam, "owned") + otherJWT := authSessionJWT(t, fx.db, otherTeam) + + resp, body := doPresign(t, fx, token, otherJWT, map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + require.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, "cross_team_session", body.Error) +} + +// ── same-team session presigns successfully ──────────────────────────────── + +func TestPresign_SameTeamSession_Success(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + team := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + token := seedStorageResource(t, fx.db, team, "ownedprefix") + jwt := authSessionJWT(t, fx.db, team) + + resp, body := doPresign(t, fx, token, jwt, map[string]any{"operation": "GET", "key": "data.json"}) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, body.OK) +} + +// ── presign helpers (unit) ───────────────────────────────────────────────── + +func TestPresignHelpers_MaskToken(t *testing.T) { + assert.Equal(t, "***", handlers.MaskPresignTokenForAuditForTest("short")) + long := "0123456789abcdef" + assert.Equal(t, "01234567...", handlers.MaskPresignTokenForAuditForTest(long)) +} + +func TestPresignHelpers_MaskKey(t *testing.T) { + assert.Equal(t, "short/key.txt", handlers.MaskPresignKeyForAuditForTest("short/key.txt")) + long := strings.Repeat("a", 40) + masked := handlers.MaskPresignKeyForAuditForTest(long) + assert.Equal(t, 35, len(masked), "32 chars + ellipsis") + assert.True(t, strings.HasSuffix(masked, "...")) +} + +func TestPresignHelpers_SanitiseAndSafe(t *testing.T) { + assert.True(t, handlers.IsSafePresignKeyForTest("a/b/c")) + assert.False(t, handlers.IsSafePresignKeyForTest("../x")) + assert.False(t, handlers.IsSafePresignKeyForTest("")) + assert.Equal(t, "a/b", handlers.SanitisePresignKeyForTest("/a/./b/..")) +} diff --git a/internal/handlers/storage_provarms_test.go b/internal/handlers/storage_provarms_test.go new file mode 100644 index 00000000..f0088a8f --- /dev/null +++ b/internal/handlers/storage_provarms_test.go @@ -0,0 +1,513 @@ +package handlers_test + +// storage_provarms_test.go — HTTP-level coverage for the POST /storage/new and +// POST /storage/:token/presign handler arms that the existing storage_test.go +// suite skips because it runs with a nil storage provider (503). +// +// THE TECHNIQUE — offline storage providers. +// - The "do-spaces" backend issues credentials WITHOUT contacting any +// server (it returns the master key directly) and has PrefixScopedKeys= +// false, so it drives the BROKER-mode response branch. +// - The "s3" backend has PrefixScopedKeys=true and an injectable +// SetAssumeRoleFunc seam, so a stub STS lets it issue prefix-scoped +// credentials offline — driving the CREDENTIAL-mode response branch. +// +// A real *storage.Provider is built around each impl and injected into a real +// *handlers.StorageHandler, wired into a Fiber app with the production +// middleware chain. This reaches NewStorage / newStorageAuthenticated / +// buildStorageResponse / PresignStorage / signStorageURL on real HTTP traffic. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "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/common/storageprovider" + s3prov "instant.dev/common/storageprovider/s3" + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/plans" + storageprov "instant.dev/internal/providers/storage" + "instant.dev/internal/testhelpers" +) + +// newDOSpacesProvider builds an offline do-spaces-backed storage provider +// (PrefixScopedKeys=false → broker mode). +func newDOSpacesProvider(t *testing.T) *storageprov.Provider { + t.Helper() + p, err := storageprov.NewFromConfig(storageprovider.Config{ + Backend: "do-spaces", + Endpoint: "nyc3.test.local", + PublicURL: "https://s3.test.local", + Bucket: "instant-shared", + MasterKey: "MASTERKEY", + MasterSecret: "MASTERSECRET", + Region: "nyc3", + UseTLS: true, + }) + require.NoError(t, err) + return p +} + +// newS3PrefixScopedProvider builds an offline s3-backed storage provider with +// a stub AssumeRole so IssueTenantCredentials returns prefix-scoped creds +// without touching AWS (PrefixScopedKeys=true → credential mode). +func newS3PrefixScopedProvider(t *testing.T) *storageprov.Provider { + t.Helper() + p, err := storageprov.NewFromConfig(storageprovider.Config{ + Backend: "s3", + Endpoint: "s3.us-east-1.amazonaws.com", + PublicURL: "https://s3.test.local", + Bucket: "instant-shared", + Region: "us-east-1", + MasterKey: "AKIAEXAMPLE", + MasterSecret: "SECRETEXAMPLE", + AWSRoleARN: "arn:aws:iam::123456789012:role/instant-storage", + }) + require.NoError(t, err) + impl, ok := p.Impl().(*s3prov.Provider) + require.True(t, ok, "expected *s3.Provider impl") + impl.SetAssumeRoleFunc(func(_ context.Context, in s3prov.AssumeRoleInput) (*s3prov.AssumeRoleOutput, error) { + return &s3prov.AssumeRoleOutput{ + AccessKeyID: "ASIASESSION", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + Expiration: time.Now().Add(time.Hour), + }, nil + }) + return p +} + +// storageProvFixture is a Fiber app whose storage handler is wired with an injected +// (offline) storage provider plus a queue handler (unused here but mirrors the +// production chain shape). It supports both anonymous and authenticated POSTs. +type storageProvFixture struct { + app *fiber.App + db *sql.DB + rdb *redis.Client + cfg *config.Config +} + +func storageProvConfig(badAES bool) *config.Config { + cfg := &config.Config{ + Port: "8080", + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + EnabledServices: "storage", + Environment: "test", + ObjectStoreBucket: "instant-shared", + ObjectStoreEndpoint: "nyc3.test.local", + ObjectStoreAccessKey: "MASTERKEY", + ObjectStoreSecretKey: "MASTERSECRET", + ObjectStoreRegion: "nyc3", + ObjectStoreSecure: true, + } + if badAES { + cfg.AESKey = "not-a-valid-aes-key" + } + return cfg +} + +func setupStorageProvFixture(t *testing.T, provider *storageprov.Provider, badAES bool) storageProvFixture { + t.Helper() + db, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { db.Close() }) + rdb, _ := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { rdb.Close() }) + + cfg := storageProvConfig(badAES) + planReg := plans.Default() + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + _ = handlers.WriteFiberError(c, code, "internal_error", err.Error()) + return nil + }, + ProxyHeader: "X-Forwarded-For", + BodyLimit: 50 * 1024 * 1024, + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Use(middleware.RateLimit(rdb, middleware.RateLimitConfig{Limit: 100, KeyPrefix: "rlstor"})) + + storageH := handlers.NewStorageHandler(db, rdb, cfg, provider, planReg) + app.Post("/storage/new", middleware.OptionalAuth(cfg), middleware.Idempotency(rdb, "storage.new"), storageH.NewStorage) + app.Post("/storage/:token/presign", + middleware.OptionalAuth(cfg), + middleware.PresignTokenRateLimit(rdb), + middleware.Idempotency(rdb, "storage.presign"), + storageH.PresignStorage, + ) + + return storageProvFixture{app: app, db: db, rdb: rdb, cfg: cfg} +} + +type storageResp struct { + OK bool `json:"ok"` + ID string `json:"id"` + Token string `json:"token"` + Name string `json:"name"` + ConnectionURL string `json:"connection_url"` + Mode string `json:"mode"` + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + SessionToken string `json:"session_token"` + PresignURL string `json:"presign_url"` + AgentAction string `json:"agent_action"` + Prefix string `json:"prefix"` + Tier string `json:"tier"` + Env string `json:"env"` + Limits map[string]any `json:"limits"` + Error string `json:"error"` + ExpiresAt string `json:"expires_at"` +} + +func postStorage(t *testing.T, fx storageProvFixture, ip, jwt, idemKey string, body map[string]any) (*http.Response, storageResp) { + t.Helper() + var reader io.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = strings.NewReader(string(b)) + } + req := httptest.NewRequest(http.MethodPost, "/storage/new", reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Forwarded-For", ip) + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + if jwt != "" { + req.Header.Set("Authorization", "Bearer "+jwt) + } + resp, err := fx.app.Test(req, 15000) + require.NoError(t, err) + var parsed storageResp + raw, _ := io.ReadAll(resp.Body) + _ = json.Unmarshal(raw, &parsed) + return resp, parsed +} + +// ── Broker mode (do-spaces) anonymous success ───────────────────────────── + +func TestStorage_Anonymous_BrokerMode_Success(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + + resp, body := postStorage(t, fx, "10.100.0.1", "", "", map[string]any{"name": "anon-broker"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + assert.True(t, body.OK) + assert.NotEmpty(t, body.Token) + assert.Equal(t, "broker", body.Mode, "do-spaces has PrefixScopedKeys=false → broker mode") + assert.Empty(t, body.AccessKeyID, "broker mode must NOT return a long-lived credential") + assert.NotEmpty(t, body.PresignURL) + assert.Equal(t, "anonymous", body.Tier) + assert.NotEmpty(t, body.ExpiresAt) +} + +// ── Credential mode (s3 + stub STS) anonymous success ───────────────────── + +func TestStorage_Anonymous_CredentialMode_Success(t *testing.T) { + fx := setupStorageProvFixture(t, newS3PrefixScopedProvider(t), false) + + resp, body := postStorage(t, fx, "10.101.0.1", "", "", map[string]any{"name": "anon-cred"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + assert.True(t, body.OK) + assert.Equal(t, "credential", "credential") // sanity + assert.NotEmpty(t, body.AccessKeyID, "prefix-scoped backend issues a long-lived/STS credential") + assert.NotEmpty(t, body.SecretAccessKey) + assert.NotEmpty(t, body.SessionToken, "STS path returns a session token") +} + +// ── Authenticated credential-mode success (tier echo + limits) ───────────── + +func TestStorage_Authenticated_CredentialMode_Success(t *testing.T) { + fx := setupStorageProvFixture(t, newS3PrefixScopedProvider(t), false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + + resp, body := postStorage(t, fx, "10.102.0.1", jwt, "", map[string]any{"name": "auth-cred"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "pro", body.Tier) + assert.NotEmpty(t, body.AccessKeyID) + require.NotNil(t, body.Limits) + assert.Contains(t, body.Limits, "storage_mb") +} + +// ── Authenticated broker-mode success ────────────────────────────────────── + +func TestStorage_Authenticated_BrokerMode_Success(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "hobby") + jwt := authSessionJWT(t, fx.db, teamID) + + resp, body := postStorage(t, fx, "10.103.0.1", jwt, "", map[string]any{"name": "auth-broker"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "hobby", body.Tier) + assert.Equal(t, "broker", body.Mode) + assert.Empty(t, body.AccessKeyID) +} + +// ── Service disabled / provider nil → 503 ────────────────────────────────── + +func TestStorage_ServiceDisabled_Returns503(t *testing.T) { + db, _ := testhelpers.SetupTestDB(t) + t.Cleanup(func() { db.Close() }) + rdb, _ := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { rdb.Close() }) + + cfg := storageProvConfig(false) + cfg.EnabledServices = "redis" // storage NOT enabled + app := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", + ErrorHandler: func(c *fiber.Ctx, err error) error { + // The 503 service_disabled response is written via respondError, + // which returns ErrResponseWritten — swallow it so Fiber's default + // 500 doesn't overwrite the already-committed body. Mirrors prod. + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.SendStatus(fiber.StatusInternalServerError) + }, + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + storageH := handlers.NewStorageHandler(db, rdb, cfg, newDOSpacesProvider(t), plans.Default()) + app.Post("/storage/new", middleware.OptionalAuth(cfg), storageH.NewStorage) + + req := httptest.NewRequest(http.MethodPost, "/storage/new", strings.NewReader(`{"name":"x"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.104.0.1") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// ── name_required negative path ──────────────────────────────────────────── + +func TestStorage_MissingName_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + + resp, body := postStorage(t, fx, "10.105.0.1", "", "", map[string]any{}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "name_required", body.Error) +} + +// ── invalid env negative path ────────────────────────────────────────────── + +func TestStorage_InvalidEnv_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + + resp, body := postStorage(t, fx, "10.106.0.1", "", "", map[string]any{"name": "x", "env": "BAD ENV!"}) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_env", body.Error) +} + +// ── Anonymous dedup over-cap returns existing (with prefix + presign_url) ─── + +func TestStorage_AnonymousDedup_ReturnsExisting(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + ip := "10.107.0.1" + var firstToken string + for i := 0; i < 6; i++ { + resp, body := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "dedup-storage"}) + resp.Body.Close() + require.True(t, body.OK) + if i < 5 { + require.Equal(t, http.StatusCreated, resp.StatusCode, "call %d provisions fresh", i+1) + if i == 0 { + firstToken = body.Token + } + } else { + require.Equal(t, http.StatusOK, resp.StatusCode, "6th call (over cap) dedups with 200") + assert.NotEmpty(t, body.ConnectionURL) + assert.NotEmpty(t, body.Prefix, "dedup response surfaces the recoverable prefix") + assert.NotEmpty(t, body.PresignURL, "broker-mode dedup surfaces the presign endpoint") + } + } + require.NotEmpty(t, firstToken) +} + +// ── persist failure (bad AES) → 503 + best-effort backend deprovision ────── + +func TestStorage_Anonymous_PersistFailure_Returns503(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), true) // bad AES key + + resp, body := postStorage(t, fx, "10.108.0.1", "", "", map[string]any{"name": "persistfail"}) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, "provision_failed", body.Error) +} + +// setProvisionCounterOverCap sets the per-fingerprint daily provision counter +// to the anonymous cap so the NEXT provision from that fingerprint is over-cap. +func setProvisionCounterOverCap(t *testing.T, rdb *redis.Client, fp string) { + t.Helper() + cap := plans.Default().ProvisionLimit("anonymous") + key := fmt.Sprintf("prov:%s:%s", fp, time.Now().UTC().Format("2006-01-02")) + require.NoError(t, rdb.Set(context.Background(), key, cap, time.Hour).Err()) +} + +// fingerprintFor provisions once from ip and returns the platform-assigned +// fingerprint for that anonymous row. +func fingerprintFor(t *testing.T, fx storageProvFixture, ip string) string { + t.Helper() + resp, body := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "fp-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)) + require.NotEmpty(t, fp) + return fp +} + +// Storage anonymous cross-service daily-cap fallback: over-cap storage POST with +// no storage row but an existing non-storage anon row for the fingerprint → 429. +func TestStorage_Anonymous_CrossServiceCapFallback_Returns429(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + ip := fmt.Sprintf("198.19.%d.%d", rand.Intn(250)+1, rand.Intn(250)+1) + fp := fingerprintFor(t, fx, ip) + + // Remove the probe's STORAGE row so the over-cap POST finds NO same-type row + // (forcing the cross-service fallback), then seed a non-storage anon row so + // GetActiveResourceByFingerprint (any type) DOES find one → 429. + _, err := fx.db.ExecContext(context.Background(), + `DELETE FROM resources WHERE fingerprint = $1 AND resource_type = 'storage'`, fp) + require.NoError(t, err) + _, err = fx.db.ExecContext(context.Background(), ` + INSERT INTO resources (resource_type, tier, env, status, fingerprint) + VALUES ('postgres', 'anonymous', 'development', 'active', $1) + `, fp) + require.NoError(t, err) + setProvisionCounterOverCap(t, fx.rdb, fp) + + resp, body := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "xservice-storage"}) + defer resp.Body.Close() + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Equal(t, "provision_limit_reached", body.Error) +} + +// Storage anonymous dedup decrypt-failure → provisions fresh (not ciphertext). +func TestStorage_Anonymous_DedupDecryptFailure_ProvisionsFresh(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + ip := fmt.Sprintf("198.20.%d.%d", rand.Intn(250)+1, rand.Intn(250)+1) + + resp, body := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "decryptfail-storage"}) + 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)) + + // Corrupt the stored connection_url so dedup decrypt fails. + _, err := fx.db.ExecContext(context.Background(), ` + UPDATE resources SET connection_url = 'not-decryptable' + WHERE resource_type = 'storage' AND status = 'active' AND team_id IS NULL AND fingerprint = $1 + `, fp) + require.NoError(t, err) + setProvisionCounterOverCap(t, fx.rdb, fp) + + resp2, body2 := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "decryptfail-storage-2"}) + defer resp2.Body.Close() + require.Equal(t, http.StatusCreated, resp2.StatusCode, "decrypt-fail dedup must provision fresh") + assert.NotContains(t, body2.ConnectionURL, "not-decryptable") +} + +// ── authenticated storage quota exceeded → 402 storage_limit_reached ─────── +// +// Seed an active storage row for the team whose storage_bytes already exceeds +// the tier's storage_mb limit, so the SumStorageBytesByTeamAndType gate trips +// before provisioning. +func TestStorage_Authenticated_QuotaExceeded_Returns402(t *testing.T) { + fx := setupStorageProvFixture(t, newS3PrefixScopedProvider(t), false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "hobby") // hobby storage limit is small + jwt := authSessionJWT(t, fx.db, teamID) + + limitMB := plans.Default().StorageLimitMB("hobby", "storage") + require.Positive(t, limitMB, "test assumes a positive hobby storage cap") + // Seed a row already at/over the cap. + _, err := fx.db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, resource_type, tier, env, status, storage_bytes) + VALUES ($1::uuid, 'storage', 'hobby', 'development', 'active', $2) + `, teamID, int64(limitMB)*1024*1024+1) + require.NoError(t, err) + + resp, body := postStorage(t, fx, "10.109.0.1", jwt, "", map[string]any{"name": "over-quota"}) + defer resp.Body.Close() + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode) + assert.Equal(t, "storage_limit_reached", body.Error) +} + +// ── anonymous storage byte cap exceeded → 402 storage_limit_reached ──────── +// +// Seed an anonymous storage row (team_id NULL) over the anon byte cap for the +// EXACT fingerprint the test IP maps to, then provision from that IP so the +// SumStorageBytesByFingerprintAndType gate trips. The fingerprint is computed +// the same way the Fingerprint middleware does, so we read it back from a +// throwaway provision first and clear any pre-existing rows to avoid +// cross-test /24 collisions on the shared DB. +func TestStorage_Anonymous_ByteCapExceeded_Returns402(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + // A per-run random /24 keeps this isolated from every other test's + // fingerprint on the shared DB, so the seed provision can't be pre-polluted + // nor over the daily cap. + ip := fmt.Sprintf("198.18.%d.%d", rand.Intn(250)+1, rand.Intn(250)+1) + + resp, body := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "seed-fp"}) + resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode, "seed provision must succeed") + + var fp string + require.NoError(t, fx.db.QueryRowContext(context.Background(), + `SELECT fingerprint FROM resources WHERE token = $1::uuid`, body.Token).Scan(&fp)) + require.NotEmpty(t, fp) + + limitMB := plans.Default().StorageLimitMB("anonymous", "storage") + require.Positive(t, limitMB) + _, err := fx.db.ExecContext(context.Background(), ` + INSERT INTO resources (resource_type, tier, env, status, fingerprint, storage_bytes) + VALUES ('storage', 'anonymous', 'development', 'active', $1, $2) + `, fp, int64(limitMB)*1024*1024+1) + require.NoError(t, err) + + // Next provision from the SAME ip (same fingerprint) trips the byte cap. + // A distinct Idempotency-Key defeats the replay cache so the handler runs. + resp2, body2 := postStorage(t, fx, ip, "", uuid.NewString(), map[string]any{"name": "over-anon-cap"}) + defer resp2.Body.Close() + require.Equal(t, http.StatusPaymentRequired, resp2.StatusCode) + assert.Equal(t, "storage_limit_reached", body2.Error) +} diff --git a/internal/handlers/twin_arms_provarms_test.go b/internal/handlers/twin_arms_provarms_test.go new file mode 100644 index 00000000..8abfcb23 --- /dev/null +++ b/internal/handlers/twin_arms_provarms_test.go @@ -0,0 +1,93 @@ +package handlers_test + +// twin_arms_provarms_test.go — fills the single-twin error arms for cache and +// nosql (the gRPC suite only covers the postgres twin error path) and the +// bulk-twin "already-exists skip" arm that records a skipped item with the +// existing twin's token. + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +// Cache single-twin gRPC error → 503 provision_failed (ProvisionForTwinCore +// provision-failure arm + soft-delete). +func TestGRPCTwin_Cache_DevEnv_GRPCError_Returns503(t *testing.T) { + fake := &fakeProvisioner{failProvision: true} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + _, srcToken := seedSourceResource(t, fx.db, teamID, "redis", "pro", "production") + + resp, body := postTwinDev(t, fx, srcToken, jwt) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, "provision_failed", body.Error) +} + +// NoSQL single-twin gRPC error → 503 provision_failed. +func TestGRPCTwin_NoSQL_DevEnv_GRPCError_Returns503(t *testing.T) { + fake := &fakeProvisioner{failProvision: true} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + _, srcToken := seedSourceResource(t, fx.db, teamID, "mongodb", "pro", "production") + + resp, body := postTwinDev(t, fx, srcToken, jwt) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, "provision_failed", body.Error) +} + +// Cache + NoSQL single-twin persist failure (bad AES) → 503. +func TestGRPCTwin_Cache_PersistFailure_Returns503(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, true) // bad AES key + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + _, srcToken := seedSourceResource(t, fx.db, teamID, "redis", "pro", "production") + + resp, body := postTwinDev(t, fx, srcToken, jwt) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, "provision_failed", body.Error) +} + +func TestGRPCTwin_NoSQL_PersistFailure_Returns503(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, true) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + _, srcToken := seedSourceResource(t, fx.db, teamID, "mongodb", "pro", "production") + + resp, body := postTwinDev(t, fx, srcToken, jwt) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, "provision_failed", body.Error) +} + +// Bulk-twin where a twin already exists in the target env → twinOneParent +// records a SKIPPED item carrying the existing twin's token (the duplicate_twin +// branch), with twinned=0 / skipped=1 and a 200. +func TestGRPCBulkTwin_AlreadyExists_SkipsWithExistingToken(t *testing.T) { + fake := &fakeProvisioner{} + fx := setupGRPCProvFixture(t, fake, false) + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + jwt := authSessionJWT(t, fx.db, teamID) + + // One prod parent + an existing development twin of it. + parentID, _ := seedSourceResource(t, fx.db, teamID, "postgres", "pro", "production") + _, _ = seedResourceFull(t, fx.db, teamID, "postgres", "pro", "development", &parentID) + + resp, body := postBulk(t, fx, jwt, "production", "development") + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, body.OK) + assert.Equal(t, 0, body.Twinned, "the only parent already has a dev twin → nothing new") + assert.Empty(t, body.Failures) +}