|
| 1 | +package handlers_test |
| 2 | + |
| 3 | +// coverage_provisioner_grpc_faults_test.go — fault-injection + authenticated |
| 4 | +// edge-branch coverage for the gRPC-provisioner arms, building on the bufconn |
| 5 | +// fake provisioner from coverage_provisioner_grpc_test.go. |
| 6 | +// |
| 7 | +// Drives: |
| 8 | +// - queue per-tier count cap (402 queue_limit_reached) |
| 9 | +// - queue dedicated tier-gate (402) + dedicated growth success |
| 10 | +// - authenticated gRPC provision error (503) for cache/nosql/queue |
| 11 | +// - anon dedup with a corrupted stored ciphertext (decrypt-fail → fresh) |
| 12 | +// - CreateResource hard failure via a closed *sql.DB (503 provision_failed) |
| 13 | + |
| 14 | +import ( |
| 15 | + "context" |
| 16 | + "database/sql" |
| 17 | + "net/http" |
| 18 | + "net/http/httptest" |
| 19 | + "testing" |
| 20 | + |
| 21 | + "github.com/google/uuid" |
| 22 | + "github.com/stretchr/testify/assert" |
| 23 | + "github.com/stretchr/testify/require" |
| 24 | + |
| 25 | + "instant.dev/internal/testhelpers" |
| 26 | +) |
| 27 | + |
| 28 | +// ── Queue per-tier count cap ─────────────────────────────────────────────── |
| 29 | + |
| 30 | +func TestGRPCQueue_CountCap_Returns402(t *testing.T) { |
| 31 | + fake := &fakeProvisioner{} |
| 32 | + fx := setupGRPCProvFixture(t, fake, false) |
| 33 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "hobby") // queue_count = 3 |
| 34 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 35 | + |
| 36 | + // Provision 3 queues (the hobby cap), each with a distinct idempotency key. |
| 37 | + for i := 0; i < 3; i++ { |
| 38 | + resp, body := doProvisionKeyed(t, fx, "/queue/new", "10.100.0.1", jwt, uuid.NewString(), |
| 39 | + map[string]any{"name": "cap-q"}) |
| 40 | + resp.Body.Close() |
| 41 | + require.Equal(t, http.StatusCreated, resp.StatusCode, "queue %d should provision", i+1) |
| 42 | + require.True(t, body.OK) |
| 43 | + } |
| 44 | + // 4th over the cap → 402 queue_limit_reached. |
| 45 | + resp, body := doProvisionKeyed(t, fx, "/queue/new", "10.100.0.1", jwt, uuid.NewString(), |
| 46 | + map[string]any{"name": "cap-q-over"}) |
| 47 | + defer resp.Body.Close() |
| 48 | + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode) |
| 49 | + assert.Equal(t, "queue_limit_reached", body.Error) |
| 50 | +} |
| 51 | + |
| 52 | +// ── Queue dedicated tier-gate + growth success ───────────────────────────── |
| 53 | + |
| 54 | +func TestGRPCQueue_Dedicated_NonGrowth_Returns402(t *testing.T) { |
| 55 | + fake := &fakeProvisioner{} |
| 56 | + fx := setupGRPCProvFixture(t, fake, false) |
| 57 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 58 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 59 | + |
| 60 | + resp, body := doProvision(t, fx, "/queue/new", "10.101.0.1", jwt, |
| 61 | + map[string]any{"name": "q-ded", "dedicated": true}) |
| 62 | + defer resp.Body.Close() |
| 63 | + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode) |
| 64 | + assert.Equal(t, "upgrade_required", body.Error) |
| 65 | +} |
| 66 | + |
| 67 | +func TestGRPCQueue_Dedicated_Growth_Success(t *testing.T) { |
| 68 | + fake := &fakeProvisioner{} |
| 69 | + fx := setupGRPCProvFixture(t, fake, false) |
| 70 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "growth") |
| 71 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 72 | + |
| 73 | + resp, body := doProvision(t, fx, "/queue/new", "10.102.0.1", jwt, |
| 74 | + map[string]any{"name": "q-ded-ok", "dedicated": true}) |
| 75 | + defer resp.Body.Close() |
| 76 | + require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 77 | + assert.Equal(t, "growth", body.Tier) |
| 78 | +} |
| 79 | + |
| 80 | +// ── Authenticated gRPC provision error → 503 for cache/nosql/queue ───────── |
| 81 | + |
| 82 | +func TestGRPCCache_Authenticated_GRPCError_Returns503(t *testing.T) { |
| 83 | + fake := &fakeProvisioner{failProvision: true} |
| 84 | + fx := setupGRPCProvFixture(t, fake, false) |
| 85 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 86 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 87 | + |
| 88 | + resp, body := doProvision(t, fx, "/cache/new", "10.103.0.1", jwt, map[string]any{"name": "c-auth-fail"}) |
| 89 | + defer resp.Body.Close() |
| 90 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 91 | + assert.Equal(t, "provision_failed", body.Error) |
| 92 | +} |
| 93 | + |
| 94 | +func TestGRPCNoSQL_Authenticated_GRPCError_Returns503(t *testing.T) { |
| 95 | + fake := &fakeProvisioner{failProvision: true} |
| 96 | + fx := setupGRPCProvFixture(t, fake, false) |
| 97 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 98 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 99 | + |
| 100 | + resp, body := doProvision(t, fx, "/nosql/new", "10.104.0.1", jwt, map[string]any{"name": "m-auth-fail"}) |
| 101 | + defer resp.Body.Close() |
| 102 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 103 | + assert.Equal(t, "provision_failed", body.Error) |
| 104 | +} |
| 105 | + |
| 106 | +func TestGRPCQueue_Authenticated_GRPCError_Returns503(t *testing.T) { |
| 107 | + fake := &fakeProvisioner{failProvision: true} |
| 108 | + fx := setupGRPCProvFixture(t, fake, false) |
| 109 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 110 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 111 | + |
| 112 | + resp, body := doProvision(t, fx, "/queue/new", "10.105.0.1", jwt, map[string]any{"name": "q-auth-fail"}) |
| 113 | + defer resp.Body.Close() |
| 114 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 115 | + assert.Equal(t, "provision_failed", body.Error) |
| 116 | +} |
| 117 | + |
| 118 | +// ── Authenticated cache/nosql persist failure (bad AES) → 503 + deprovision ─ |
| 119 | + |
| 120 | +func TestGRPCCache_Authenticated_PersistFailure_Returns503(t *testing.T) { |
| 121 | + fake := &fakeProvisioner{} |
| 122 | + fx := setupGRPCProvFixture(t, fake, true) // bad AES key |
| 123 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 124 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 125 | + |
| 126 | + resp, body := doProvision(t, fx, "/cache/new", "10.106.0.1", jwt, map[string]any{"name": "c-auth-persist"}) |
| 127 | + defer resp.Body.Close() |
| 128 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 129 | + assert.Equal(t, "provision_failed", body.Error) |
| 130 | + assert.GreaterOrEqual(t, fake.deprovisionCount(), 1) |
| 131 | +} |
| 132 | + |
| 133 | +func TestGRPCQueue_Authenticated_PersistFailure_Returns503(t *testing.T) { |
| 134 | + fake := &fakeProvisioner{} |
| 135 | + fx := setupGRPCProvFixture(t, fake, true) |
| 136 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 137 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 138 | + |
| 139 | + resp, body := doProvision(t, fx, "/queue/new", "10.107.0.1", jwt, map[string]any{"name": "q-auth-persist"}) |
| 140 | + defer resp.Body.Close() |
| 141 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 142 | + assert.Equal(t, "provision_failed", body.Error) |
| 143 | + assert.GreaterOrEqual(t, fake.deprovisionCount(), 1) |
| 144 | +} |
| 145 | + |
| 146 | +// ── Anon dedup with corrupted stored ciphertext → decrypt-fail → fresh ───── |
| 147 | +// |
| 148 | +// Seed an active anonymous row whose connection_url is non-empty but NOT valid |
| 149 | +// AES ciphertext, set the fingerprint counter over cap, then provision: the |
| 150 | +// dedup branch hits decryptConnectionURL → (_, false) and falls through to a |
| 151 | +// fresh provision (the gRPC fake supplies a usable URL). |
| 152 | + |
| 153 | +func TestGRPCDB_AnonDedup_DecryptFailure_FallsThrough(t *testing.T) { |
| 154 | + fake := &fakeProvisioner{} |
| 155 | + fx := setupGRPCProvFixture(t, fake, false) |
| 156 | + |
| 157 | + ip := "10.110.0.1" |
| 158 | + // Provision once so the fingerprint has a real row + the recycle marker. |
| 159 | + resp0, _ := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "decryptfail-seed"}) |
| 160 | + resp0.Body.Close() |
| 161 | + require.Equal(t, http.StatusCreated, resp0.StatusCode) |
| 162 | + |
| 163 | + // Corrupt the stored connection_url on the most-recent row for this |
| 164 | + // fingerprint so the dedup decrypt fails. |
| 165 | + _, err := fx.db.ExecContext(context.Background(), |
| 166 | + `UPDATE resources SET connection_url = 'not-valid-ciphertext' |
| 167 | + WHERE fingerprint = (SELECT fingerprint FROM resources WHERE name = 'decryptfail-seed' LIMIT 1) |
| 168 | + AND resource_type = 'postgres' AND status = 'active'`) |
| 169 | + require.NoError(t, err) |
| 170 | + |
| 171 | + // Push the fingerprint over the cap (5) so the next call enters the |
| 172 | + // limitExceeded → dedup branch. Distinct idempotency keys per call. |
| 173 | + for i := 0; i < 5; i++ { |
| 174 | + r, _ := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "decryptfail-fill"}) |
| 175 | + r.Body.Close() |
| 176 | + } |
| 177 | + // Over-cap call: dedup decrypt fails on the corrupted row → falls through. |
| 178 | + // The response is either a fresh 201 or a dedup 200 onto a non-corrupted |
| 179 | + // row; either way the connection_url must be usable (never the ciphertext). |
| 180 | + resp, body := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "decryptfail-final"}) |
| 181 | + defer resp.Body.Close() |
| 182 | + require.True(t, body.OK) |
| 183 | + assert.NotEqual(t, "not-valid-ciphertext", body.ConnectionURL) |
| 184 | +} |
| 185 | + |
| 186 | +// ── CreateResource hard failure via a closed DB → 503 provision_failed ───── |
| 187 | +// |
| 188 | +// Closing the *sql.DB after fixture setup makes models.CreateResource fail, so |
| 189 | +// the anonymous-path CreateResource error branch (provision_failed) runs. |
| 190 | + |
| 191 | +func TestGRPCDB_Anonymous_CreateResourceFailure_Returns503(t *testing.T) { |
| 192 | + fake := &fakeProvisioner{} |
| 193 | + fx := setupGRPCProvFixture(t, fake, false) |
| 194 | + |
| 195 | + // Close the platform DB so CreateResource errors. |
| 196 | + require.NoError(t, closeUnderlying(fx.db)) |
| 197 | + |
| 198 | + resp, body := doProvision(t, fx, "/db/new", "10.120.0.1", "", map[string]any{"name": "createfail"}) |
| 199 | + defer resp.Body.Close() |
| 200 | + // Either provision_failed (CreateResource err) or another 5xx — assert 503. |
| 201 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 202 | + assert.NotEmpty(t, body.Error) |
| 203 | +} |
| 204 | + |
| 205 | +// closeUnderlying closes the *sql.DB so subsequent queries fail. |
| 206 | +func closeUnderlying(db *sql.DB) error { return db.Close() } |
| 207 | + |
| 208 | +// ── Resource Delete via gRPC provisioner (deprovision path) ──────────────── |
| 209 | +// |
| 210 | +// Provision a real (authenticated) resource through the fixture, then DELETE it |
| 211 | +// — the Delete handler's default arm calls provisioner.DeprovisionResource |
| 212 | +// against the bufconn fake (the non-nil-provisioner branch in resource.go). |
| 213 | + |
| 214 | +func TestGRPCResource_Delete_DeprovisionsViaGRPC(t *testing.T) { |
| 215 | + fake := &fakeProvisioner{} |
| 216 | + fx := setupGRPCProvFixture(t, fake, false) |
| 217 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 218 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 219 | + |
| 220 | + // Provision an authenticated postgres resource. |
| 221 | + resp, body := doProvision(t, fx, "/db/new", "10.140.0.1", jwt, map[string]any{"name": "del-db"}) |
| 222 | + resp.Body.Close() |
| 223 | + require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 224 | + require.NotEmpty(t, body.Token) |
| 225 | + |
| 226 | + before := fake.deprovisionCount() |
| 227 | + |
| 228 | + // DELETE /api/v1/resources/:token |
| 229 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/resources/"+body.Token, nil) |
| 230 | + req.Header.Set("Authorization", "Bearer "+jwt) |
| 231 | + delResp, err := fx.app.Test(req, 15000) |
| 232 | + require.NoError(t, err) |
| 233 | + defer delResp.Body.Close() |
| 234 | + require.Equal(t, http.StatusOK, delResp.StatusCode) |
| 235 | + assert.Greater(t, fake.deprovisionCount(), before, |
| 236 | + "Delete must call provisioner.DeprovisionResource via the gRPC client") |
| 237 | +} |
| 238 | + |
| 239 | +func TestGRPCResource_Get_AfterProvision(t *testing.T) { |
| 240 | + fake := &fakeProvisioner{} |
| 241 | + fx := setupGRPCProvFixture(t, fake, false) |
| 242 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 243 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 244 | + |
| 245 | + resp, body := doProvision(t, fx, "/db/new", "10.141.0.1", jwt, map[string]any{"name": "get-db"}) |
| 246 | + resp.Body.Close() |
| 247 | + require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 248 | + |
| 249 | + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources/"+body.Token, nil) |
| 250 | + req.Header.Set("Authorization", "Bearer "+jwt) |
| 251 | + getResp, err := fx.app.Test(req, 15000) |
| 252 | + require.NoError(t, err) |
| 253 | + defer getResp.Body.Close() |
| 254 | + require.Equal(t, http.StatusOK, getResp.StatusCode) |
| 255 | +} |
| 256 | + |
| 257 | +func TestGRPCResource_Delete_CrossTeam_404(t *testing.T) { |
| 258 | + fake := &fakeProvisioner{} |
| 259 | + fx := setupGRPCProvFixture(t, fake, false) |
| 260 | + ownerTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 261 | + ownerJWT := authSessionJWT(t, fx.db, ownerTeam) |
| 262 | + resp, body := doProvision(t, fx, "/db/new", "10.142.0.1", ownerJWT, map[string]any{"name": "xt-db"}) |
| 263 | + resp.Body.Close() |
| 264 | + require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 265 | + |
| 266 | + // A different team tries to delete it → 404 (never confirm existence). |
| 267 | + otherTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 268 | + otherJWT := authSessionJWT(t, fx.db, otherTeam) |
| 269 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/resources/"+body.Token, nil) |
| 270 | + req.Header.Set("Authorization", "Bearer "+otherJWT) |
| 271 | + delResp, err := fx.app.Test(req, 15000) |
| 272 | + require.NoError(t, err) |
| 273 | + defer delResp.Body.Close() |
| 274 | + require.Equal(t, http.StatusNotFound, delResp.StatusCode) |
| 275 | +} |
| 276 | + |
| 277 | +func TestGRPCResource_Delete_BadUUID_400(t *testing.T) { |
| 278 | + fake := &fakeProvisioner{} |
| 279 | + fx := setupGRPCProvFixture(t, fake, false) |
| 280 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 281 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 282 | + |
| 283 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/resources/not-a-uuid", nil) |
| 284 | + req.Header.Set("Authorization", "Bearer "+jwt) |
| 285 | + delResp, err := fx.app.Test(req, 15000) |
| 286 | + require.NoError(t, err) |
| 287 | + defer delResp.Body.Close() |
| 288 | + require.Equal(t, http.StatusBadRequest, delResp.StatusCode) |
| 289 | +} |
| 290 | + |
| 291 | +func TestGRPCResource_Delete_NotFound_404(t *testing.T) { |
| 292 | + fake := &fakeProvisioner{} |
| 293 | + fx := setupGRPCProvFixture(t, fake, false) |
| 294 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 295 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 296 | + |
| 297 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/resources/"+uuid.NewString(), nil) |
| 298 | + req.Header.Set("Authorization", "Bearer "+jwt) |
| 299 | + delResp, err := fx.app.Test(req, 15000) |
| 300 | + require.NoError(t, err) |
| 301 | + defer delResp.Body.Close() |
| 302 | + require.Equal(t, http.StatusNotFound, delResp.StatusCode) |
| 303 | +} |
| 304 | + |
| 305 | +// ── DB dedicated growth success + authenticated persist failure ──────────── |
| 306 | + |
| 307 | +func TestGRPCDB_Dedicated_Growth_Success(t *testing.T) { |
| 308 | + fake := &fakeProvisioner{} |
| 309 | + fx := setupGRPCProvFixture(t, fake, false) |
| 310 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "growth") |
| 311 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 312 | + |
| 313 | + resp, body := doProvision(t, fx, "/db/new", "10.130.0.1", jwt, |
| 314 | + map[string]any{"name": "db-ded-ok", "dedicated": true}) |
| 315 | + defer resp.Body.Close() |
| 316 | + require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 317 | + assert.Equal(t, "growth", body.Tier) |
| 318 | +} |
| 319 | + |
| 320 | +func TestGRPCDB_Authenticated_PersistFailure_Returns503(t *testing.T) { |
| 321 | + fake := &fakeProvisioner{} |
| 322 | + fx := setupGRPCProvFixture(t, fake, true) // bad AES key |
| 323 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 324 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 325 | + |
| 326 | + resp, body := doProvision(t, fx, "/db/new", "10.131.0.1", jwt, map[string]any{"name": "db-auth-persist"}) |
| 327 | + defer resp.Body.Close() |
| 328 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 329 | + assert.Equal(t, "provision_failed", body.Error) |
| 330 | + assert.GreaterOrEqual(t, fake.deprovisionCount(), 1) |
| 331 | +} |
| 332 | + |
| 333 | +func TestGRPCNoSQL_Authenticated_PersistFailure_Returns503(t *testing.T) { |
| 334 | + fake := &fakeProvisioner{} |
| 335 | + fx := setupGRPCProvFixture(t, fake, true) |
| 336 | + teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro") |
| 337 | + jwt := authSessionJWT(t, fx.db, teamID) |
| 338 | + |
| 339 | + resp, body := doProvision(t, fx, "/nosql/new", "10.132.0.1", jwt, map[string]any{"name": "m-auth-persist"}) |
| 340 | + defer resp.Body.Close() |
| 341 | + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) |
| 342 | + assert.Equal(t, "provision_failed", body.Error) |
| 343 | + assert.GreaterOrEqual(t, fake.deprovisionCount(), 1) |
| 344 | +} |
| 345 | + |
| 346 | +// ── Cross-service daily-cap fallback → 429 provision_limit_reached ───────── |
| 347 | +// |
| 348 | +// Fill the cap with 5 cache provisions (distinct idem keys), then request a DB |
| 349 | +// from the SAME fingerprint: over cap, no postgres row exists but a redis row |
| 350 | +// does → cross-service fallback fires a 429. |
| 351 | + |
| 352 | +func TestGRPCCrossServiceCap_Returns429(t *testing.T) { |
| 353 | + fake := &fakeProvisioner{} |
| 354 | + fx := setupGRPCProvFixture(t, fake, false) |
| 355 | + ip := "10.133.0.1" |
| 356 | + for i := 0; i < 5; i++ { |
| 357 | + r, _ := doProvisionKeyed(t, fx, "/cache/new", ip, "", uuid.NewString(), map[string]any{"name": "xcap-cache"}) |
| 358 | + r.Body.Close() |
| 359 | + } |
| 360 | + // 6th call, postgres, same fingerprint: over cap, no postgres row but a |
| 361 | + // redis row exists → 429 provision_limit_reached. |
| 362 | + resp, body := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(), map[string]any{"name": "xcap-db"}) |
| 363 | + defer resp.Body.Close() |
| 364 | + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) |
| 365 | + assert.Equal(t, "provision_limit_reached", body.Error) |
| 366 | +} |
0 commit comments