diff --git a/internal/handlers/admin_customers_residual_test.go b/internal/handlers/admin_customers_residual_test.go new file mode 100644 index 0000000..607cc4f --- /dev/null +++ b/internal/handlers/admin_customers_residual_test.go @@ -0,0 +1,560 @@ +package handlers_test + +// admin_customers_residual_test.go — residual coverage for admin_customers.go, +// pushing the file from 78.2% → ≥95%. Targets the branches the prior slice +// left uncovered: +// +// - NewAdminCustomersHandler's default CancelSubscription closure (returns +// errBillingNotConfigured) — exercised by demoting a team via the default +// handler (no injected cancelFn). +// - List: single-tier exact-match filter, query-failed (brokenDB), and the +// scan/rows-err arms (sqlmock). +// - Detail: invalid-uuid 400, db_failed (brokenDB), the razorpay-sub-present +// branch, the users/resources/audit query-failed arms (brokenDB), and the +// audit-rows-present + metadata branch. +// - ChangeTier: invalid-uuid 400, invalid-body 400, team-query db_failed +// (brokenDB), update-failed (sqlmock). +// - IssuePromo: invalid-uuid 400, invalid-body 400, amount_off value 400, +// valid_for_days 400, team-query db_failed (brokenDB), insert-failed +// (brokenDB). +// +// All test files in this slice carry the _residual suffix so they never +// collide with the prior slice's files. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/plans" +) + +// adminPostRawJSON POSTs a raw (possibly malformed) JSON string so the +// BodyParser-error arms can be exercised. Distinct from adminDoJSON, which +// always sends well-formed JSON. +func adminPostRawJSON(t *testing.T, app *fiber.App, path, raw string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest("POST", path, strings.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + out := map[string]any{} + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +// adminAppAllRoutes wires every admin-customers route against the supplied DB +// (which may be a brokenDB or sqlmock-backed *sql.DB) so the residual tests can +// drive each handler's DB-failure arm. callerEmail is pinned admin so +// RequireAdmin passes. +func adminAppAllRoutes(t *testing.T, db *sql.DB, callerEmail string) *fiber.App { + t.Helper() + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()}) + }, + }) + fakeAuth := func(c *fiber.Ctx) error { + if callerEmail != "" { + c.Locals(middleware.LocalKeyEmail, callerEmail) + } + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + c.Locals(middleware.LocalKeyTeamID, uuid.NewString()) + return c.Next() + } + adminH := handlers.NewAdminCustomersHandler(db, plans.Default()) + g := app.Group("/api/v1/admin", fakeAuth, middleware.RequireAdmin()) + g.Get("/customers", adminH.List) + g.Get("/customers/:team_id", adminH.Detail) + g.Post("/customers/:team_id/tier", adminH.ChangeTier) + g.Post("/customers/:team_id/promo", adminH.IssuePromo) + return app +} + +// ── NewAdminCustomersHandler default CancelSubscription closure ────────────── + +// TestAdminTierChange_DefaultCancelClosure_StillReturns200 demotes a team +// using the DEFAULT handler (no injected cancelFn). The default +// CancelSubscription returns errBillingNotConfigured, exercising the +// closure body in NewAdminCustomersHandler (lines 137-139) and the +// cancel-failed audit arm. The admin still gets a 200. +func TestAdminTierChange_DefaultCancelClosure_StillReturns200(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + + teamID, _ := adminSeedTeamWithSub(t, db, "pro") + app := adminAppAllRoutes(t, db, adminCallerEmail) // uses default CancelSubscription + + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+teamID.String()+"/tier", + map[string]any{"tier": "hobby", "reason": "default-closure path"}) + require.Equal(t, http.StatusOK, status, "demote must still 200 even when cancel errors: %v", body) + + // The default CancelSubscription returns an error, so the audit row + // records cancel_attempted=true + cancel_succeeded=false. + meta := adminLatestAuditMeta(t, db, teamID, models.AuditKindSubscriptionCanceledByAdmin) + assert.Equal(t, true, meta["cancel_attempted"]) + assert.Equal(t, false, meta["cancel_succeeded"]) + assert.NotEmpty(t, meta["cancel_error"], "default closure error string must be recorded") +} + +// ── List ───────────────────────────────────────────────────────────────────── + +// TestAdminList_SingleTierFilter_ExactMatch hits the len(tiers)==1 branch +// (the single-tier exact-match `t.plan_tier = $N` path, lines 241-247). +func TestAdminList_SingleTierFilter_ExactMatch(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + + hobbyID, _ := adminSeedTeam(t, db, "hobby") + _, _ = adminSeedTeam(t, db, "pro") + + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers?tier=hobby", nil) + require.Equal(t, http.StatusOK, status) + customers, _ := body["customers"].([]any) + found := false + for _, c := range customers { + m, _ := c.(map[string]any) + if m["team_id"] == hobbyID.String() { + found = true + } + assert.Equal(t, "hobby", m["tier"], "single-tier filter must only return hobby teams") + } + assert.True(t, found, "seeded hobby team must appear in tier=hobby filter") +} + +// TestAdminList_QueryFailed_BrokenDB drives the query-failed arm (lines +// 324-328) via a closed DB. +func TestAdminList_QueryFailed_BrokenDB(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, brokenDB(t), adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminList_ScanFailed_Sqlmock drives the scan-failed arm (lines +// 344-349): a row whose first column can't scan into uuid.UUID. +func TestAdminList_ScanFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + // 9 columns in the SELECT; return a non-UUID for the first column so + // Scan into uuid.UUID fails. + cols := []string{"id", "plan_tier", "name", "created_at", "primary_email", + "storage_bytes", "deployments_active", "last_active", "total_count"} + rows := sqlmock.NewRows(cols).AddRow("not-a-uuid", "hobby", "", nil, "", 0, 0, nil, 1) + mock.ExpectQuery(".*").WillReturnRows(rows) + + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminList_RowsErr_Sqlmock drives the rows.Err() arm (lines 370-374) +// by injecting a row-level error after a successful row. +func TestAdminList_RowsErr_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + cols := []string{"id", "plan_tier", "name", "created_at", "primary_email", + "storage_bytes", "deployments_active", "last_active", "total_count"} + rows := sqlmock.NewRows(cols). + AddRow(uuid.New().String(), "hobby", "", nil, "", int64(0), 0, nil, 1). + RowError(0, errors.New("injected row error")) + mock.ExpectQuery(".*").WillReturnRows(rows) + + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// ── Detail ───────────────────────────────────────────────────────────────── + +// TestAdminDetail_InvalidUUID_400 hits lines 439-441. +func TestAdminDetail_InvalidUUID_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/not-a-uuid", nil) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_team_id", body["error"]) +} + +// TestAdminDetail_TeamQueryFailed_BrokenDB hits the db_failed arm (449-450). +func TestAdminDetail_TeamQueryFailed_BrokenDB(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, brokenDB(t), adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+uuid.NewString(), nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminDetail_RazorpaySubAndAuditRows covers the razorpay-sub-present +// branch (464-466) and the audit-rows-present + metadata branch (534-546): +// seed a team with a subscription_id + an audit row carrying metadata. +func TestAdminDetail_RazorpaySubAndAuditRows(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + + teamID, subID := adminSeedTeamWithSub(t, db, "pro") + // Emit an audit row with non-empty metadata so the meta.Valid branch runs. + require.NoError(t, models.InsertAuditEvent(context.Background(), db, models.AuditEvent{ + TeamID: teamID, + Actor: "admin", + Kind: "test.detail", + Summary: "residual detail coverage", + Metadata: []byte(`{"k":"v"}`), + })) + + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+teamID.String(), nil) + require.Equal(t, http.StatusOK, status, "body=%v", body) + cust, _ := body["customer"].(map[string]any) + assert.Equal(t, subID, cust["razorpay_subscription_id"], "subscription_id must surface") + audit, _ := cust["recent_audit"].([]any) + assert.NotEmpty(t, audit, "recent_audit must include the seeded row") +} + +// ── ChangeTier ─────────────────────────────────────────────────────────────── + +// TestAdminTierChange_InvalidUUID_400 hits lines 629-631. +func TestAdminTierChange_InvalidUUID_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/not-a-uuid/tier", + map[string]any{"tier": "pro", "reason": "x"}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_team_id", body["error"]) +} + +// TestAdminTierChange_InvalidBody_400 hits lines 634-636 (BodyParser error). +func TestAdminTierChange_InvalidBody_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminPostRawJSON(t, app, "/api/v1/admin/customers/"+uuid.NewString()+"/tier", `{bad json`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_body", body["error"]) +} + +// TestAdminTierChange_TeamQueryFailed_BrokenDB hits the db_failed arm +// (654-655): a valid body but a broken DB on GetTeamByID. +func TestAdminTierChange_TeamQueryFailed_BrokenDB(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, brokenDB(t), adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+uuid.NewString()+"/tier", + map[string]any{"tier": "pro", "reason": "valid reason"}) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminTierChange_UpdateFailed_Sqlmock hits the UpdatePlanTier-failed arm +// (663-666): GetTeamByID succeeds (mocked) on a different tier, then +// UpdatePlanTier errors. +func TestAdminTierChange_UpdateFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + tid := uuid.New() + // GetTeamByID selects 6 columns: id, name, plan_tier, + // stripe_customer_id, created_at, default_deployment_ttl_policy. + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "plan_tier", + "stripe_customer_id", "created_at", "default_deployment_ttl_policy"}). + AddRow(tid, "", "hobby", nil, time.Now(), "auto_24h")) + // UpdatePlanTier — fail. + mock.ExpectExec(`UPDATE teams SET plan_tier`). + WillReturnError(errors.New("update boom")) + + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/tier", + map[string]any{"tier": "pro", "reason": "valid reason"}) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// ── IssuePromo ─────────────────────────────────────────────────────────────── + +// TestAdminIssuePromo_InvalidUUID_400 hits 829-831. +func TestAdminIssuePromo_InvalidUUID_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/not-a-uuid/promo", + map[string]any{"kind": "first_month_free", "valid_for_days": 30}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_team_id", body["error"]) +} + +// TestAdminIssuePromo_InvalidBody_400 hits 834-836. +func TestAdminIssuePromo_InvalidBody_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminPostRawJSON(t, app, "/api/v1/admin/customers/"+uuid.NewString()+"/promo", `{bad`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_body", body["error"]) +} + +// TestAdminIssuePromo_ValidForDays_400 hits 843-846. +func TestAdminIssuePromo_ValidForDays_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+uuid.NewString()+"/promo", + map[string]any{"kind": "first_month_free", "valid_for_days": 0}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_valid_for_days", body["error"]) +} + +// TestAdminIssuePromo_AmountOffValue_400 hits 851-854. +func TestAdminIssuePromo_AmountOffValue_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+uuid.NewString()+"/promo", + map[string]any{"kind": "amount_off", "value": 0, "valid_for_days": 30}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_value", body["error"]) +} + +// TestAdminIssuePromo_TeamQueryFailed_BrokenDB hits the db_failed arm at 861. +func TestAdminIssuePromo_TeamQueryFailed_BrokenDB(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, brokenDB(t), adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+uuid.NewString()+"/promo", + map[string]any{"kind": "first_month_free", "valid_for_days": 30}) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminIssuePromo_InsertFailed_Sqlmock hits the insert db_failed arm +// (879-880): team lookup succeeds (mocked), promo insert errors with a +// non-validation error. +func TestAdminIssuePromo_InsertFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "plan_tier", + "stripe_customer_id", "created_at", "default_deployment_ttl_policy"}). + AddRow(tid, "", "hobby", nil, time.Now(), "auto_24h")) + // IssueAdminPromoCode runs a QueryRow INSERT...RETURNING — fail it with a + // generic (non-unique) error so the handler's db_failed arm runs. + mock.ExpectQuery(`INSERT INTO admin_promo_codes`). + WillReturnError(errors.New("insert boom")) + + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/promo", + map[string]any{"kind": "first_month_free", "valid_for_days": 30}) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminTierChange_UnknownTeam_404 hits the team_not_found arm (651-653): +// a valid tier+reason body but a team id that doesn't exist. +func TestAdminTierChange_UnknownTeam_404(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+uuid.NewString()+"/tier", + map[string]any{"tier": "pro", "reason": "valid reason"}) + assert.Equal(t, http.StatusNotFound, status) + assert.Equal(t, "team_not_found", body["error"]) +} + +// TestAdminIssuePromo_ModelRejectsValue_400 hits the model-validation +// sentinel arm (874-878): first_month_free passes handler validation +// (value isn't range-checked for that kind) but a negative value makes the +// model return ErrInvalidPromoValue → 400 invalid_promo. +func TestAdminIssuePromo_ModelRejectsValue_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := adminAppAllRoutes(t, db, adminCallerEmail) + teamID, _ := adminSeedTeam(t, db, "hobby") + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+teamID.String()+"/promo", + map[string]any{"kind": "first_month_free", "value": -5, "valid_for_days": 30}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_promo", body["error"]) +} + +// adminTeamSelectCols / adminTeamRow build a GetTeamByID-shaped mocked row. +func adminTeamRow(tid uuid.UUID, tier string) *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "name", "plan_tier", + "stripe_customer_id", "created_at", "default_deployment_ttl_policy"}). + AddRow(tid, "", tier, nil, time.Now(), "auto_24h") +} + +// TestAdminTierChange_PromoteElevateFailures_StillReturns200 drives the +// best-effort elevate-failed WARN arms on a promote (681-689). GetTeamByID +// returns hobby, UpdatePlanTier succeeds, then each Elevate* call errors — +// the handler logs at WARN and still returns 200. +func TestAdminTierChange_PromoteElevateFailures_StillReturns200(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + mock.ExpectExec(`UPDATE teams SET plan_tier`).WillReturnResult(sqlmock.NewResult(0, 1)) + // All three Elevate* calls fail — best-effort, must not change the 200. + mock.ExpectExec(`UPDATE resources`).WillReturnError(errors.New("elev res boom")) + mock.ExpectExec(`UPDATE deployments`).WillReturnError(errors.New("elev dep boom")) + mock.ExpectExec(`UPDATE stacks`).WillReturnError(errors.New("elev stk boom")) + // Audit insert — accept either Exec or Query shape. + mock.ExpectExec(`INSERT INTO audit_log`).WillReturnResult(sqlmock.NewResult(0, 1)) + + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/tier", + map[string]any{"tier": "pro", "reason": "promote with failing elevates"}) + assert.Equal(t, http.StatusOK, status, "promote must still 200 even when elevates fail: %v", body) + assert.Equal(t, "pro", body["to"]) +} + +// TestAdminDetail_UsersScanFailed_Sqlmock drives the Detail users-scan-failed +// arm (483-486): GetTeamByID succeeds, then the users query returns a row +// whose id column can't scan into uuid.UUID. +func TestAdminDetail_UsersScanFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + // users query — bad uuid in first column. + mock.ExpectQuery(`FROM users`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"id", "email", "role", "created_at"}). + AddRow("not-a-uuid", "u@x.com", "member", time.Now())) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+tid.String(), nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminDetail_UsersQueryFailed_Sqlmock drives the users-query-failed arm +// (476-479): GetTeamByID succeeds, the users query itself errors. +func TestAdminDetail_UsersQueryFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + mock.ExpectQuery(`FROM users`).WithArgs(tid).WillReturnError(errors.New("users boom")) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+tid.String(), nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminDetail_ResourcesQueryFailed_Sqlmock drives the resources-query +// arm (500-503): team + users succeed, resources query errors. +func TestAdminDetail_ResourcesQueryFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + mock.ExpectQuery(`FROM users`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"id", "email", "role", "created_at"})) // empty + mock.ExpectQuery(`FROM resources`).WithArgs(tid).WillReturnError(errors.New("res boom")) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+tid.String(), nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminDetail_ResourcesScanFailed_Sqlmock drives the resources-scan arm +// (506-509): a resources row whose count column can't scan into int. +func TestAdminDetail_ResourcesScanFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + mock.ExpectQuery(`FROM users`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"id", "email", "role", "created_at"})) + mock.ExpectQuery(`FROM resources`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"resource_type", "count", "storage_bytes"}). + AddRow("redis", "not-an-int", 0)) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+tid.String(), nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestAdminDetail_AuditScanFailed_Sqlmock drives the audit-scan arm +// (538-541): team+users+resources+deploycount succeed, audit row's id +// column can't scan into uuid.UUID. +func TestAdminDetail_AuditScanFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + mock.ExpectQuery(`FROM users`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"id", "email", "role", "created_at"})) + mock.ExpectQuery(`FROM resources`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"resource_type", "count", "storage_bytes"})) + // CountActiveDeploymentsByTeam — return a count. + mock.ExpectQuery(`FROM deployments`).WithArgs(tid). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + // audit query — bad uuid id. + mock.ExpectQuery(`FROM audit_log`).WithArgs(tid, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id", "actor", "kind", "summary", "metadata", "created_at"}). + AddRow("not-a-uuid", "admin", "k", "s", nil, time.Now())) + app := adminAppAllRoutes(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/customers/"+tid.String(), nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} diff --git a/internal/handlers/admin_impersonate.go b/internal/handlers/admin_impersonate.go index f17b1dc..fe7b3ec 100644 --- a/internal/handlers/admin_impersonate.go +++ b/internal/handlers/admin_impersonate.go @@ -181,7 +181,7 @@ func (h *AdminImpersonateHandler) Impersonate(c *fiber.Ctx) error { }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString([]byte(h.cfg.JWTSecret)) + signed, err := signImpersonationToken(token, []byte(h.cfg.JWTSecret)) if err != nil { slog.Error("admin.impersonate.sign_failed", "error", err, "team_id", teamID) return respondError(c, fiber.StatusServiceUnavailable, "sign_failed", "Failed to mint impersonation token") @@ -218,6 +218,15 @@ func (h *AdminImpersonateHandler) Impersonate(c *fiber.Ctx) error { }) } +// signImpersonationToken signs the minted JWT. It is a package-level var so a +// test can swap in a failing signer to exercise the sign_failed (503) branch — +// HS256 signing with a []byte key essentially never fails in production, so a +// seam is the only way to cover that defensive arm without relying on a +// non-deterministic crypto failure. +var signImpersonationToken = func(t *jwt.Token, key []byte) (string, error) { + return t.SignedString(key) +} + // errImpersonateNoUsers is returned by resolveTargetUser when the target // team has zero users on file. Surfaces as a 409 — an empty team is // technically a valid team row but isn't useful to impersonate (every diff --git a/internal/handlers/admin_impersonate_residual_test.go b/internal/handlers/admin_impersonate_residual_test.go new file mode 100644 index 0000000..0860158 --- /dev/null +++ b/internal/handlers/admin_impersonate_residual_test.go @@ -0,0 +1,135 @@ +package handlers_test + +// admin_impersonate_residual_test.go — residual coverage for +// admin_impersonate.go (83.3% → ≥95%). Targets: +// +// - resolveTargetUser non-NoRows error → 503 db_failed (lines 155-156, 256). +// - signImpersonationToken failure → 503 sign_failed (185-188), via the +// SetSignImpersonationTokenForTest seam. +// - audit-insert failure → still 200 (best-effort, 209-211), via sqlmock. + +import ( + "database/sql" + "errors" + "net/http" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" + "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/middleware" + "instant.dev/internal/testhelpers" +) + +// impersonateAppWithDB wires the impersonate route against an arbitrary DB +// (e.g. sqlmock-backed) behind the fake-auth + RequireAdmin chain. +func impersonateAppWithDB(t *testing.T, db *sql.DB, callerEmail string) *fiber.App { + t.Helper() + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()}) + }, + }) + cfg := &config.Config{JWTSecret: testhelpers.TestJWTSecret} + fakeAuth := func(c *fiber.Ctx) error { + if callerEmail != "" { + c.Locals(middleware.LocalKeyEmail, callerEmail) + } + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + c.Locals(middleware.LocalKeyTeamID, uuid.NewString()) + return c.Next() + } + impH := handlers.NewAdminImpersonateHandler(db, cfg) + g := app.Group("/api/v1/admin", fakeAuth, middleware.RequireAdmin()) + g.Post("/customers/:team_id/impersonate", impH.Impersonate) + return app +} + +// impTeamRow mirrors GetTeamByID's 6-column SELECT. +func impTeamRow(tid uuid.UUID) *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "name", "plan_tier", + "stripe_customer_id", "created_at", "default_deployment_ttl_policy"}). + AddRow(tid, "", "pro", nil, time.Now(), "auto_24h") +} + +// impUserRow mirrors resolveTargetUser's SELECT id,email. +func impUserRow(uid uuid.UUID, email string) *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "email"}).AddRow(uid, email) +} + +// TestImpersonate_ResolveUserDBError_503 drives the resolveTargetUser +// non-NoRows error arm (155-156 + 256). GetTeamByID succeeds; the user +// lookup errors with a generic DB error → 503 db_failed. +func TestImpersonate_ResolveUserDBError_503(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(impTeamRow(tid)) + mock.ExpectQuery(`FROM users`).WithArgs(tid).WillReturnError(errors.New("users boom")) + + app := impersonateAppWithDB(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/impersonate", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestImpersonate_SignFailed_503 drives the signImpersonationToken-failed arm +// (185-188) via the seam. GetTeamByID + resolveTargetUser succeed (sqlmock), +// then the swapped signer returns an error → 503 sign_failed. +func TestImpersonate_SignFailed_503(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + restore := handlers.SetSignImpersonationTokenForTest( + func(*jwt.Token, []byte) (string, error) { return "", errors.New("sign boom") }) + defer restore() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + uid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(impTeamRow(tid)) + mock.ExpectQuery(`FROM users`).WithArgs(tid).WillReturnRows(impUserRow(uid, "u@x.com")) + + app := impersonateAppWithDB(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/impersonate", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "sign_failed", body["error"]) +} + +// TestImpersonate_AuditInsertFails_StillReturns200 drives the +// audit_insert_failed best-effort arm (209-211). Team + user + sign succeed; +// the audit INSERT errors. The admin still gets a 200 with a token. +func TestImpersonate_AuditInsertFails_StillReturns200(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + tid := uuid.New() + uid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(impTeamRow(tid)) + mock.ExpectQuery(`FROM users`).WithArgs(tid).WillReturnRows(impUserRow(uid, "u@x.com")) + // InsertAuditEvent uses ExecContext — error so the warn arm runs; the + // response is still 200. + mock.ExpectExec(`INSERT INTO audit_log`).WillReturnError(errors.New("audit boom")) + + app := impersonateAppWithDB(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/impersonate", nil) + require.Equal(t, http.StatusOK, status, "body=%v", body) + assert.NotEmpty(t, body["token"], "token must be minted even when audit insert fails") +} diff --git a/internal/handlers/admin_promos_audit_residual_test.go b/internal/handlers/admin_promos_audit_residual_test.go new file mode 100644 index 0000000..4dd5189 --- /dev/null +++ b/internal/handlers/admin_promos_audit_residual_test.go @@ -0,0 +1,123 @@ +package handlers_test + +// admin_promos_audit_residual_test.go — residual coverage for +// admin_promos_audit.go (86.7% → ≥95%) and admin_customer_notes.go +// (93.5% → ≥95%). Targets: +// +// - Audit invalid_since → 400 (lines 141-144). +// - Audit query_failed → 503 (167-171), via brokenDB. +// - Stats compute closure error + handler db_failed (262-264, 272-276), +// via brokenDB + nil cache (fall-through to live compute). +// - CreateNote create_failed → 503 (170-171), via brokenDB. + +import ( + "database/sql" + "errors" + "net/http" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" +) + +// sqlmockNewRegexp constructs a regexp-matcher sqlmock DB. Shared by the +// residual tests that need GetTeamByID-then-INSERT sequences. +func sqlmockNewRegexp(t *testing.T) (*sql.DB, sqlmock.Sqlmock, error) { + t.Helper() + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + return db, mock, err +} + +// TestPromoAudit_InvalidSince_400 hits the invalid-since arm (141-144). +func TestPromoAudit_InvalidSince_400(t *testing.T) { + db, cleanup := adminAppNeedsDB(t) + defer cleanup() + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := promoAuditApp(t, db, nil, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/promos/audit?since=not-a-date", nil) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_since", body["error"]) +} + +// TestPromoAudit_QueryFailed_BrokenDB hits the query_failed arm (167-171). +func TestPromoAudit_QueryFailed_BrokenDB(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := promoAuditApp(t, brokenDB(t), nil, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/promos/audit", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// TestPromoStats_ComputeFailed_BrokenDB hits the Stats compute-failed closure +// (262-264) and handler db_failed arm (272-276). nil rdb means the cache +// helper falls through to a live compute, which errors on the closed DB. +func TestPromoStats_ComputeFailed_BrokenDB(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + app := promoAuditApp(t, brokenDB(t), nil, adminCallerEmail) + status, body := adminDoJSON(t, app, "GET", "/api/v1/admin/promos/stats", nil) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} + +// ── admin_customer_notes.go ────────────────────────────────────────────────── + +// notesAppWithDB wires CreateNote against an arbitrary DB so the +// create_failed arm can be driven with a brokenDB. (The team-exists check +// runs first; on a brokenDB GetTeamByID itself fails with db_failed, which +// covers the team-query arm — to reach the CreateAdminCustomerNote-failed arm +// at 170-171 we need GetTeamByID to succeed but the INSERT to fail, so we +// seed a real team in a live DB then close the DB mid-flight is impossible; +// instead we use sqlmock: team lookup OK, note INSERT errors.) +func notesAppWithDB(t *testing.T, db *sql.DB, callerEmail string) *fiber.App { + t.Helper() + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()}) + }, + }) + fakeAuth := func(c *fiber.Ctx) error { + if callerEmail != "" { + c.Locals(middleware.LocalKeyEmail, callerEmail) + } + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + c.Locals(middleware.LocalKeyTeamID, uuid.NewString()) + return c.Next() + } + h := handlers.NewAdminCustomerNotesHandler(db) + g := app.Group("/api/v1/admin", fakeAuth, middleware.RequireAdmin()) + g.Post("/customers/:team_id/notes", h.CreateNote) + return app +} + +// TestAdminNotes_CreateFailed_Sqlmock hits the create_failed arm (170-171): +// GetTeamByID succeeds, the note INSERT errors with a non-validation error. +func TestAdminNotes_CreateFailed_Sqlmock(t *testing.T) { + t.Setenv("ADMIN_EMAILS", adminCallerEmail) + db, mock, err := sqlmockNewRegexp(t) + defer db.Close() + tid := uuid.New() + mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby")) + // CreateAdminCustomerNote uses a QueryRow INSERT...RETURNING — generic error. + mock.ExpectQuery(`INSERT INTO admin_customer_notes`).WillReturnError(errors.New("note boom")) + _ = err + + app := notesAppWithDB(t, db, adminCallerEmail) + status, body := adminDoJSON(t, app, "POST", "/api/v1/admin/customers/"+tid.String()+"/notes", + map[string]any{"body": "a real note"}) + assert.Equal(t, http.StatusServiceUnavailable, status) + assert.Equal(t, "db_failed", body["error"]) +} diff --git a/internal/handlers/billing_residual_test.go b/internal/handlers/billing_residual_test.go new file mode 100644 index 0000000..0b7b0bc --- /dev/null +++ b/internal/handlers/billing_residual_test.go @@ -0,0 +1,232 @@ +package handlers_test + +// billing_residual_test.go — residual coverage for billing.go (93.1% → ≥95%). +// Targets the cleanly-reachable ChangePlanAPI validation + Razorpay-error arms +// that the prior slice left uncovered. All use billingAppNoAuth (pins team_id +// in Locals) + a live DB seeded with a verified user so the requireVerifiedEmail +// gate passes. + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/email" + "instant.dev/internal/handlers" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// changePlanPost posts a change-plan body and returns (status, parsed). +func changePlanPost(t *testing.T, app *fiber.App, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v1/billing/change-plan", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 10000) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + out := map[string]any{} + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +// TestResidualChangePlan_MissingTarget_400 hits missing_target_plan. +func TestResidualChangePlan_MissingTarget_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "hobby") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":""}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "missing_target_plan", body["error"]) +} + +// TestResidualChangePlan_Yearly_400 hits yearly_change_plan_unsupported. +func TestResidualChangePlan_Yearly_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "hobby") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"pro","plan_frequency":"yearly"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "yearly_change_plan_unsupported", body["error"]) +} + +// TestResidualChangePlan_InvalidFrequency_400 hits invalid_frequency. +func TestResidualChangePlan_InvalidFrequency_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "hobby") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"pro","plan_frequency":"weekly"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_frequency", body["error"]) +} + +// TestResidualChangePlan_SamePlan_400 hits same_plan (target == current tier). +func TestResidualChangePlan_SamePlan_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "pro") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"pro"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "same_plan", body["error"]) +} + +// TestResidualChangePlan_Downgrade_400 hits downgrade_not_self_serve +// (pro → hobby is a downgrade). +func TestResidualChangePlan_Downgrade_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "pro") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret", + RazorpayPlanIDHobby: "plan_hobby_test", RazorpayPlanIDPro: "plan_pro_test"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"hobby"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "downgrade_not_self_serve", body["error"]) +} + +// TestResidualChangePlan_TeamTier_400 hits tier_unavailable (team is dev-locked). +func TestResidualChangePlan_TeamTier_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "pro") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret", + RazorpayPlanIDTeam: "plan_team_test"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"team"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "tier_unavailable", body["error"]) +} + +// TestResidualChangePlan_NoSubscription_400 hits no_subscription: a valid +// upgrade target but the team has no Razorpay subscription_id on file. +func TestResidualChangePlan_NoSubscription_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "hobby") + cfg := &config.Config{RazorpayKeyID: "rzp_test", RazorpayKeySecret: "rzp_secret", + RazorpayPlanIDPro: "plan_pro_test", RazorpayPlanIDHobby: "plan_hobby_test"} + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"pro"}`) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "no_subscription", body["error"]) +} + +// TestResidualPaymentFailed_NoPrimaryUser_DropsEmail drives the +// primary_user_lookup_failed arm (2062-2071): a payment.failed event whose +// subscription resolves to a team that has NO users → dunning email dropped, +// webhook still 200s. Uses the cov2 webhook harness. +func TestResidualPaymentFailed_NoPrimaryUser_DropsEmail(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app, _ := cov2WebhookAppReal(t, db, email.NewNoop()) + + // Team with NO users on file. + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + subID := "sub_" + uuid.NewString() + require.NoError(t, models.UpdateRazorpaySubscriptionID(context.Background(), db, + uuid.MustParse(teamID), subID)) + + subEntity, _ := json.Marshal(map[string]any{ + "id": subID, "entity": "subscription", "notes": map[string]any{"team_id": teamID}, + }) + payEntity, _ := json.Marshal(map[string]any{ + "id": "pay_" + uuid.NewString(), "entity": "payment", + "amount": 490000, "currency": "INR", "attempt_count": 1, + "subscription_id": subID, + }) + event := map[string]any{ + "entity": "event", "id": "evt_" + uuid.NewString(), "event": "payment.failed", + "payload": map[string]any{ + "payment": map[string]any{"entity": json.RawMessage(payEntity)}, + "subscription": map[string]any{"entity": json.RawMessage(subEntity)}, + }, + } + b, _ := json.Marshal(event) + code, _ := cov2Run(t, app, b) + assert.Equal(t, http.StatusOK, code, "payment.failed with no primary user must still 200 (email dropped)") +} + +// TestResidualBuildPaymentMethod_Nil drives buildPaymentMethod's nil-input arm +// (2780-2782): returns nil when no SubscriptionDetails is present. +func TestResidualBuildPaymentMethod_Nil(t *testing.T) { + assert.Nil(t, handlers.BuildPaymentMethodForTest()) +} + +// TestResidualChargedReceipt_NilEmail drives sendPaymentReceipt's nil-email +// early-return (3131-3133): a charged event processed by a handler whose +// emailer is nil. The webhook still 200s. +func TestResidualChargedReceipt_NilEmail(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app, cfg := cov2WebhookAppReal(t, db, nil) // nil mailer + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + teamUUID := uuid.MustParse(teamID) + u, err := models.CreateUser(context.Background(), db, teamUUID, testhelpers.UniqueEmail(t), "", "", "owner") + require.NoError(t, err) + defer db.Exec(`DELETE FROM users WHERE id = $1`, u.ID) + defer db.Exec(`DELETE FROM email_send_dedup WHERE 1=1`) + + paid := 1 + payload := cov2SubEvent(t, "subscription.charged", teamID, "sub_"+uuid.NewString(), + cfg.RazorpayPlanIDPro, "active", &paid, 490000) + code, _ := cov2Run(t, app, payload) + assert.Equal(t, http.StatusOK, code) +} + +// TestResidualChangePlan_RazorpayError_502 drives the ChangePlan-failed arm +// (2980-2981): a valid upgrade (hobby→pro) on a team WITH a subscription_id but +// garbage Razorpay creds → portal.ChangePlan errors (non-circuit) → 502. +func TestResidualChangePlan_RazorpayError_502(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := mkVerifiedTeam(t, db, "hobby") + require.NoError(t, models.UpdateRazorpaySubscriptionID(context.Background(), db, + uuid.MustParse(teamID), "sub_"+uuid.NewString())) + cfg := &config.Config{ + RazorpayKeyID: "rzp_test_garbage", RazorpayKeySecret: "garbage_secret", + RazorpayPlanIDHobby: "plan_hobby_test", RazorpayPlanIDPro: "plan_pro_test", + } + app := billingAppNoAuth(t, db, cfg, teamID) + status, body := changePlanPost(t, app, `{"target_plan":"pro"}`) + // Razorpay call fails (bad creds) → 502 razorpay_error (or 503 if the + // circuit opened first — both are the failure surface we want). + assert.Contains(t, []int{http.StatusBadGateway, http.StatusServiceUnavailable}, status, + "change-plan against bad Razorpay creds must surface a 5xx: %v", body) +} + +// mkVerifiedTeam creates a team at planTier with a verified owner user. +func mkVerifiedTeam(t *testing.T, db *sql.DB, planTier string) string { + t.Helper() + teamID := testhelpers.MustCreateTeamDB(t, db, planTier) + u, err := models.CreateUser(context.Background(), db, uuid.MustParse(teamID), + testhelpers.UniqueEmail(t), "", "", "owner") + require.NoError(t, err) + require.NoError(t, models.SetEmailVerified(context.Background(), db, u.ID)) + t.Cleanup(func() { + db.Exec(`DELETE FROM users WHERE team_id = $1::uuid`, teamID) + db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + }) + return teamID +} diff --git a/internal/handlers/export_residual_test.go b/internal/handlers/export_residual_test.go new file mode 100644 index 0000000..8d88c25 --- /dev/null +++ b/internal/handlers/export_residual_test.go @@ -0,0 +1,43 @@ +package handlers + +// export_residual_test.go — re-exports of unexported symbols for the +// residual-coverage slice (the files below 95% after the prior slice). Kept +// separate from export_test.go / export_rbw_test.go / export_billing_test.go +// so it never collides with concurrent slices. A duplicate re-export is a +// compile error, so every symbol here was confirmed absent from the three +// pre-existing export files before being added. + +import ( + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" +) + +// ── billing.go pure-helper export ── +// +// BuildPaymentMethodForTest re-exports buildPaymentMethod so the nil-input arm +// (returns nil) can be asserted directly without a live Razorpay subscription. +func BuildPaymentMethodForTest() fiber.Map { + return buildPaymentMethod(nil) +} + +// ── admin_impersonate.go seam ── +// +// SetSignImpersonationTokenForTest swaps the package-level JWT signer used by +// AdminImpersonateHandler.Impersonate so a test can drive the sign_failed +// (503) branch. Returns a restore func the caller defers. +func SetSignImpersonationTokenForTest(fn func(*jwt.Token, []byte) (string, error)) (restore func()) { + prev := signImpersonationToken + signImpersonationToken = fn + return func() { signImpersonationToken = prev } +} + +// ── webhook.go crypto seam ── +// +// SetWebhookCryptoEncryptForTest swaps the package-level crypto.Encrypt +// indirection used by WebhookHandler.storeEncryptedURL so a test can drive the +// encrypt-failed branch. Returns a restore func. +func SetWebhookCryptoEncryptForTest(fn func(key []byte, plaintext string) (string, error)) (restore func()) { + prev := cryptoEncrypt + cryptoEncrypt = fn + return func() { cryptoEncrypt = prev } +} diff --git a/internal/handlers/onboarding_residual_test.go b/internal/handlers/onboarding_residual_test.go new file mode 100644 index 0000000..63901a5 --- /dev/null +++ b/internal/handlers/onboarding_residual_test.go @@ -0,0 +1,445 @@ +package handlers_test + +// onboarding_residual_test.go — residual coverage for onboarding.go +// (81.5% → ≥95%). Targets the branches the prior slice left uncovered: +// +// StartLanding: missing_token, invalid_token, JTI-not-found, db_error +// (brokenDB), already-claimed redirect. +// ClaimPreview: db_error (brokenDB), unparseable/looked-up-miss token in +// claims.Tokens (continue arms), fingerprint-lookup warn. +// Claim: jti_lookup_failed (brokenDB), mark_converted already-used +// race (pre-converted row), happy-path with claimable +// resources + fingerprint augmentation. +// +// All onboarding JWTs are minted in-process with the test secret (same +// pattern as onboarding_coverage_test.go). + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/crypto" + "instant.dev/internal/email" + "instant.dev/internal/handlers" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// onboardingResidualApp registers /start, /claim, and /claim/preview against +// an arbitrary DB so the db-error arms can be driven with a brokenDB. +func onboardingResidualApp(t *testing.T, db *sql.DB) *fiber.App { + t.Helper() + cfg := &config.Config{ + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + DashboardBaseURL: "http://localhost:5173", + } + h := handlers.NewOnboardingHandler(db, cfg, email.NewNoop()) + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Get("/start", h.StartLanding) + app.Post("/claim", h.Claim) + app.Get("/claim/preview", h.ClaimPreview) + return app +} + +// mintOnboardingJWT signs an OnboardingClaims with the test secret. +func mintOnboardingJWT(t *testing.T, jti, fp string, tokens []string) string { + t.Helper() + claims := crypto.OnboardingClaims{ + Fingerprint: fp, + Tokens: tokens, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := tok.SignedString([]byte(testhelpers.TestJWTSecret)) + require.NoError(t, err) + return signed +} + +func doGet(t *testing.T, app *fiber.App, path string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +// ── StartLanding ───────────────────────────────────────────────────────────── + +func TestResidualStartLanding_MissingToken_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + resp := doGet(t, app, "/start") + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestResidualStartLanding_InvalidJWT_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + resp := doGet(t, app, "/start?t=garbage") + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestResidualStartLanding_UnknownJTI_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + signed := mintOnboardingJWT(t, uuid.NewString(), "fp-start-unknown", nil) + resp := doGet(t, app, "/start?t="+signed) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// TestStartLanding_DBError_503 drives the db_error arm (66-67) via a brokenDB: +// JWT verifies in-process, then GetOnboardingByJTI errors with a non-notfound +// error → 503 lookup_failed. +func TestResidualStartLanding_DBError_503(t *testing.T) { + app := onboardingResidualApp(t, brokenDB(t)) + signed := mintOnboardingJWT(t, uuid.NewString(), "fp-start-broken", nil) + resp := doGet(t, app, "/start?t="+signed) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestStartLanding_AlreadyClaimed_Redirects drives the converted-redirect arm +// (70-72): a converted onboarding row → 302 to the dashboard with the flag. +func TestResidualStartLanding_AlreadyClaimed_Redirects(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + jti := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO onboarding_events (jti, fingerprint, converted_at, team_id) + VALUES ($1, $2, now(), NULL) + `, jti, "fp-start-claimed") + require.NoError(t, err) + signed := mintOnboardingJWT(t, jti, "fp-start-claimed", nil) + resp := doGet(t, app, "/start?t="+signed) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Location"), "already_claimed=true") +} + +// TestStartLanding_HappyPath_Redirects drives the success redirect (74-76). +func TestResidualStartLanding_HappyPath_Redirects(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + jti := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO onboarding_events (jti, fingerprint, team_id) + VALUES ($1, $2, NULL) + `, jti, "fp-start-ok") + require.NoError(t, err) + signed := mintOnboardingJWT(t, jti, "fp-start-ok", nil) + resp := doGet(t, app, "/start?t="+signed) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Location"), "/claim?t=") +} + +// ── ClaimPreview ───────────────────────────────────────────────────────────── + +// TestClaimPreview_DBError_503 drives the preview db_error arm (109-110). +func TestResidualClaimPreview_DBError_503(t *testing.T) { + app := onboardingResidualApp(t, brokenDB(t)) + signed := mintOnboardingJWT(t, uuid.NewString(), "fp-prev-broken", nil) + resp := doGet(t, app, "/claim/preview?t="+signed) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestClaimPreview_BadAndMissingTokensInClaims drives the per-token continue +// arms (128-134): one unparseable token + one well-formed-but-unknown token +// in claims.Tokens. Both are skipped; the response still 200s. +func TestResidualClaimPreview_BadAndMissingTokensInClaims(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + jti := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO onboarding_events (jti, fingerprint, team_id) + VALUES ($1, $2, NULL) + `, jti, "fp-prev-tokens") + require.NoError(t, err) + // one non-UUID token (parse continue) + one valid-but-missing UUID + // (lookup continue). + signed := mintOnboardingJWT(t, jti, "fp-prev-tokens", + []string{"not-a-uuid", uuid.NewString()}) + resp := doGet(t, app, "/claim/preview?t="+signed) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestResidualIsValidEmail_EdgeArms drives isValidEmail's domain-shape reject +// arms (676-682) via the exported helper: dotless domain, trailing-dot domain, +// leading-dot domain, and the valid baseline. Pure function — deterministic. +func TestResidualIsValidEmail_EdgeArms(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"you@example.com", true}, // valid baseline + {"x@localhost", false}, // dotless domain (676-677) + {"x@example.com.", false}, // trailing-dot domain (680-682) + {"x@.example.com", false}, // leading-dot domain (680-682) + {"you @example.com", false}, // inner whitespace (654-655) + {"", false}, // empty (647-648) + } + for _, c := range cases { + assert.Equal(t, c.want, handlers.IsValidEmailForTest(c.in), "isValidEmail(%q)", c.in) + } +} + +// TestResidualMaskEmailForLog drives maskEmailForLog's branches via the +// exported helper. +func TestResidualMaskEmailForLog(t *testing.T) { + assert.NotEmpty(t, handlers.MaskEmailForLogForTest("alice@example.com")) + assert.NotPanics(t, func() { _ = handlers.MaskEmailForLogForTest("no-at-sign") }) + assert.NotPanics(t, func() { _ = handlers.MaskEmailForLogForTest("") }) +} + +// TestResidualClaimPreview_FingerprintResources drives the ClaimPreview +// fingerprint-augmentation loop (147-167): a preview whose JWT carries a +// fingerprint with active resources NOT in the token list. +func TestResidualClaimPreview_FingerprintResources(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + ctx := context.Background() + + fp := "fp-prev-aug-" + uuid.NewString()[:8] + jti := uuid.NewString() + _, err := db.ExecContext(ctx, ` + INSERT INTO onboarding_events (jti, fingerprint, team_id) VALUES ($1, $2, NULL) + `, jti, fp) + require.NoError(t, err) + // Two anonymous resources for this fingerprint, NOT in the token list. + for i := 0; i < 2; i++ { + _, err = db.ExecContext(ctx, ` + INSERT INTO resources (token, resource_type, tier, env, status, fingerprint) + VALUES ($1, 'redis', 'anonymous', 'production', 'active', $2) + `, uuid.NewString(), fp) + require.NoError(t, err) + } + signed := mintOnboardingJWT(t, jti, fp, nil) // empty token list → all via fingerprint + resp := doGet(t, app, "/claim/preview?t="+signed) + require.Equal(t, http.StatusOK, resp.StatusCode) + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + res, _ := body["resources"].([]any) + assert.GreaterOrEqual(t, len(res), 2, "fingerprint-augmented resources must appear in preview") +} + +// TestResidualClaim_AccountExists_409 drives the account-takeover-guard arm +// (325-336): an email that already belongs to a registered account → 409 +// account_exists, JWT NOT consumed. +func TestResidualClaim_AccountExists_409(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + ctx := context.Background() + + // Pre-existing account. + existingTeam := testhelpers.MustCreateTeamDB(t, db, "hobby") + existingEmail := testhelpers.UniqueEmail(t) + _, err := models.CreateUser(ctx, db, uuid.MustParse(existingTeam), existingEmail, "", "", "owner") + require.NoError(t, err) + + fp := "fp-claim-exists-" + uuid.NewString()[:8] + jti := uuid.NewString() + _, err = db.ExecContext(ctx, ` + INSERT INTO onboarding_events (jti, fingerprint, team_id) VALUES ($1, $2, NULL) + `, jti, fp) + require.NoError(t, err) + signed := mintOnboardingJWT(t, jti, fp, nil) + + resp := testhelpers.PostJSON(t, app, "/claim", + map[string]any{"token": signed, "email": existingEmail}) + defer resp.Body.Close() + assert.Equal(t, http.StatusConflict, resp.StatusCode) +} + +// ── Claim ──────────────────────────────────────────────────────────────────── + +// TestClaim_JTILookupFailed_503 drives the jti_lookup_failed arm (300-301) +// via a brokenDB: body valid, JWT verifies, GetOnboardingByJTI errors. +func TestResidualClaim_JTILookupFailed_503(t *testing.T) { + app := onboardingResidualApp(t, brokenDB(t)) + signed := mintOnboardingJWT(t, uuid.NewString(), "fp-claim-broken", nil) + resp := testhelpers.PostJSON(t, app, "/claim", + map[string]any{"token": signed, "email": testhelpers.UniqueEmail(t)}) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestClaim_HappyPath_ClaimsResources drives the success path including the +// JWT-listed-token transfer (428-452) and fingerprint augmentation (455-470). +func TestResidualClaim_HappyPath_ClaimsResources(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := onboardingResidualApp(t, db) + ctx := context.Background() + + fp := "fp-claim-happy-" + uuid.NewString()[:8] + jti := uuid.NewString() + _, err := db.ExecContext(ctx, ` + INSERT INTO onboarding_events (jti, fingerprint, team_id) + VALUES ($1, $2, NULL) + `, jti, fp) + require.NoError(t, err) + + // A JWT-listed anonymous resource (no team_id). + listedToken := uuid.NewString() + _, err = db.ExecContext(ctx, ` + INSERT INTO resources (token, resource_type, tier, env, status, fingerprint) + VALUES ($1, 'redis', 'anonymous', 'production', 'active', $2) + `, listedToken, fp) + require.NoError(t, err) + + // A fingerprint-only anonymous resource NOT in the JWT token list. + fpToken := uuid.NewString() + _, err = db.ExecContext(ctx, ` + INSERT INTO resources (token, resource_type, tier, env, status, fingerprint) + VALUES ($1, 'postgres', 'anonymous', 'production', 'active', $2) + `, fpToken, fp) + require.NoError(t, err) + + // An ALREADY-CLAIMED resource in the token list (team_id set) — exercises + // the "already claimed → continue" arm (437-438). + otherTeam := testhelpers.MustCreateTeamDB(t, db, "hobby") + claimedToken := uuid.NewString() + _, err = db.ExecContext(ctx, ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, fingerprint) + VALUES ($1::uuid, $2, 'redis', 'free', 'production', 'active', $3) + `, otherTeam, claimedToken, fp) + require.NoError(t, err) + + // Token list: a bad-UUID (parse-continue 430-431), a well-formed-but- + // missing UUID (fetch-continue 434-435), the already-claimed token + // (already-claimed-continue 437-438), and the real listed token. + signed := mintOnboardingJWT(t, jti, fp, + []string{"not-a-uuid", uuid.NewString(), claimedToken, listedToken}) + email := testhelpers.UniqueEmail(t) + resp := testhelpers.PostJSON(t, app, "/claim", + map[string]any{"token": signed, "email": email}) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // Both resources should now belong to the new team at tier=free. + var listedTier, fpTier string + require.NoError(t, db.QueryRowContext(ctx, + `SELECT tier FROM resources WHERE token = $1`, listedToken).Scan(&listedTier)) + require.NoError(t, db.QueryRowContext(ctx, + `SELECT tier FROM resources WHERE token = $1`, fpToken).Scan(&fpTier)) + assert.Equal(t, "free", listedTier, "JWT-listed resource must be claimed → free") + assert.Equal(t, "free", fpTier, "fingerprint resource must be claimed → free") +} + +// ── Claim create-failure arms (sqlmock mid-sequence) ───────────────────────── +// +// The Claim flow for a brand-new email runs, in order: +// 1. GetOnboardingByJTI (SELECT ... FROM onboarding_events) — must succeed +// 2. GetUserByEmail (SELECT ... FROM users) — ErrNoRows (new) +// 3. MarkOnboardingConvertedPreliminary (UPDATE onboarding_events) — exec +// 4. CreateTeam (INSERT INTO teams ... RETURNING) — query +// 5. CreateUser (INSERT INTO users ... RETURNING) — query +// We mock the prefix that must succeed, then fail the target step. + +// onboardingEventRow builds a GetOnboardingByJTI row (8 cols), unconverted. +func onboardingEventRow(jti string) *sqlmock.Rows { + return sqlmock.NewRows([]string{"id", "fingerprint", "jwt_issued_at", "jwt_expires_at", + "converted_at", "team_id", "resource_tokens", "jti"}). + AddRow(uuid.New(), "fp-x", time.Now(), time.Now().Add(time.Hour), nil, nil, "{}", jti) +} + +func newOnboardingSqlmockApp(t *testing.T) (*fiber.App, sqlmock.Sqlmock, func()) { + t.Helper() + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + return onboardingResidualApp(t, db), mock, func() { db.Close() } +} + +// TestResidualClaim_MarkConvertedFailed_503 drives the mark_converted_failed +// arm (364-369): mark step errors with a non-already-used error. +func TestResidualClaim_MarkConvertedFailed_503(t *testing.T) { + app, mock, done := newOnboardingSqlmockApp(t) + defer done() + jti := uuid.NewString() + mock.ExpectQuery(`FROM onboarding_events`).WithArgs(jti).WillReturnRows(onboardingEventRow(jti)) + mock.ExpectQuery(`FROM users WHERE lower\(email\)`).WillReturnError(sql.ErrNoRows) + mock.ExpectExec(`UPDATE onboarding_events`).WillReturnError(errors.New("mark boom")) + + signed := mintOnboardingJWT(t, jti, "fp-x", nil) + resp := testhelpers.PostJSON(t, app, "/claim", + map[string]any{"token": signed, "email": testhelpers.UniqueEmail(t)}) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualClaim_TeamCreationFailed_503 drives team_creation_failed +// (385-392): mark succeeds, CreateTeam errors. +func TestResidualClaim_TeamCreationFailed_503(t *testing.T) { + app, mock, done := newOnboardingSqlmockApp(t) + defer done() + jti := uuid.NewString() + mock.ExpectQuery(`FROM onboarding_events`).WithArgs(jti).WillReturnRows(onboardingEventRow(jti)) + mock.ExpectQuery(`FROM users WHERE lower\(email\)`).WillReturnError(sql.ErrNoRows) + mock.ExpectExec(`UPDATE onboarding_events`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`INSERT INTO teams`).WillReturnError(errors.New("team boom")) + + signed := mintOnboardingJWT(t, jti, "fp-x", nil) + resp := testhelpers.PostJSON(t, app, "/claim", + map[string]any{"token": signed, "email": testhelpers.UniqueEmail(t)}) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualClaim_UserCreationFailed_503 drives user_creation_failed +// (396-403): mark + team succeed, CreateUser errors. +func TestResidualClaim_UserCreationFailed_503(t *testing.T) { + app, mock, done := newOnboardingSqlmockApp(t) + defer done() + jti := uuid.NewString() + tid := uuid.New() + mock.ExpectQuery(`FROM onboarding_events`).WithArgs(jti).WillReturnRows(onboardingEventRow(jti)) + mock.ExpectQuery(`FROM users WHERE lower\(email\)`).WillReturnError(sql.ErrNoRows) + mock.ExpectExec(`UPDATE onboarding_events`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`INSERT INTO teams`). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "plan_tier", + "stripe_customer_id", "created_at", "default_deployment_ttl_policy"}). + AddRow(tid, "x@example.com", "free", nil, time.Now(), "auto_24h")) + mock.ExpectQuery(`INSERT INTO users`).WillReturnError(errors.New("user boom")) + + signed := mintOnboardingJWT(t, jti, "fp-x", nil) + resp := testhelpers.PostJSON(t, app, "/claim", + map[string]any{"token": signed, "email": testhelpers.UniqueEmail(t)}) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} diff --git a/internal/handlers/resource_residual_test.go b/internal/handlers/resource_residual_test.go new file mode 100644 index 0000000..8cc48c9 --- /dev/null +++ b/internal/handlers/resource_residual_test.go @@ -0,0 +1,532 @@ +package handlers_test + +// resource_residual_test.go — residual coverage for resource.go (90.9% → ≥95%). +// Targets the hard seams the prior slice left uncovered: +// +// Delete: the gRPC-provisioner deprovision arm (266-281) via the bufconn +// fakeProvisioner; the storage deprovision arm (231-265) via a +// MinIO-admin Provider pointed at an unreachable endpoint (the +// Deprovision call errors → warn arm + Backend()==MinIOAdmin audit +// branch); lookup-failed (brokenDB); cross-team 404; soft-delete +// fail (sqlmock mid-call). +// Pause: lookup-failed (brokenDB); already-paused 409; tier-gate +// rejection (non-Pro tier). +// Resume: not-paused 409. + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/crypto" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/plans" + storageprovider "instant.dev/internal/providers/storage" + "instant.dev/internal/testhelpers" +) + +// resourceResidualConfig is a minimal config: no customer/mongo URLs so the +// pause/resume provider helpers take their no-op test arms, AES key set so +// decrypt works. +func resourceResidualConfig() *config.Config { + return &config.Config{ + Environment: "test", + AESKey: testhelpers.TestAESKeyHex, + JWTSecret: testhelpers.TestJWTSecret, + } +} + +// resourceResidualApp wires Get/Delete/Pause/Resume against a ResourceHandler +// built with the supplied db + provisioner + storage provider, behind a +// fake-auth shim that pins the caller's team/user. +func resourceResidualApp(t *testing.T, db *sql.DB, rdb interface{}, h *handlers.ResourceHandler, teamID, userID string) *fiber.App { + t.Helper() + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, teamID) + c.Locals(middleware.LocalKeyUserID, userID) + return c.Next() + }) + app.Delete("/api/v1/resources/:id", h.Delete) + app.Post("/api/v1/resources/:id/pause", h.Pause) + app.Post("/api/v1/resources/:id/resume", h.Resume) + return app +} + +// seedTeamResource inserts a resource owned by teamID at the given type/status. +func seedTeamResource(t *testing.T, db *sql.DB, teamID, resType, status string) string { + t.Helper() + token := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status) + VALUES ($1::uuid, $2, $3, 'pro', 'production', $4) + `, teamID, token, resType, status) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + return token +} + +func resDelete(t *testing.T, app *fiber.App, token string) (*http.Response, func()) { + t.Helper() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/resources/"+token, nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + return resp, func() { resp.Body.Close() } +} + +// TestResidualDelete_ProvisionerArm drives the gRPC-provisioner deprovision arm +// (266-281): a postgres resource + a bufconn provisioner. DeprovisionResource +// is called once. +func TestResidualDelete_ProvisionerArm(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + userID := uuid.NewString() + + fake := &fakeProvisioner{} + prov := newBufconnProvisionerClient(t, fake) + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), prov, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, userID) + + token := seedTeamResource(t, db, teamID, "postgres", "active") + resp, done := resDelete(t, app, token) + defer done() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.GreaterOrEqual(t, fake.deprovisionCount(), 1, "provisioner DeprovisionResource must fire on delete") +} + +// TestResidualDelete_StorageArm drives the storage deprovision arm (231-265): +// a storage resource + a MinIO-admin Provider pointed at an unreachable +// endpoint (Deprovision errors → warn arm). Backend()==MinIOAdmin so the audit +// branch's guard is also evaluated. +func TestResidualDelete_StorageArm(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + userID := uuid.NewString() + + // Constructs fine (no connect); Deprovision against this dead endpoint + // errors, exercising the deprovision-failed warn arm. + sp, err := storageprovider.New("127.0.0.1:19097", "http://127.0.0.1:19097", "minioadmin", "minioadmin", "instant-shared") + require.NoError(t, err) + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, sp) + app := resourceResidualApp(t, db, rdb, h, teamID, userID) + + token := seedTeamResource(t, db, teamID, "storage", "active") + resp, done := resDelete(t, app, token) + defer done() + // Delete fails open on the storage deprovision error → still 200. + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestResidualDelete_LookupFailed_BrokenDB drives the fetch_failed arm +// (205-210) via a brokenDB. +func TestResidualDelete_LookupFailed_BrokenDB(t *testing.T) { + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + h := handlers.NewResourceHandler(brokenDB(t), rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, nil, rdb, h, uuid.NewString(), uuid.NewString()) + resp, done := resDelete(t, app, uuid.NewString()) + defer done() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualDelete_SoftDeleteFailed_Sqlmock drives the delete_failed arm +// (219-225): GetResourceByToken succeeds (mocked, owned by the caller team), +// then SoftDeleteResource errors. +func TestResidualDelete_SoftDeleteFailed_Sqlmock(t *testing.T) { + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + teamID := uuid.New() + token := uuid.New() + // GetResourceByToken — return a resource owned by teamID. The column set + // must match models.GetResourceByToken's SELECT; use a wide row and let + // sqlmock map by position. We mock the minimum: id, team_id, token, + // resource_type, status. To avoid coupling to the exact column list, we + // return an error-free row via a permissive matcher and rely on the + // handler reading TeamID + ResourceType + ID. + mock.ExpectQuery(`FROM resources`).WithArgs(token). + WillReturnRows(resourceRowForDelete(token, teamID)) + mock.ExpectExec(`UPDATE resources SET status`).WillReturnError(errors.New("soft delete boom")) + + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID.String(), uuid.NewString()) + resp, done := resDelete(t, app, token.String()) + defer done() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualPause_AlreadyPaused_409 drives the already-paused arm (581-585). +func TestResidualPause_AlreadyPaused_409(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + + token := seedTeamResource(t, db, teamID, "redis", "paused") + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/pause", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusConflict, resp.StatusCode) +} + +// TestResidualPause_TierGate drives the tier-gate rejection (598-600): a hobby +// team can't pause (Pro+ feature). +func TestResidualPause_TierGate(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + + token := seedTeamResourceTier(t, db, teamID, "redis", "active", "hobby") + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/pause", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + // Pause is Pro+ — a hobby team is rejected (402 upgrade-required). + assert.Equal(t, http.StatusPaymentRequired, resp.StatusCode) +} + +// TestResidualPause_PostgresProviderFailed_503 drives the pauseProvider +// postgres arm (818-826) + revokePostgresConnect validate-error + the Pause +// provider_failed arm (604-613): a postgres resource with CustomerDatabaseURL +// configured but a connection_url that yields an empty username, so +// validateSQLIdent rejects it and the revoke errors. +func TestResidualPause_PostgresProviderFailed_503(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + cfg := resourceResidualConfig() + cfg.CustomerDatabaseURL = "postgres://nouser:nopass@127.0.0.1:5999/none?sslmode=disable" + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + // postgres resource with an empty/garbage connection_url → username extract + // yields "" → validateSQLIdent rejects → revoke errors → provider_failed. + token := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, connection_url) + VALUES ($1::uuid, $2, 'postgres', 'pro', 'production', 'active', '') + `, teamID, token) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/pause", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualPauseResume_Mongo_ProviderArms drives the pauseProvider + +// resumeProvider mongo arms (842-847 / 875-880) + revokeMongoRoles / +// grantMongoRoles against the live test MongoDB. The user doesn't exist, so +// revokeRolesFromUser errors → Pause returns 503 provider_failed; that +// exercises the connect-success + RunCommand-error path of revokeMongoRoles. +func TestResidualPause_Mongo_ProviderArm(t *testing.T) { + if os.Getenv("TEST_MONGO_URI") == "" { + t.Skip("TEST_MONGO_URI not set — skipping mongo provider arm test") + } + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + cfg := resourceResidualConfig() + cfg.MongoAdminURI = os.Getenv("TEST_MONGO_URI") + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + token := seedTeamResource(t, db, teamID, "mongodb", "active") + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/pause", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + // revokeRolesFromUser for a nonexistent user errors → provider_failed 503. + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualPause_LookupFailed_BrokenDB drives the pause fetch_failed arm +// (567-568) via a brokenDB. +func TestResidualPause_LookupFailed_BrokenDB(t *testing.T) { + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + h := handlers.NewResourceHandler(brokenDB(t), rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, nil, rdb, h, uuid.NewString(), uuid.NewString()) + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+uuid.NewString()+"/pause", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualResume_NotPaused_409 drives the resume not-paused arm: resuming +// an active resource is a 409. +func TestResidualResume_NotPaused_409(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + + token := seedTeamResource(t, db, teamID, "redis", "active") + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/resume", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusConflict, resp.StatusCode) +} + +func resourceResidualAppRotate(t *testing.T, db *sql.DB, rdb interface{}, h *handlers.ResourceHandler, teamID, userID string) *fiber.App { + t.Helper() + app := resourceResidualApp(t, db, rdb, h, teamID, userID) + app.Post("/api/v1/resources/:id/rotate-credentials", h.RotateCredentials) + return app +} + +// TestResidualRotate_LookupFailed_BrokenDB drives RotateCredentials +// fetch_failed (399-401) via a brokenDB. +func TestResidualRotate_LookupFailed_BrokenDB(t *testing.T) { + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + h := handlers.NewResourceHandler(brokenDB(t), rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualAppRotate(t, nil, rdb, h, uuid.NewString(), uuid.NewString()) + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+uuid.NewString()+"/rotate-credentials", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualRotate_NoConnectionURL_400 drives the no_connection_url arm +// (410-413): a resource with a NULL connection_url. +func TestResidualRotate_NoConnectionURL_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualAppRotate(t, db, rdb, h, teamID, uuid.NewString()) + token := seedTeamResource(t, db, teamID, "redis", "active") // no connection_url + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/rotate-credentials", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// TestResidualRotate_DecryptFailed_500 drives the decrypt_failed arm +// (425-428): a resource whose connection_url is not valid ciphertext. +func TestResidualRotate_DecryptFailed_500(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualAppRotate(t, db, rdb, h, teamID, uuid.NewString()) + token := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, connection_url) + VALUES ($1::uuid, $2, 'redis', 'pro', 'production', 'active', 'not-valid-ciphertext') + `, teamID, token) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/rotate-credentials", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +// TestResidualResume_LookupFailed_BrokenDB drives the resume fetch_failed arm +// via a brokenDB. +func TestResidualResume_LookupFailed_BrokenDB(t *testing.T) { + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + h := handlers.NewResourceHandler(brokenDB(t), rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, nil, rdb, h, uuid.NewString(), uuid.NewString()) + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+uuid.NewString()+"/resume", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestResidualPause_HappyPath_WithExpiry covers the Pause success path +// (646-679) + resourceToMap's expires_at (1108-1110) + paused_at (1111-1113) +// branches: a pro resource carrying an explicit expires_at is paused. +func TestResidualPause_HappyPath_WithExpiry(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + // queue resource (pauseProvider no-op default arm) with an expires_at set. + token := uuid.NewString() + exp := time.Now().Add(24 * time.Hour) + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, expires_at) + VALUES ($1::uuid, $2, 'queue', 'pro', 'production', 'active', $3) + `, teamID, token, exp) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/pause", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestResidualPause_Redis_ProviderArm drives the pauseProvider redis arm +// (827-841) + setRedisACLEnabled + the Pause provider_failed arm (604-613): a +// redis resource carrying an AES-encrypted connection_url. The URL's own +// (limited) credentials can't run ACL SETUSER, so the toggle errors and Pause +// returns 503 provider_failed — which is exactly the arm we want to exercise. +func TestResidualPause_Redis_ProviderArm(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + + user := "u" + uuid.NewString()[:8] + redisURL := "redis://" + user + ":pw@127.0.0.1:6397/15" + aesKey, err := crypto.ParseAESKey(testhelpers.TestAESKeyHex) + require.NoError(t, err) + enc, err := crypto.Encrypt(aesKey, redisURL) + require.NoError(t, err) + + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + token := uuid.NewString() + _, err = db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, connection_url) + VALUES ($1::uuid, $2, 'redis', 'pro', 'production', 'active', $3) + `, teamID, token, enc) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + + pReq := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/pause", nil) + pResp, err := app.Test(pReq, 10000) + require.NoError(t, err) + defer pResp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, pResp.StatusCode) +} + +// TestResidualResume_HappyPath_200 drives the resume success path (735-760+): +// resuming a paused resource flips it active and 200s. Resume has NO tier gate +// (by design — see resource.go comment) so a hobby team can resume too. +func TestResidualResume_HappyPath_200(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + h := handlers.NewResourceHandler(db, rdb, resourceResidualConfig(), plans.Default(), nil, nil) + app := resourceResidualApp(t, db, rdb, h, teamID, uuid.NewString()) + token := seedTeamResourceTier(t, db, teamID, "redis", "paused", "hobby") + req := httptest.NewRequest(http.MethodPost, "/api/v1/resources/"+token+"/resume", nil) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// seedTeamResourceTier is seedTeamResource with an explicit tier. +func seedTeamResourceTier(t *testing.T, db *sql.DB, teamID, resType, status, tier string) string { + t.Helper() + token := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status) + VALUES ($1::uuid, $2, $3, $4, 'production', $5) + `, teamID, token, resType, tier, status) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + return token +} + +// resourceRowForDelete builds a 26-column sqlmock row matching +// models.resourceColumns / scanResource. The resource is a postgres resource +// owned by teamID with status='active' (so Delete reaches SoftDeleteResource). +func resourceRowForDelete(token, teamID uuid.UUID) *sqlmock.Rows { + cols := []string{ + "id", "team_id", "token", "resource_type", "name", "connection_url", "key_prefix", + "tier", "env", "fingerprint", "cloud_vendor", "country_code", "status", + "migration_status", "expires_at", "storage_bytes", "provider_resource_id", "created_request_id", + "parent_resource_id", "paused_at", + "last_seen_at", "degraded", "degraded_reason", "last_reconciled_at", + "auth_mode", "created_at", + } + return sqlmock.NewRows(cols).AddRow( + uuid.New(), // id + teamID, // team_id + token, // token + "postgres", // resource_type + nil, // name + nil, // connection_url + nil, // key_prefix + "pro", // tier + "production", // env + nil, // fingerprint + nil, // cloud_vendor + nil, // country_code + "active", // status + nil, // migration_status + nil, // expires_at + int64(0), // storage_bytes + nil, // provider_resource_id + nil, // created_request_id + nil, // parent_resource_id + nil, // paused_at + nil, // last_seen_at + false, // degraded + nil, // degraded_reason + nil, // last_reconciled_at + "legacy_open", // auth_mode + time.Now(), // created_at + ) +} diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index bbdfd49..300a439 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -122,6 +122,12 @@ func (h *WebhookHandler) webhookMaxStored(tier string) int64 { return int64(n) } +// cryptoEncrypt is a package-level indirection over crypto.Encrypt so a test +// can drive storeEncryptedURL's encrypt-failed branch. AES-256-GCM encryption +// with a valid key essentially never fails in production, so a seam is the +// only deterministic way to cover that defensive arm. +var cryptoEncrypt = crypto.Encrypt + // WebhookHandler handles POST /webhook/new, POST /webhook/receive/:token, // and GET /api/v1/webhooks/:token/requests. type WebhookHandler struct { @@ -901,7 +907,7 @@ func (h *WebhookHandler) storeEncryptedURL(ctx context.Context, resourceID uuid. if err != nil { return fmt.Errorf("storeEncryptedURL: parse key: %w", err) } - encrypted, err := crypto.Encrypt(aesKey, rURL) + encrypted, err := cryptoEncrypt(aesKey, rURL) if err != nil { return fmt.Errorf("storeEncryptedURL: encrypt: %w", err) } diff --git a/internal/handlers/webhook_residual_test.go b/internal/handlers/webhook_residual_test.go new file mode 100644 index 0000000..ca7eb33 --- /dev/null +++ b/internal/handlers/webhook_residual_test.go @@ -0,0 +1,451 @@ +package handlers_test + +// webhook_residual_test.go — residual coverage for webhook.go (82.6% → ≥95%). +// Targets: +// +// storeEncryptedURL: the crypto.Encrypt-failed arm (905-907) via the +// SetWebhookCryptoEncryptForTest seam (encrypt with a +// valid key never fails in prod). +// NewWebhook (anon): missing-name 400 (220-222), invalid-env 400 (226-228). +// Receive: lookup_failed (brokenDB), inactive 410, expired 410, +// idempotency replay, rotation header. +// ListRequests: lookup_failed (brokenDB), inactive 410, expired 410. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "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" +) + +// ── storeEncryptedURL encrypt-fail seam ───────────────────────────────────── + +// TestStoreEncryptedURL_EncryptFails drives the crypto.Encrypt-failed arm +// (905-907). A valid AES key parses fine, so the only way to reach the +// encrypt error is the package-level cryptoEncrypt seam. +func TestStoreEncryptedURL_EncryptFails(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + h := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + + restore := handlers.SetWebhookCryptoEncryptForTest( + func([]byte, string) (string, error) { return "", errors.New("encrypt boom") }) + defer restore() + + err := handlers.StoreEncryptedURLForTest(h, context.Background(), + uuid.New(), "https://hook.example/x", "req-enc") + require.Error(t, err) + assert.Contains(t, err.Error(), "encrypt") +} + +// ── webhook receive/list app wired to an arbitrary DB ─────────────────────── + +// newWebhookHandlerWithDB builds a WebhookHandler over the given DB + a real +// test Redis, with webhook enabled. +func newWebhookHandlerWithDB(t *testing.T, db *sql.DB) (*handlers.WebhookHandler, func()) { + t.Helper() + rdb, rClean := testhelpers.SetupTestRedis(t) + cfg := &config.Config{ + Environment: "test", + AESKey: testhelpers.TestAESKeyHex, + EnabledServices: "webhook", + } + h := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + return h, rClean +} + +// receiveRouteApp mounts Receive + ListRequests on a handler. +func receiveRouteApp(h *handlers.WebhookHandler) *fiber.App { + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.All("/webhook/receive/:token", h.Receive) + app.Get("/api/v1/webhooks/:token/requests", h.ListRequests) + return app +} + +// TestReceive_LookupFailed_BrokenDB drives the Receive lookup_failed arm +// (534-536) via a brokenDB. +func TestReceive_LookupFailed_BrokenDB(t *testing.T) { + h, clean := newWebhookHandlerWithDB(t, brokenDB(t)) + defer clean() + app := receiveRouteApp(h) + req := httptest.NewRequest(http.MethodPost, "/webhook/receive/"+uuid.NewString(), nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// TestListRequests_LookupFailed_BrokenDB drives the ListRequests lookup_failed +// arm (816-818) via a brokenDB. +func TestListRequests_LookupFailed_BrokenDB(t *testing.T) { + h, clean := newWebhookHandlerWithDB(t, brokenDB(t)) + defer clean() + app := receiveRouteApp(h) + req := httptest.NewRequest(http.MethodGet, "/api/v1/webhooks/"+uuid.NewString()+"/requests", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// seedWebhookResource inserts a webhook resource row with the given status + +// optional expiry, returning its token. +func seedWebhookResource(t *testing.T, db *sql.DB, status string, expiresAt *time.Time) string { + t.Helper() + token := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (token, resource_type, tier, env, status, expires_at) + VALUES ($1, 'webhook', 'anonymous', 'production', $2, $3) + `, token, status, expiresAt) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + return token +} + +// TestReceive_InactiveResource_410 drives the inactive-status arm (548-550). +func TestReceive_InactiveResource_410(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + app := receiveRouteApp(h) + token := seedWebhookResource(t, db, "suspended", nil) + req := httptest.NewRequest(http.MethodPost, "/webhook/receive/"+token, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusGone, resp.StatusCode) +} + +// TestReceive_ExpiredResource_410 drives the past-TTL arm (557-559). +func TestReceive_ExpiredResource_410(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + app := receiveRouteApp(h) + past := time.Now().Add(-time.Hour) + token := seedWebhookResource(t, db, "active", &past) + req := httptest.NewRequest(http.MethodPost, "/webhook/receive/"+token, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusGone, resp.StatusCode) +} + +// TestListRequests_InactiveResource_410 drives the ListRequests inactive arm +// (834-837). +func TestListRequests_InactiveResource_410(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + app := receiveRouteApp(h) + token := seedWebhookResource(t, db, "suspended", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/webhooks/"+token+"/requests", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusGone, resp.StatusCode) +} + +// TestListRequests_ExpiredResource_410 drives the ListRequests past-TTL arm +// (842-844). +func TestListRequests_ExpiredResource_410(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + app := receiveRouteApp(h) + past := time.Now().Add(-time.Hour) + token := seedWebhookResource(t, db, "active", &past) + req := httptest.NewRequest(http.MethodGet, "/api/v1/webhooks/"+token+"/requests", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusGone, resp.StatusCode) +} + +// TestReceive_IdempotencyReplay drives the idempotency-replay arm (607-610): +// the second request with the same X-Idempotency-Key returns the cached +// response without writing a new ring-buffer entry. +func TestReceive_IdempotencyReplay(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + app := receiveRouteApp(h) + token := seedWebhookResource(t, db, "active", nil) + + idem := "idem-" + uuid.NewString() + send := func() *http.Response { + req := httptest.NewRequest(http.MethodPost, "/webhook/receive/"+token, bytes.NewReader([]byte(`{"x":1}`))) + req.Header.Set("X-Idempotency-Key", idem) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + return resp + } + r1 := send() + r1.Body.Close() + require.Equal(t, http.StatusOK, r1.StatusCode) + r2 := send() + r2.Body.Close() + require.Equal(t, http.StatusOK, r2.StatusCode, "idempotent replay must succeed") +} + +// ── NewWebhook anonymous validation arms ───────────────────────────────────── + +// newWebhookProvisionApp mounts POST /webhook/new on a webhook-enabled +// handler over a real DB + Redis. +func newWebhookProvisionApp(t *testing.T, db *sql.DB) *fiber.App { + t.Helper() + h, clean := newWebhookHandlerWithDB(t, db) + t.Cleanup(clean) + app := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Post("/webhook/new", h.NewWebhook) + return app +} + +func postWebhookNew(t *testing.T, app *fiber.App, ip, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/webhook/new", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", ip) + resp, err := app.Test(req, 10000) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + out := map[string]any{} + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +// TestNewWebhook_MissingName_400 drives the requireName error arm (219-222). +func TestNewWebhook_MissingName_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := newWebhookProvisionApp(t, db) + status, _ := postWebhookNew(t, app, "10.70.0.1", `{}`) + assert.Equal(t, http.StatusBadRequest, status) +} + +// TestNewWebhook_InvalidEnv_400 drives the resolveEnv error arm (225-228). +func TestNewWebhook_InvalidEnv_400(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := newWebhookProvisionApp(t, db) + status, _ := postWebhookNew(t, app, "10.71.0.1", `{"name":"wh","env":"not a valid env!!"}`) + assert.Equal(t, http.StatusBadRequest, status) +} + +// TestReceive_RedisStoreFailed_FailsOpen drives the Receive LLen-error + +// pipeline-Exec-failed arms (659-661 + 667-672): a dead Redis makes both the +// pre-length read and the store pipeline fail, but the receiver still 200s +// (fail open — never block the sender). +func TestReceive_RedisStoreFailed_FailsOpen(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + deadRDB := redis.NewClient(&redis.Options{Addr: "127.0.0.1:19995"}) + defer deadRDB.Close() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex, EnabledServices: "webhook"} + h := handlers.NewWebhookHandler(db, deadRDB, cfg, plans.Default()) + + token := seedWebhookResource(t, db, "active", nil) + app := receiveRouteApp(h) + req := httptest.NewRequest(http.MethodPost, "/webhook/receive/"+token, bytes.NewReader([]byte(`{"x":1}`))) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "redis store failure must fail open with 200") +} + +// TestNewWebhook_AuthTeamLookupFailed_503 drives newWebhookAuthenticated's +// team_lookup_failed arm (407-410): a session-authed caller (team_id pinned in +// Locals) over a brokenDB → GetTeamByID errors → 503. +func TestNewWebhook_AuthTeamLookupFailed_503(t *testing.T) { + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex, EnabledServices: "webhook"} + h := handlers.NewWebhookHandler(brokenDB(t), rdb, cfg, plans.Default()) + app := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", + 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 + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Use(middleware.Fingerprint()) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, uuid.NewString()) // authenticated path + return c.Next() + }) + app.Post("/webhook/new", h.NewWebhook) + + status, _ := postWebhookNew(t, app, "10.72.0.1", `{"name":"auth-wh"}`) + assert.Equal(t, http.StatusServiceUnavailable, status) +} + +// ── ListRequests cross-team + redis arms ───────────────────────────────────── + +// TestListRequests_CrossTeamSession_403 drives the cross_team_session arm +// (864-872): a claimed (team-owned) webhook + a session JWT for a different +// team. +func TestListRequests_CrossTeamSession_403(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + + ownerTeam := testhelpers.MustCreateTeamDB(t, db, "pro") + otherTeam := testhelpers.MustCreateTeamDB(t, db, "pro") + token := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status) + VALUES ($1::uuid, $2, 'webhook', 'pro', 'production', 'active') + `, ownerTeam, token) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE token = $1`, token) }) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false}) + }, + }) + // fake-auth pins a session for the OTHER team. + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyTeamID, otherTeam) + return c.Next() + }) + app.Get("/api/v1/webhooks/:token/requests", h.ListRequests) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/webhooks/"+token+"/requests", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +// TestListRequests_RedisReadFailed_FailsOpen drives the redis-read-failed arm +// (877-883): a claimed webhook + a dead Redis → LRange errors → empty list, +// still 200 (fail open). +func TestListRequests_RedisReadFailed_FailsOpen(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + deadRDB := redis.NewClient(&redis.Options{Addr: "127.0.0.1:19996"}) + defer deadRDB.Close() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex, EnabledServices: "webhook"} + h := handlers.NewWebhookHandler(db, deadRDB, cfg, plans.Default()) + + token := seedWebhookResource(t, db, "active", nil) + app := receiveRouteApp(h) + req := httptest.NewRequest(http.MethodGet, "/api/v1/webhooks/"+token+"/requests", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "redis read failure must fail open with empty list") +} + +// TestListRequests_DecodeItemFailed_Skips drives the decode-item-failed arm +// (889-892): a malformed (non-JSON) item in the ring buffer is skipped. +func TestListRequests_DecodeItemFailed_Skips(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex, EnabledServices: "webhook"} + h := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + + token := seedWebhookResource(t, db, "active", nil) + // Inject a malformed (non-JSON) entry directly into the ring buffer. + listKey := "wh:list:" + token + require.NoError(t, rdb.LPush(context.Background(), listKey, "not-json-{").Err()) + // Best-effort: also push a valid one so the loop runs both arms. + require.NoError(t, rdb.LPush(context.Background(), listKey, `{"id":"x"}`).Err()) + + app := receiveRouteApp(h) + req := httptest.NewRequest(http.MethodGet, "/api/v1/webhooks/"+token+"/requests", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestReceive_RotationHeader drives the rotation arm (684-693): filling the +// ring buffer past the anonymous max-stored cap sets X-Webhook-Rotated. +func TestReceive_RotationHeader(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + h, clean := newWebhookHandlerWithDB(t, db) + defer clean() + app := receiveRouteApp(h) + token := seedWebhookResource(t, db, "active", nil) + + maxStored := int(handlers.WebhookMaxStoredForTest(h, "anonymous")) + var lastRotated string + for i := 0; i < maxStored+2; i++ { + req := httptest.NewRequest(http.MethodPost, "/webhook/receive/"+token, + bytes.NewReader([]byte(fmt.Sprintf(`{"n":%d}`, i)))) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + if h := resp.Header.Get("X-Webhook-Rotated"); h != "" { + lastRotated = h + } + resp.Body.Close() + } + assert.Equal(t, token, lastRotated, "ring-buffer rotation must set X-Webhook-Rotated once over cap") +} diff --git a/internal/middleware/residual_coverage_test.go b/internal/middleware/residual_coverage_test.go new file mode 100644 index 0000000..782ec11 --- /dev/null +++ b/internal/middleware/residual_coverage_test.go @@ -0,0 +1,91 @@ +package middleware_test + +// residual_coverage_test.go — closes the last ~0.1% gap in internal/middleware +// (94.9% → ≥95%). Targets the cheap, deterministic uncovered arms: +// +// RequireAdmin: the admin-allowed c.Next() success path (admin.go +// line 119) — every existing test only drives the +// 403 rejection. +// idempotencyFingerprint: the canonicalisation-failed fail-open arm +// (idempotency.go 364-375 + canonicalMultipartBody +// 510-512) via a malformed multipart body. +// Idempotency (fp, redis): the Redis-GET fail-open arm via a dead Redis +// client (idempotency.go 382-391). + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/middleware" +) + +// TestRequireAdmin_AllowedCallsNext drives the success path (admin.go:119): +// an allow-listed email passes through to the next handler. +func TestRequireAdmin_AllowedCallsNext(t *testing.T) { + t.Setenv("ADMIN_EMAILS", "founder@instanode.dev") + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyEmail, "founder@instanode.dev") + return c.Next() + }) + app.Get("/admin/ping", middleware.RequireAdmin(), func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"ok": true}) + }) + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/admin/ping", nil), 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "allow-listed admin must reach the handler") +} + +// TestIdempotencyFingerprint_RedisDown_FailsOpen drives the Redis-GET +// fail-open arm (idempotency.go 382-391): a dead Redis client makes the GET +// error, so the middleware logs + falls through to the handler. +func TestIdempotencyFingerprint_RedisDown_FailsOpen(t *testing.T) { + deadRDB := redis.NewClient(&redis.Options{Addr: "127.0.0.1:19998"}) // nothing listening + defer deadRDB.Close() + app := fiber.New(fiber.Config{ProxyHeader: "X-Forwarded-For"}) + app.Use(middleware.Fingerprint()) + reached := false + app.Post("/rd", middleware.Idempotency(deadRDB, "rd.fp"), func(c *fiber.Ctx) error { + reached = true + return c.SendStatus(fiber.StatusCreated) + }) + req := httptest.NewRequest(http.MethodPost, "/rd", strings.NewReader(`{"a":1}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "10.51.0.1") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, reached, "Redis-down must fail open and reach the handler") + assert.Equal(t, http.StatusCreated, resp.StatusCode) +} + +// TestPopulateTeamRole_NilDB_FallsThrough drives the uninitialised-DB arm +// (role_lookup.go 49-51): with userID+teamID locals set but the package DB +// handle nil, the middleware skips the role lookup and calls c.Next(). +func TestPopulateTeamRole_NilDB_FallsThrough(t *testing.T) { + middleware.SetRoleLookupDB(nil) // force the nil-DB arm + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyUserID, "11111111-1111-1111-1111-111111111111") + c.Locals(middleware.LocalKeyTeamID, "22222222-2222-2222-2222-222222222222") + return c.Next() + }) + reached := false + app.Get("/role", middleware.PopulateTeamRole(), func(c *fiber.Ctx) error { + reached = true + return c.SendStatus(fiber.StatusOK) + }) + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/role", nil), 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, reached, "nil role-lookup DB must fall through to the handler") + assert.Equal(t, http.StatusOK, resp.StatusCode) +}