Skip to content

Commit 46e0631

Browse files
test(coverage): vector over-cap dedup + cross-service fallback; backup CreateRestore/ListRestores DB-error arms
- vector.go: over-cap dedup decrypt-fail fallthrough (corrupt all active rows then over-cap) + cross-service-fallback 429 (retype rows so type-lookup misses but any-lookup hits); auth gRPC-error soft-delete via DB-exposed failing bufconn fixture. - backup.go (now 95.8%): CreateRestore team/backup/inflight/insert DB-error arms, no-user 401, missing-ack 400; ListRestores count-fail; bad-team/bad-id across all four routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f1eeb0 commit 46e0631

2 files changed

Lines changed: 302 additions & 30 deletions

File tree

internal/handlers/backup_final_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,208 @@ func TestBackupFinal_ListRestores_ListFailed_503(t *testing.T) {
178178
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
179179
}
180180

181+
// CreateRestore: GetTeamByID errors → team_lookup_failed (backup.go:411). In-
182+
// place restore (no target). resource(1) succeeds, team(2) errors. failAfter=1.
183+
func TestBackupFinal_CreateRestore_TeamLookup_503(t *testing.T) {
184+
seedDB, clean := testhelpers.SetupTestDB(t)
185+
defer clean()
186+
rdb, cleanR := testhelpers.SetupTestRedis(t)
187+
defer cleanR()
188+
teamID := testhelpers.MustCreateTeamDB(t, seedDB, "pro")
189+
token := bkSeedPGResource(t, seedDB, teamID)
190+
var resID string
191+
require.NoError(t, seedDB.QueryRowContext(context.Background(),
192+
`SELECT id::text FROM resources WHERE token=$1::uuid`, token).Scan(&resID))
193+
backupID := seedBackupRow(t, seedDB, resID, "ok")
194+
195+
faultDB := openFaultDB(t, 1)
196+
h := handlers.NewBackupHandler(faultDB, rdb, plans.Default())
197+
app := newBackupApp(t, h, teamID, uuid.NewString())
198+
body := `{"backup_id":"` + backupID + `","destructive_acknowledgment":true}`
199+
resp := bkDo(t, app, http.MethodPost, "/api/v1/resources/"+token+"/restore", body)
200+
defer resp.Body.Close()
201+
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
202+
assert.Equal(t, "team_lookup_failed", bkErr(t, resp))
203+
}
204+
205+
// CreateRestore: GetBackupByIDForTeam errors → backup_lookup_failed
206+
// (backup.go:438). resource(1) + team(2) succeed, backup lookup(3) errors.
207+
// failAfter=2.
208+
func TestBackupFinal_CreateRestore_BackupLookup_503(t *testing.T) {
209+
seedDB, clean := testhelpers.SetupTestDB(t)
210+
defer clean()
211+
rdb, cleanR := testhelpers.SetupTestRedis(t)
212+
defer cleanR()
213+
teamID := testhelpers.MustCreateTeamDB(t, seedDB, "pro")
214+
token := bkSeedPGResource(t, seedDB, teamID)
215+
var resID string
216+
require.NoError(t, seedDB.QueryRowContext(context.Background(),
217+
`SELECT id::text FROM resources WHERE token=$1::uuid`, token).Scan(&resID))
218+
backupID := seedBackupRow(t, seedDB, resID, "ok")
219+
220+
faultDB := openFaultDB(t, 2)
221+
h := handlers.NewBackupHandler(faultDB, rdb, plans.Default())
222+
app := newBackupApp(t, h, teamID, uuid.NewString())
223+
body := `{"backup_id":"` + backupID + `","destructive_acknowledgment":true}`
224+
resp := bkDo(t, app, http.MethodPost, "/api/v1/resources/"+token+"/restore", body)
225+
defer resp.Body.Close()
226+
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
227+
assert.Equal(t, "backup_lookup_failed", bkErr(t, resp))
228+
}
229+
230+
// CreateRestore: HasInflightRestore errors → inflight_check_failed
231+
// (backup.go:464). resource(1) + team(2) + backup(3) succeed, inflight(4)
232+
// errors. failAfter=3.
233+
func TestBackupFinal_CreateRestore_InflightCheck_503(t *testing.T) {
234+
seedDB, clean := testhelpers.SetupTestDB(t)
235+
defer clean()
236+
rdb, cleanR := testhelpers.SetupTestRedis(t)
237+
defer cleanR()
238+
teamID := testhelpers.MustCreateTeamDB(t, seedDB, "pro")
239+
token := bkSeedPGResource(t, seedDB, teamID)
240+
var resID string
241+
require.NoError(t, seedDB.QueryRowContext(context.Background(),
242+
`SELECT id::text FROM resources WHERE token=$1::uuid`, token).Scan(&resID))
243+
backupID := seedBackupRow(t, seedDB, resID, "ok")
244+
245+
faultDB := openFaultDB(t, 3)
246+
h := handlers.NewBackupHandler(faultDB, rdb, plans.Default())
247+
app := newBackupApp(t, h, teamID, uuid.NewString())
248+
body := `{"backup_id":"` + backupID + `","destructive_acknowledgment":true}`
249+
resp := bkDo(t, app, http.MethodPost, "/api/v1/resources/"+token+"/restore", body)
250+
defer resp.Body.Close()
251+
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
252+
assert.Equal(t, "inflight_check_failed", bkErr(t, resp))
253+
}
254+
255+
// Bad team-id in Locals → unauthorized across CreateBackup / ListBackups /
256+
// CreateRestore / ListRestores (parseTeamID arms 104 / 224 / 320 / 550).
257+
func TestBackupFinal_BadTeamID_Unauthorized(t *testing.T) {
258+
db, clean := testhelpers.SetupTestDB(t)
259+
defer clean()
260+
rdb, cleanR := testhelpers.SetupTestRedis(t)
261+
defer cleanR()
262+
h := handlers.NewBackupHandler(db, rdb, plans.Default())
263+
app := newBackupApp(t, h, "not-a-uuid", uuid.NewString())
264+
tok := uuid.NewString()
265+
for _, route := range []struct {
266+
method, path string
267+
}{
268+
{http.MethodPost, "/api/v1/resources/" + tok + "/backup"},
269+
{http.MethodGet, "/api/v1/resources/" + tok + "/backups"},
270+
{http.MethodPost, "/api/v1/resources/" + tok + "/restore"},
271+
{http.MethodGet, "/api/v1/resources/" + tok + "/restores"},
272+
} {
273+
resp := bkDo(t, app, route.method, route.path, "")
274+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "route %s %s", route.method, route.path)
275+
resp.Body.Close()
276+
}
277+
}
278+
279+
// Non-UUID :id → invalid_id across the four routes (224 / 230 / 332 / 557).
280+
func TestBackupFinal_BadResourceID_400(t *testing.T) {
281+
db, clean := testhelpers.SetupTestDB(t)
282+
defer clean()
283+
rdb, cleanR := testhelpers.SetupTestRedis(t)
284+
defer cleanR()
285+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
286+
h := handlers.NewBackupHandler(db, rdb, plans.Default())
287+
app := newBackupApp(t, h, teamID, uuid.NewString())
288+
for _, route := range []struct{ method, path string }{
289+
{http.MethodPost, "/api/v1/resources/not-a-uuid/backup"},
290+
{http.MethodGet, "/api/v1/resources/not-a-uuid/backups"},
291+
{http.MethodPost, "/api/v1/resources/not-a-uuid/restore"},
292+
{http.MethodGet, "/api/v1/resources/not-a-uuid/restores"},
293+
} {
294+
resp := bkDo(t, app, route.method, route.path, "")
295+
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "route %s", route.path)
296+
resp.Body.Close()
297+
}
298+
}
299+
300+
// CreateRestore: in-place without destructive_acknowledgment → 400
301+
// destructive_ack_required (backup.go:404-area).
302+
func TestBackupFinal_CreateRestore_MissingAck_400(t *testing.T) {
303+
db, clean := testhelpers.SetupTestDB(t)
304+
defer clean()
305+
rdb, cleanR := testhelpers.SetupTestRedis(t)
306+
defer cleanR()
307+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
308+
token := bkSeedPGResource(t, db, teamID)
309+
var resID string
310+
require.NoError(t, db.QueryRowContext(context.Background(),
311+
`SELECT id::text FROM resources WHERE token=$1::uuid`, token).Scan(&resID))
312+
backupID := seedBackupRow(t, db, resID, "ok")
313+
314+
h := handlers.NewBackupHandler(db, rdb, plans.Default())
315+
app := newBackupApp(t, h, teamID, uuid.NewString())
316+
body := `{"backup_id":"` + backupID + `"}` // no destructive_acknowledgment
317+
resp := bkDo(t, app, http.MethodPost, "/api/v1/resources/"+token+"/restore", body)
318+
defer resp.Body.Close()
319+
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
320+
assert.Equal(t, "destructive_ack_required", bkErr(t, resp))
321+
}
322+
323+
// CreateRestore: CreateRestoreRow errors → restore_create_failed (backup.go:508).
324+
// resource(1)+team(2)+backup(3)+inflight(4) succeed, the INSERT(5) errors.
325+
// failAfter=4.
326+
func TestBackupFinal_CreateRestore_InsertFailed_503(t *testing.T) {
327+
seedDB, clean := testhelpers.SetupTestDB(t)
328+
defer clean()
329+
rdb, cleanR := testhelpers.SetupTestRedis(t)
330+
defer cleanR()
331+
teamID := testhelpers.MustCreateTeamDB(t, seedDB, "pro")
332+
token := bkSeedPGResource(t, seedDB, teamID)
333+
var resID string
334+
require.NoError(t, seedDB.QueryRowContext(context.Background(),
335+
`SELECT id::text FROM resources WHERE token=$1::uuid`, token).Scan(&resID))
336+
backupID := seedBackupRow(t, seedDB, resID, "ok")
337+
338+
faultDB := openFaultDB(t, 4)
339+
h := handlers.NewBackupHandler(faultDB, rdb, plans.Default())
340+
app := newBackupApp(t, h, teamID, uuid.NewString())
341+
body := `{"backup_id":"` + backupID + `","destructive_acknowledgment":true}`
342+
resp := bkDo(t, app, http.MethodPost, "/api/v1/resources/"+token+"/restore", body)
343+
defer resp.Body.Close()
344+
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
345+
assert.Equal(t, "restore_create_failed", bkErr(t, resp))
346+
}
347+
348+
// CreateRestore: missing user session → unauthorized (backup.go:325). The
349+
// newBackupApp helper pins a user; pass "" to drop it.
350+
func TestBackupFinal_CreateRestore_NoUser_401(t *testing.T) {
351+
db, clean := testhelpers.SetupTestDB(t)
352+
defer clean()
353+
rdb, cleanR := testhelpers.SetupTestRedis(t)
354+
defer cleanR()
355+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
356+
token := bkSeedPGResource(t, db, teamID)
357+
h := handlers.NewBackupHandler(db, rdb, plans.Default())
358+
app := newBackupApp(t, h, teamID, "") // no user-id local
359+
body := `{"backup_id":"` + uuid.NewString() + `","destructive_acknowledgment":true}`
360+
resp := bkDo(t, app, http.MethodPost, "/api/v1/resources/"+token+"/restore", body)
361+
defer resp.Body.Close()
362+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
363+
}
364+
365+
// ListRestores: COUNT fails after list succeeds → 200 with total=len(items)
366+
// (backup.go:579). resource(1)+list(2) succeed, count(3) errors. failAfter=2.
367+
func TestBackupFinal_ListRestores_CountFail_200(t *testing.T) {
368+
seedDB, clean := testhelpers.SetupTestDB(t)
369+
defer clean()
370+
rdb, cleanR := testhelpers.SetupTestRedis(t)
371+
defer cleanR()
372+
teamID := testhelpers.MustCreateTeamDB(t, seedDB, "pro")
373+
token := bkSeedPGResource(t, seedDB, teamID)
374+
375+
faultDB := openFaultDB(t, 2)
376+
h := handlers.NewBackupHandler(faultDB, rdb, plans.Default())
377+
app := newBackupApp(t, h, teamID, uuid.NewString())
378+
resp := bkDo(t, app, http.MethodGet, "/api/v1/resources/"+token+"/restores", "")
379+
defer resp.Body.Close()
380+
require.Equal(t, http.StatusOK, resp.StatusCode)
381+
}
382+
181383
// decodeJSON reads the response body into v.
182384
func decodeJSON(resp *http.Response, v any) error {
183385
return json.NewDecoder(resp.Body).Decode(v)

internal/handlers/vector_final_test.go

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -141,28 +141,61 @@ func TestVectorFinal_Anon_OverCap_DedupDecryptFail(t *testing.T) {
141141
post := func() (*http.Response, vecRespVecwave) {
142142
return postVectorVecwave(t, app, ip, "", "", map[string]any{"name": "v", "env": "production"})
143143
}
144-
first, body := post()
144+
first, _ := post()
145145
first.Body.Close()
146146
require.Equal(t, http.StatusCreated, first.StatusCode)
147147

148-
// Corrupt the stored connection_url so the dedup decrypt fails.
148+
// Burn the rest of the daily cap (anonymous = 5/fp) so the NEXT call lands
149+
// on the over-cap dedup branch.
150+
for i := 0; i < 5; i++ {
151+
r, _ := post()
152+
r.Body.Close()
153+
}
154+
155+
// Now corrupt EVERY active vector resource for this fingerprint so the
156+
// over-cap dedup's GetActiveResourceByFingerprintType returns a row whose
157+
// connection_url cannot be decrypted → the fail-closed fallthrough
158+
// (vector.go:294-298) runs instead of emitting ciphertext.
149159
_, err := db.ExecContext(context.Background(),
150-
`UPDATE resources SET connection_url = 'not-valid-ciphertext' WHERE token = $1::uuid`, body.Token)
160+
`UPDATE resources SET connection_url = 'not-valid-ciphertext'
161+
WHERE resource_type = 'vector' AND status = 'active' AND tier = 'anonymous'`)
151162
require.NoError(t, err)
152163

153-
// Hammer past the daily cap (anonymous = 5/fp). Some calls dedup-hit the
154-
// corrupt row (decrypt-fail → fall through to fresh provision → 201) or
155-
// over-cap deny (429); we just need the corrupt-dedup arm to execute.
156-
sawResolved := false
157-
for i := 0; i < 10; i++ {
158-
resp, _ := post()
159-
code := resp.StatusCode
160-
resp.Body.Close()
161-
if code == http.StatusOK || code == http.StatusCreated || code == http.StatusTooManyRequests {
162-
sawResolved = true
163-
}
164+
// One more over-cap call: dedup hit on a corrupt row → decrypt fails →
165+
// fallthrough (then recycle gate / fresh provision / deny). Any non-5xx
166+
// outcome proves the corrupt-url fallthrough arm executed.
167+
resp, _ := post()
168+
code := resp.StatusCode
169+
resp.Body.Close()
170+
assert.NotEqual(t, http.StatusInternalServerError, code)
171+
}
172+
173+
// TestVectorFinal_Anon_OverCap_CrossServiceFallback — burns the cap with vector
174+
// provisions, then RETYPES every active row for the fingerprint to 'redis' so
175+
// the over-cap vector-type-by-env lookup MISSES but the any-type-by-env lookup
176+
// HITS → cross-service daily-cap fallback 429 (vector.go:269-275).
177+
func TestVectorFinal_Anon_OverCap_CrossServiceFallback(t *testing.T) {
178+
app, db, _ := vectorGRPCAppWithDB(t)
179+
const ip = "10.131.0.8"
180+
post := func() (*http.Response, vecRespVecwave) {
181+
return postVectorVecwave(t, app, ip, "", "", map[string]any{"name": "v", "env": "production"})
164182
}
165-
assert.True(t, sawResolved, "over-cap calls should resolve to dedup/fresh/deny, exercising the corrupt-url fallthrough")
183+
// Burn the full cap (6 calls → over-cap on the 6th onward).
184+
for i := 0; i < 6; i++ {
185+
r, _ := post()
186+
r.Body.Close()
187+
}
188+
// Retype the fingerprint's vector rows to redis: vector-type lookup now
189+
// misses, but any-type lookup still finds a row → cross-service 429.
190+
_, err := db.ExecContext(context.Background(),
191+
`UPDATE resources SET resource_type = 'redis'
192+
WHERE resource_type = 'vector' AND status = 'active' AND tier = 'anonymous'`)
193+
require.NoError(t, err)
194+
195+
resp, body := post()
196+
defer resp.Body.Close()
197+
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
198+
assert.Equal(t, "provision_limit_reached", body.Error)
166199
}
167200

168201
// TestVectorFinal_Auth_TeamLookup_DBError_503 — GetTeamByID errors (vector.go:453).
@@ -223,25 +256,62 @@ func TestVectorFinal_Auth_CreateResource_DBError_503(t *testing.T) {
223256
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
224257
}
225258

226-
// TestVectorFinal_Auth_GRPCError_SoftDelete_503 — provision via the bufconn
227-
// fake set to fail → soft-delete + 503 (vector.go:514). Reuses the vecwave
228-
// fixture.
259+
// vectorGRPCFailAppWithDB builds a /vector/new app whose bufconn provisioner
260+
// FAILS, exposing the DB so an authenticated team can be seeded.
261+
func vectorGRPCFailAppWithDB(t *testing.T) (*fiber.App, *sql.DB) {
262+
t.Helper()
263+
db, _ := testhelpers.SetupTestDB(t)
264+
rdb, _ := testhelpers.SetupTestRedis(t)
265+
t.Cleanup(func() { db.Close(); rdb.Close() })
266+
cfg := &config.Config{
267+
JWTSecret: testhelpers.TestJWTSecret,
268+
AESKey: testhelpers.TestAESKeyHex,
269+
EnabledServices: "postgres,vector,redis",
270+
Environment: "test",
271+
PostgresProvisionBackend: "local",
272+
}
273+
provClient := newBufconnProvisionerClient(t, &fakeProvisioner{failProvision: true})
274+
app := fiber.New(fiber.Config{
275+
ErrorHandler: func(c *fiber.Ctx, e error) error {
276+
if e == handlers.ErrResponseWritten {
277+
return nil
278+
}
279+
code := fiber.StatusInternalServerError
280+
if fe, ok := e.(*fiber.Error); ok {
281+
code = fe.Code
282+
}
283+
_ = handlers.WriteFiberError(c, code, "internal_error", e.Error())
284+
return nil
285+
},
286+
ProxyHeader: "X-Forwarded-For",
287+
})
288+
app.Use(middleware.RequestID())
289+
app.Use(middleware.Fingerprint())
290+
vectorH := handlers.NewVectorHandler(db, rdb, cfg, provClient, plans.Default())
291+
app.Post("/vector/new", middleware.OptionalAuth(cfg), vectorH.NewVector)
292+
return app, db
293+
}
294+
295+
// TestVectorFinal_Auth_GRPCError_SoftDelete_503 — an AUTHENTICATED provision
296+
// where the gRPC provisioner fails → soft-delete + 503 (vector.go:514). Uses a
297+
// DB-exposed failing fixture so we can seed the team the JWT points at.
229298
func TestVectorFinal_Auth_GRPCError_SoftDelete_503(t *testing.T) {
230-
fake := &fakeProvisioner{failProvision: true}
231-
app, _, cleanup := setupVectorGRPCFixture(t, fake, false)
232-
defer cleanup()
299+
app, db := vectorGRPCFailAppWithDB(t)
300+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
301+
jwt := vecJWT(t, db, teamID)
302+
303+
resp := vecPost(t, app, "10.62.0.1", jwt, `{"name":"v","env":"production"}`)
304+
defer resp.Body.Close()
305+
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
306+
}
233307

234-
// Need a seeded team + jwt; pull a fresh DB through the fixture's app is
235-
// not exposed, so seed against a parallel DB and reuse the same secret.
236-
// The fixture's VectorHandler shares the test DB created inside it, so we
237-
// must mint the JWT against THAT db. Use a dev-only set-tier shortcut is
238-
// unavailable; instead use the anonymous→gRPC-error path which also hits
239-
// soft-delete on the anonymous arm (vector.go:362). Both share the
240-
// SoftDeleteResource branch.
241-
resp, body := postVectorVecwave(t, app, "10.62.0.1", "", "", map[string]any{"name": "v", "env": "production"})
308+
// TestVectorFinal_Anon_GRPCError_SoftDelete_503 — anonymous provision gRPC
309+
// failure → soft-delete on the anon arm (vector.go:362).
310+
func TestVectorFinal_Anon_GRPCError_SoftDelete_503(t *testing.T) {
311+
app, _ := vectorGRPCFailAppWithDB(t)
312+
resp := vecPost(t, app, "10.62.0.9", "", `{"name":"v","env":"production"}`)
242313
defer resp.Body.Close()
243314
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
244-
assert.Equal(t, "provision_failed", body.Error)
245315
}
246316

247317
// TestVectorFinal_ParseDimensions_MalformedJSON_Default — a body that is valid

0 commit comments

Comments
 (0)