diff --git a/internal/db/pool_metrics.go b/internal/db/pool_metrics.go index b3ac94a..36b4d90 100644 --- a/internal/db/pool_metrics.go +++ b/internal/db/pool_metrics.go @@ -54,12 +54,20 @@ func StartPoolStatsExporter(ctx context.Context, pool *sql.DB, label string) { // Prom rules can't distinguish from "process unreachable"). publishStats(pool, label) + runExporterLoop(ctx, pool, label, ticker.C) +} + +// runExporterLoop is the blocking sample-or-stop select loop, split out so a +// unit test can drive both arms synchronously (on the test goroutine, so the +// atomic coverage counters are recorded deterministically rather than racing +// the spawning goroutine's exit). +func runExporterLoop(ctx context.Context, pool *sql.DB, label string, tick <-chan time.Time) { for { select { case <-ctx.Done(): slog.Info("db.pool_metrics.exporter_stopped", "label", label) return - case <-ticker.C: + case <-tick: publishStats(pool, label) } } diff --git a/internal/db/postgres.go b/internal/db/postgres.go index d07a266..702e9b1 100644 --- a/internal/db/postgres.go +++ b/internal/db/postgres.go @@ -19,6 +19,16 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS +// Seams for testing genuinely-unreachable error branches. Production code +// always uses the real embedded FS + lib/pq driver; tests swap these to +// drive the embed-read / driver-open failure paths that can't otherwise +// be reached because the FS is compiled in and lib/pq opens lazily. +var ( + readMigrationDir = func() ([]fs.DirEntry, error) { return fs.ReadDir(migrationsFS, "migrations") } + readMigrationFile = func(name string) ([]byte, error) { return fs.ReadFile(migrationsFS, "migrations/"+name) } + sqlOpen = sql.Open +) + // RunMigrations executes all embedded SQL migration files in alphabetical order. // All SQL files use CREATE TABLE IF NOT EXISTS / ALTER TABLE ADD COLUMN IF NOT EXISTS / // CREATE INDEX IF NOT EXISTS — safe to re-run on every startup. @@ -36,7 +46,7 @@ func RunMigrations(db *sql.DB) error { } for _, name := range names { - content, err := fs.ReadFile(migrationsFS, "migrations/"+name) + content, err := readMigrationFile(name) if err != nil { return fmt.Errorf("db.RunMigrations: read %s: %w", name, err) } @@ -68,7 +78,7 @@ func RunMigrations(db *sql.DB) error { // filenames. Exported via MigrationFiles for read-only callers that want // to compare the in-binary set against the DB-tracked set. func embeddedMigrationFilenames() ([]string, error) { - entries, err := fs.ReadDir(migrationsFS, "migrations") + entries, err := readMigrationDir() if err != nil { return nil, fmt.Errorf("read dir: %w", err) } @@ -173,7 +183,7 @@ func envDuration(name string, def time.Duration) time.Duration { // API_PG_CONN_MAX_LIFETIME (default 4m) — Go time.Duration // API_PG_CONN_MAX_IDLE_TIME (default 90s) func ConnectPostgres(databaseURL string) *sql.DB { - db, err := sql.Open("postgres", databaseURL) + db, err := sqlOpen("postgres", databaseURL) if err != nil { panic(&ErrDBConnect{Cause: err}) } diff --git a/internal/db/seam_rbw_test.go b/internal/db/seam_rbw_test.go new file mode 100644 index 0000000..1e33a89 --- /dev/null +++ b/internal/db/seam_rbw_test.go @@ -0,0 +1,213 @@ +// Package db: coverage for the genuinely-unreachable error branches in the +// migration runner + ConnectPostgres, driven through the package-var seams +// (readMigrationDir / readMigrationFile / sqlOpen). These paths can't be +// reached with the real embedded FS (compiled in, always readable) or the +// lib/pq driver (sql.Open is lazy and never errors on a DSN), so the seams +// let a test inject the failure deterministically. +// +// Rule-17 coverage block: +// Symptom: postgres.go 34-36 / 40-42 / 72-74 / 177-178 uncovered (94.1%) +// Enumeration: grep 'postgres.go' cover.out | awk '$NF==0' +// Sites found: 4 zero-count blocks +// Sites touched: 4 (this file) +// Coverage test: TestRunMigrations_FilenameListError, _FileReadError, +// TestEmbeddedMigrationFilenames_ReadDirError, +// TestConnectPostgres_PanicsOnOpenError, +// TestStartPoolStatsExporter_CtxDoneAfterFirstTick +package db + +import ( + "context" + "database/sql" + "errors" + "io/fs" + "strings" + "testing" + "time" +) + +// withSeams swaps the package-var seams for the duration of a test and +// restores them after. Centralises the save/restore so a forgotten restore +// can't leak into the next test in the serialized (-p 1) run. +func withSeams(t *testing.T, dir func() ([]fs.DirEntry, error), file func(string) ([]byte, error), open func(string, string) (*sql.DB, error)) { + t.Helper() + origDir, origFile, origOpen := readMigrationDir, readMigrationFile, sqlOpen + if dir != nil { + readMigrationDir = dir + } + if file != nil { + readMigrationFile = file + } + if open != nil { + sqlOpen = open + } + t.Cleanup(func() { + readMigrationDir = origDir + readMigrationFile = origFile + sqlOpen = origOpen + }) +} + +// TestEmbeddedMigrationFilenames_ReadDirError covers postgres.go:72-74 — the +// fs.ReadDir failure path inside embeddedMigrationFilenames. +func TestEmbeddedMigrationFilenames_ReadDirError(t *testing.T) { + sentinel := errors.New("boom: embed dir unreadable") + withSeams(t, func() ([]fs.DirEntry, error) { return nil, sentinel }, nil, nil) + + names, err := embeddedMigrationFilenames() + if err == nil { + t.Fatal("embeddedMigrationFilenames: want error, got nil") + } + if names != nil { + t.Errorf("names should be nil on error, got %v", names) + } + if !errors.Is(err, sentinel) { + t.Errorf("error should wrap sentinel: %v", err) + } + if !strings.Contains(err.Error(), "read dir") { + t.Errorf("error wrapping lost 'read dir': %v", err) + } +} + +// TestRunMigrations_FilenameListError covers postgres.go:34-36 — RunMigrations +// returns early when embeddedMigrationFilenames (via the dir seam) errors. +func TestRunMigrations_FilenameListError(t *testing.T) { + sentinel := errors.New("boom: cannot list migrations") + withSeams(t, func() ([]fs.DirEntry, error) { return nil, sentinel }, nil, nil) + + err := RunMigrations(nil) // db never touched — list fails first + if err == nil { + t.Fatal("RunMigrations: want error from filename list, got nil") + } + if !strings.Contains(err.Error(), "db.RunMigrations") { + t.Errorf("error wrapping lost prefix: %v", err) + } + if !errors.Is(err, sentinel) { + t.Errorf("error should wrap sentinel: %v", err) + } +} + +// TestRunMigrations_FileReadError covers postgres.go:40-42 — RunMigrations +// returns when reading a listed migration file fails. The dir seam yields a +// non-empty name list so the loop body runs, and the file seam fails the read. +func TestRunMigrations_FileReadError(t *testing.T) { + sentinel := errors.New("boom: file vanished") + withSeams(t, + func() ([]fs.DirEntry, error) { return []fs.DirEntry{fakeDirEntry{name: "001_x.sql"}}, nil }, + func(string) ([]byte, error) { return nil, sentinel }, + nil, + ) + + err := RunMigrations(nil) // db never reached — read fails before Exec + if err == nil { + t.Fatal("RunMigrations: want error from file read, got nil") + } + if !strings.Contains(err.Error(), "read 001_x.sql") { + t.Errorf("error should name the failing file: %v", err) + } + if !errors.Is(err, sentinel) { + t.Errorf("error should wrap sentinel: %v", err) + } +} + +// fakeDirEntry is a minimal fs.DirEntry so the dir seam can return a +// synthetic non-empty file list without touching a real FS. +type fakeDirEntry struct{ name string } + +func (f fakeDirEntry) Name() string { return f.name } +func (f fakeDirEntry) IsDir() bool { return false } +func (f fakeDirEntry) Type() fs.FileMode { return 0 } +func (f fakeDirEntry) Info() (fs.FileInfo, error) { return nil, nil } + +// TestConnectPostgres_PanicsOnOpenError covers postgres.go:177-178 — the +// sql.Open failure panic. lib/pq's Open is lazy and never errors on a DSN, +// so this branch is only reachable via the sqlOpen seam. +func TestConnectPostgres_PanicsOnOpenError(t *testing.T) { + sentinel := errors.New("boom: driver open failed") + withSeams(t, nil, nil, func(string, string) (*sql.DB, error) { return nil, sentinel }) + + defer func() { + r := recover() + if r == nil { + t.Fatal("ConnectPostgres: expected panic on open error") + } + e, ok := r.(*ErrDBConnect) + if !ok { + t.Fatalf("panic value: want *ErrDBConnect, got %T (%v)", r, r) + } + if !errors.Is(e.Cause, sentinel) { + t.Errorf("ErrDBConnect.Cause should wrap sentinel, got %v", e.Cause) + } + }() + ConnectPostgres("postgres://whatever") +} + +// TestRunExporterLoop_CtxDoneArm drives the ctx.Done() branch +// (pool_metrics.go ctx.Done case) SYNCHRONOUSLY on the test goroutine, so the +// atomic coverage counter is recorded deterministically. A pre-cancelled +// context + an empty (never-firing) tick channel forces the ctx.Done() arm. +func TestRunExporterLoop_CtxDoneArm(t *testing.T) { + dsn := testDSN() + pool, err := sql.Open("postgres", dsn) + if err != nil { + t.Skipf("postgres open: %v", err) + } + defer pool.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already done before the loop is entered + tick := make(chan time.Time) + + // runs on this goroutine and returns immediately via the ctx.Done() arm. + runExporterLoop(ctx, pool, "rbw-ctxdone", tick) +} + +// TestRunExporterLoop_TickArm drives the ticker arm synchronously: a pre-fed +// tick channel publishes one sample, then ctx cancellation exits the loop. +func TestRunExporterLoop_TickArm(t *testing.T) { + dsn := testDSN() + pool, err := sql.Open("postgres", dsn) + if err != nil { + t.Skipf("postgres open: %v", err) + } + defer pool.Close() + + tick := make(chan time.Time, 1) + tick <- time.Now() // one sample will publish + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel shortly after the single tick is consumed so the loop takes the + // tick arm at least once, then exits via ctx.Done(). + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + runExporterLoop(ctx, pool, "rbw-tick", tick) +} + +// TestStartPoolStatsExporter_CtxDoneAfterFirstTick exercises the public entry +// end-to-end (immediate publish + loop) and asserts clean shutdown on cancel. +func TestStartPoolStatsExporter_CtxDoneAfterFirstTick(t *testing.T) { + dsn := testDSN() + pool, err := sql.Open("postgres", dsn) + if err != nil { + t.Skipf("postgres open: %v", err) + } + defer pool.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + StartPoolStatsExporter(ctx, pool, "rbw-ctxdone-e2e") + close(done) + }() + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("StartPoolStatsExporter did not return after ctx cancel") + } +} diff --git a/internal/handlers/admin_helpers_rbw_test.go b/internal/handlers/admin_helpers_rbw_test.go new file mode 100644 index 0000000..3a0cdb5 --- /dev/null +++ b/internal/handlers/admin_helpers_rbw_test.go @@ -0,0 +1,109 @@ +package handlers_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/plans" +) + +// TestAdminParseTierFilter covers every documented return case. +func TestAdminParseTierFilter(t *testing.T) { + out, empty := handlers.AdminParseTierFilterForTest("") + require.Nil(t, out) + require.False(t, empty) + + out, empty = handlers.AdminParseTierFilterForTest("pro") + require.Equal(t, []string{"pro"}, out) + require.False(t, empty) + + out, _ = handlers.AdminParseTierFilterForTest("hobby, ,pro,HOBBY") // dedupe + case + whitespace + require.Equal(t, []string{"hobby", "pro"}, out) + + out, empty = handlers.AdminParseTierFilterForTest("platinum") // all unknown + require.Nil(t, out) + require.True(t, empty) + + out, empty = handlers.AdminParseTierFilterForTest(" , , ") // only whitespace → no filter + require.Nil(t, out) + require.False(t, empty) + + out, _ = handlers.AdminParseTierFilterForTest("pro,platinum") // partial unknown + require.Equal(t, []string{"pro"}, out) +} + +// TestAdminParseLimit covers default / clamp-low / clamp-high / valid. +func TestAdminParseLimit(t *testing.T) { + require.Equal(t, 25, handlers.AdminParseLimitForTest("", 25, 100)) + require.Equal(t, 25, handlers.AdminParseLimitForTest("abc", 25, 100)) + require.Equal(t, 25, handlers.AdminParseLimitForTest("0", 25, 100)) + require.Equal(t, 25, handlers.AdminParseLimitForTest("-3", 25, 100)) + require.Equal(t, 100, handlers.AdminParseLimitForTest("9999", 25, 100)) + require.Equal(t, 42, handlers.AdminParseLimitForTest(" 42 ", 25, 100)) +} + +// TestAdminParseOffset covers default / negative / valid. +func TestAdminParseOffset(t *testing.T) { + require.Equal(t, 0, handlers.AdminParseOffsetForTest("")) + require.Equal(t, 0, handlers.AdminParseOffsetForTest("xyz")) + require.Equal(t, 0, handlers.AdminParseOffsetForTest("-1")) + require.Equal(t, 7, handlers.AdminParseOffsetForTest(" 7 ")) +} + +// TestAdminOrderClause covers all sort keys + the invalid arm. +func TestAdminOrderClause(t *testing.T) { + for _, sb := range []string{"", "mrr", "last_active", "created_at", "storage_bytes"} { + clause, err := handlers.AdminOrderClauseForTest(sb) + require.NoError(t, err, "sort_by=%q", sb) + require.NotEmpty(t, clause) + } + _, err := handlers.AdminOrderClauseForTest("'; DROP TABLE--") + require.Error(t, err) +} + +// TestEscapeLikePattern covers the three metacharacters + backslash. +func TestEscapeLikePattern(t *testing.T) { + require.Equal(t, `\%\_`, handlers.EscapeLikePatternForTest("%_")) + require.Equal(t, `\\`, handlers.EscapeLikePatternForTest(`\`)) + require.Equal(t, "plain", handlers.EscapeLikePatternForTest("plain")) +} + +// TestComputeMRR covers the nil-plans arm + the priced arm. +func TestComputeMRR(t *testing.T) { + // nil plans → (0,0) + hNil := handlers.NewAdminCustomersHandler(nil, nil) + m, a := handlers.ComputeMRRForTest(hNil, "pro") + require.Equal(t, 0, m) + require.Equal(t, 0, a) + + // real registry → monthly>0 for a paid tier, annual = 12×monthly + h := handlers.NewAdminCustomersHandler(nil, plans.Default()) + m, a = handlers.ComputeMRRForTest(h, "pro") + require.Greater(t, m, 0) + require.Equal(t, m*12, a) +} + +// TestParsePromoAuditSince covers empty / RFC3339 / date-only / invalid. +func TestParsePromoAuditSince(t *testing.T) { + tm, err := handlers.ParsePromoAuditSinceForTest("") + require.NoError(t, err) + require.True(t, tm.IsZero()) + + tm, err = handlers.ParsePromoAuditSinceForTest("2026-05-20T10:00:00Z") + require.NoError(t, err) + require.False(t, tm.IsZero()) + + tm, err = handlers.ParsePromoAuditSinceForTest("2026-05-20") + require.NoError(t, err) + require.Equal(t, 2026, tm.Year()) + + _, err = handlers.ParsePromoAuditSinceForTest("not-a-date") + require.Error(t, err) +} + +var _ = errors.Is +var _ = time.Now \ No newline at end of file diff --git a/internal/handlers/admin_impersonate_rbw_test.go b/internal/handlers/admin_impersonate_rbw_test.go new file mode 100644 index 0000000..7f7daaa --- /dev/null +++ b/internal/handlers/admin_impersonate_rbw_test.go @@ -0,0 +1,83 @@ +package handlers_test + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/testhelpers" +) + +func impersonateApp(h *handlers.AdminImpersonateHandler, email string) *fiber.App { + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return fiber.DefaultErrorHandler(c, err) + }}) + app.Use(func(c *fiber.Ctx) error { c.Locals(middleware.LocalKeyEmail, email); return c.Next() }) + app.Post("/imp/:team_id", h.Impersonate) + return app +} + +func impReq(t *testing.T, app *fiber.App, path string) int { + t.Helper() + resp, err := app.Test(httptest.NewRequest("POST", path, nil), 10000) + require.NoError(t, err) + resp.Body.Close() + return resp.StatusCode +} + +func TestImpersonate_InvalidTeamID(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + cfg := &config.Config{Environment: "test", JWTSecret: testhelpers.TestJWTSecret} + app := impersonateApp(handlers.NewAdminImpersonateHandler(db, cfg), "admin@x.com") + require.Equal(t, fiber.StatusBadRequest, impReq(t, app, "/imp/not-a-uuid")) +} + +func TestImpersonate_TeamNotFound(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + cfg := &config.Config{Environment: "test", JWTSecret: testhelpers.TestJWTSecret} + app := impersonateApp(handlers.NewAdminImpersonateHandler(db, cfg), "admin@x.com") + require.Equal(t, fiber.StatusNotFound, impReq(t, app, "/imp/"+uuid.NewString())) +} + +func TestImpersonate_DBError(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + dbClean() // closed → GetTeamByID errors (non-not-found) → db_failed + cfg := &config.Config{Environment: "test", JWTSecret: testhelpers.TestJWTSecret} + app := impersonateApp(handlers.NewAdminImpersonateHandler(db, cfg), "admin@x.com") + require.Equal(t, fiber.StatusServiceUnavailable, impReq(t, app, "/imp/"+uuid.NewString())) +} + +func TestImpersonate_TeamHasNoUsers(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + team := testhelpers.MustCreateTeamDB(t, db, "pro") // team row, zero users + cfg := &config.Config{Environment: "test", JWTSecret: testhelpers.TestJWTSecret} + app := impersonateApp(handlers.NewAdminImpersonateHandler(db, cfg), "admin@x.com") + require.Equal(t, fiber.StatusConflict, impReq(t, app, "/imp/"+team)) +} + +func TestImpersonate_Success(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + team := testhelpers.MustCreateTeamDB(t, db, "pro") + _, err := db.ExecContext(context.Background(), + `INSERT INTO users (team_id, email, role) VALUES ($1::uuid, $2, 'owner')`, + team, testhelpers.UniqueEmail(t)) + require.NoError(t, err) + cfg := &config.Config{Environment: "test", JWTSecret: testhelpers.TestJWTSecret} + app := impersonateApp(handlers.NewAdminImpersonateHandler(db, cfg), "admin@x.com") + require.Equal(t, fiber.StatusOK, impReq(t, app, "/imp/"+team)) +} diff --git a/internal/handlers/admin_notes_rbw_test.go b/internal/handlers/admin_notes_rbw_test.go new file mode 100644 index 0000000..98638de --- /dev/null +++ b/internal/handlers/admin_notes_rbw_test.go @@ -0,0 +1,102 @@ +package handlers_test + +import ( + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/testhelpers" +) + +// notesApp mounts the three note routes behind a shim that sets the admin +// email Local (CreateNote reads middleware.GetEmail). +func notesApp(h *handlers.AdminCustomerNotesHandler, email string) *fiber.App { + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return fiber.DefaultErrorHandler(c, err) + }}) + app.Use(func(c *fiber.Ctx) error { + c.Locals(middleware.LocalKeyEmail, email) + return c.Next() + }) + app.Get("/n/:team_id", h.ListNotes) + app.Post("/n/:team_id", h.CreateNote) + app.Delete("/n/:note_id", h.DeleteNote) + return app +} + +func noteReq(t *testing.T, app *fiber.App, method, path, body string) int { + t.Helper() + var r *strings.Reader + if body != "" { + r = strings.NewReader(body) + } else { + r = strings.NewReader("") + } + req := httptest.NewRequest(method, path, r) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 10000) + require.NoError(t, err) + resp.Body.Close() + return resp.StatusCode +} + +func TestAdminNotes_InvalidIDs(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + app := notesApp(handlers.NewAdminCustomerNotesHandler(db), "admin@x.com") + require.Equal(t, fiber.StatusBadRequest, noteReq(t, app, "GET", "/n/not-a-uuid", "")) + require.Equal(t, fiber.StatusBadRequest, noteReq(t, app, "POST", "/n/not-a-uuid", `{"body":"x"}`)) + require.Equal(t, fiber.StatusBadRequest, noteReq(t, app, "DELETE", "/n/not-a-uuid", "")) +} + +func TestAdminNotes_CreateValidation(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + team := testhelpers.MustCreateTeamDB(t, db, "pro") + app := notesApp(handlers.NewAdminCustomerNotesHandler(db), "admin@x.com") + + // invalid body (not JSON) + require.Equal(t, fiber.StatusBadRequest, noteReq(t, app, "POST", "/n/"+team, `{not json`)) + // missing body + require.Equal(t, fiber.StatusBadRequest, noteReq(t, app, "POST", "/n/"+team, `{"body":" "}`)) + // body too long (> 8KB) + big := `{"body":"` + strings.Repeat("a", 9000) + `"}` + require.Equal(t, fiber.StatusBadRequest, noteReq(t, app, "POST", "/n/"+team, big)) + // success → 201 + require.Equal(t, fiber.StatusCreated, noteReq(t, app, "POST", "/n/"+team, `{"body":"a real note"}`)) +} + +func TestAdminNotes_TeamNotFound(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + app := notesApp(handlers.NewAdminCustomerNotesHandler(db), "admin@x.com") + // valid UUID, no team row → 404 + require.Equal(t, fiber.StatusNotFound, noteReq(t, app, "POST", "/n/"+uuid.NewString(), `{"body":"hi"}`)) +} + +func TestAdminNotes_NoteNotFound(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + app := notesApp(handlers.NewAdminCustomerNotesHandler(db), "admin@x.com") + require.Equal(t, fiber.StatusNotFound, noteReq(t, app, "DELETE", "/n/"+uuid.NewString(), "")) +} + +func TestAdminNotes_DBError(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + team := testhelpers.MustCreateTeamDB(t, db, "pro") + dbClean() // close → all queries error + app := notesApp(handlers.NewAdminCustomerNotesHandler(db), "admin@x.com") + require.Equal(t, fiber.StatusServiceUnavailable, noteReq(t, app, "GET", "/n/"+team, "")) + require.Equal(t, fiber.StatusServiceUnavailable, noteReq(t, app, "POST", "/n/"+team, `{"body":"x"}`)) + require.Equal(t, fiber.StatusServiceUnavailable, noteReq(t, app, "DELETE", "/n/"+uuid.NewString(), "")) +} diff --git a/internal/handlers/billing_helpers_rbw_test.go b/internal/handlers/billing_helpers_rbw_test.go new file mode 100644 index 0000000..438cc33 --- /dev/null +++ b/internal/handlers/billing_helpers_rbw_test.go @@ -0,0 +1,58 @@ +package handlers + +// Internal coverage for the unexported billing webhook helpers chargedPaymentMeta +// and receiptDedupKey, which operate on unexported rzp* types and so can't be +// reached from the external _test package. + +import ( + "encoding/json" + "testing" +) + +func TestChargedPaymentMeta_RBW(t *testing.T) { + // nil payment → all-zero + id, amt, cur := chargedPaymentMeta(rzpWebhookEvent{}) + if id != "" || amt != 0 || cur != "" { + t.Errorf("nil payment: got %q,%d,%q", id, amt, cur) + } + + // bad JSON entity → all-zero + bad := rzpWebhookEvent{Payload: rzpEventPayload{Payment: &rzpEntityWrapper{Entity: json.RawMessage(`{not json`)}}} + id, amt, cur = chargedPaymentMeta(bad) + if id != "" || amt != 0 || cur != "" { + t.Errorf("bad json: got %q,%d,%q", id, amt, cur) + } + + // valid entity → parsed fields + ent, _ := json.Marshal(rzpPaymentEntity{ID: "pay_1", Amount: 4900, Currency: "USD"}) + ok := rzpWebhookEvent{Payload: rzpEventPayload{Payment: &rzpEntityWrapper{Entity: ent}}} + id, amt, cur = chargedPaymentMeta(ok) + if id != "pay_1" || amt != 4900 || cur != "USD" { + t.Errorf("valid: got %q,%d,%q", id, amt, cur) + } +} + +func TestReceiptDedupKey_RBW(t *testing.T) { + // empty sub.ID → "" + if got := receiptDedupKey(rzpSubscriptionEntity{}, rzpWebhookEvent{}); got != "" { + t.Errorf("empty sub: got %q", got) + } + + // paid_count present → paid: key + pc := int64(3) + if got := receiptDedupKey(rzpSubscriptionEntity{ID: "sub_1", PaidCount: &pc}, rzpWebhookEvent{}); got != "receipt:sub_1:paid:3" { + t.Errorf("paid_count: got %q", got) + } + + // no paid_count, payment id present → pay: key + ent, _ := json.Marshal(rzpPaymentEntity{ID: "pay_9"}) + ev := rzpWebhookEvent{Payload: rzpEventPayload{Payment: &rzpEntityWrapper{Entity: ent}}} + if got := receiptDedupKey(rzpSubscriptionEntity{ID: "sub_2"}, ev); got != "receipt:sub_2:pay:pay_9" { + t.Errorf("pay fallback: got %q", got) + } + + // no paid_count, no payment id → "" + if got := receiptDedupKey(rzpSubscriptionEntity{ID: "sub_3"}, rzpWebhookEvent{}); got != "" { + t.Errorf("neither: got %q", got) + } +} diff --git a/internal/handlers/dev_rbw_test.go b/internal/handlers/dev_rbw_test.go new file mode 100644 index 0000000..b1f3861 --- /dev/null +++ b/internal/handlers/dev_rbw_test.go @@ -0,0 +1,108 @@ +package handlers_test + +import ( + "encoding/json" + "errors" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// devApp mounts NewSetTierHandler on a throwaway fiber app whose ErrorHandler +// recognises the ErrResponseWritten sentinel respondError returns — without +// it, the returned sentinel would be coerced to a generic 500, masking the +// real 4xx/503 status the handler set. +func devApp(h fiber.Handler) *fiber.App { + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return fiber.DefaultErrorHandler(c, err) + }, + }) + app.Post("/internal/set-tier", h) + return app +} + +func postSetTier(t *testing.T, app *fiber.App, raw string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest("POST", "/internal/set-tier", strings.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var m map[string]any + _ = json.Unmarshal(body, &m) + return resp.StatusCode, m +} + +// TestSetTier_InvalidBody covers the BodyParser error arm. +func TestSetTier_InvalidBody(t *testing.T) { + app := devApp(handlers.NewSetTierHandler(nil)) + code, m := postSetTier(t, app, `{not json`) + require.Equal(t, fiber.StatusBadRequest, code) + require.Equal(t, "invalid_body", m["error"]) +} + +// TestSetTier_MissingTeamID covers the empty team_id guard. +func TestSetTier_MissingTeamID(t *testing.T) { + app := devApp(handlers.NewSetTierHandler(nil)) + code, m := postSetTier(t, app, `{"tier":"pro"}`) + require.Equal(t, fiber.StatusBadRequest, code) + require.Equal(t, "missing_team_id", m["error"]) +} + +// TestSetTier_InvalidTier covers the non-upgrade-tier rejection (downgrade, +// junk, and hobby are all rejected here — downgrade is Razorpay's job). +func TestSetTier_InvalidTier(t *testing.T) { + app := devApp(handlers.NewSetTierHandler(nil)) + for _, tier := range []string{"hobby", "anonymous", "bogus", ""} { + code, m := postSetTier(t, app, `{"team_id":"00000000-0000-0000-0000-000000000001","tier":"`+tier+`"}`) + require.Equal(t, fiber.StatusBadRequest, code, "tier=%q", tier) + require.Equal(t, "invalid_tier", m["error"], "tier=%q", tier) + } +} + +// TestSetTier_InvalidUUID covers the uuid.Parse failure arm. +func TestSetTier_InvalidUUID(t *testing.T) { + app := devApp(handlers.NewSetTierHandler(nil)) + code, m := postSetTier(t, app, `{"team_id":"not-a-uuid","tier":"pro"}`) + require.Equal(t, fiber.StatusBadRequest, code) + require.Equal(t, "invalid_team_id", m["error"]) +} + +// TestSetTier_UpgradeFailed covers the UpgradeTeamAllTiers error arm: a valid +// UUID for a team that does not exist still parses, but the upgrade against a +// closed DB connection fails → 503 upgrade_failed. +func TestSetTier_UpgradeFailed(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + cleanup() // close the pool so every query errors + app := devApp(handlers.NewSetTierHandler(db)) + code, m := postSetTier(t, app, `{"team_id":"00000000-0000-0000-0000-000000000009","tier":"pro"}`) + require.Equal(t, fiber.StatusServiceUnavailable, code) + require.Equal(t, "upgrade_failed", m["error"]) +} + +// TestSetTier_Success covers the happy path: a real team is upgraded and the +// handler returns ok + the echoed team_id/tier. +func TestSetTier_Success(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + + app := devApp(handlers.NewSetTierHandler(db)) + code, m := postSetTier(t, app, `{"team_id":"`+teamID+`","tier":"pro"}`) + require.Equal(t, fiber.StatusOK, code) + require.Equal(t, true, m["ok"]) + require.Equal(t, teamID, m["team_id"]) + require.Equal(t, "pro", m["tier"]) +} diff --git a/internal/handlers/export_rbw_test.go b/internal/handlers/export_rbw_test.go new file mode 100644 index 0000000..8a9fa32 --- /dev/null +++ b/internal/handlers/export_rbw_test.go @@ -0,0 +1,118 @@ +package handlers + +// export_rbw_test.go — re-exports for the resource/billing/webhook/onboarding/ +// admin/readyz coverage slice (_rbw suffix). Kept separate from the shared +// export_test.go to avoid collisions with the concurrent provisioning-arm +// coverage work. + +import ( + "context" + "database/sql" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "instant.dev/common/readiness" +) + +// ── webhook.go re-exports ── + +func WebhookMaxStoredForTest(h *WebhookHandler, tier string) int64 { return h.webhookMaxStored(tier) } + +func StoreEncryptedURLForTest(h *WebhookHandler, ctx context.Context, resourceID uuid.UUID, rURL, requestID string) error { + return h.storeEncryptedURL(ctx, resourceID, rURL, requestID) +} + +func DecryptWebhookURLForTest(h *WebhookHandler, encrypted, requestID string) string { + return h.decryptWebhookURL(encrypted, requestID) +} + +func LookupIdempotentReceiveForTest(h *WebhookHandler, ctx context.Context, token, key string) (fiber.Map, bool) { + return h.lookupIdempotentReceive(ctx, token, key) +} + +func StoreIdempotentReceiveForTest(h *WebhookHandler, ctx context.Context, token, key string, resp fiber.Map, ttl time.Duration) { + h.storeIdempotentReceive(ctx, token, key, resp, ttl) +} + +func VerifyWebhookHMACForTest(secret string, body []byte, header string) bool { + return verifyWebhookHMAC(secret, body, header) +} + +func WebhookRedisForTest(h *WebhookHandler) *redis.Client { return h.rdb } + +func WebhookIdempotencyKeyForTest(token, key string) string { return webhookIdempotencyKey(token, key) } + +// ── onboarding.go re-exports ── + +func IsValidEmailForTest(s string) bool { return isValidEmail(s) } + +func MaskEmailForLogForTest(s string) string { return maskEmailForLog(s) } + +func EmitOnboardingClaimedAuditForTest(db *sql.DB, teamID, userID uuid.UUID, n int, email string) { + emitOnboardingClaimedAudit(db, teamID, userID, n, email) +} + +// claimMailerForTest is a test double for the claimVerificationEmailMailer. +type ClaimMailerForTest struct { + Err error + Called bool +} + +func (m *ClaimMailerForTest) SendMagicLink(ctx context.Context, to, link string) error { + m.Called = true + return m.Err +} + +func SendClaimVerificationEmailForTest(db *sql.DB, mailer *ClaimMailerForTest, email, returnTo string) { + if mailer == nil { + sendClaimVerificationEmail(db, nil, email, returnTo) + return + } + sendClaimVerificationEmail(db, mailer, email, returnTo) +} + +// ── admin_customers.go / admin_promos_audit.go re-exports ── + +func AdminParseTierFilterForTest(raw string) ([]string, bool) { return adminParseTierFilter(raw) } +func AdminParseLimitForTest(raw string, def, max int) int { return adminParseLimit(raw, def, max) } +func AdminParseOffsetForTest(raw string) int { return adminParseOffset(raw) } +func AdminOrderClauseForTest(sortBy string) (string, error) { return adminOrderClause(sortBy) } +func EscapeLikePatternForTest(s string) string { return escapeLikePattern(s) } +func ComputeMRRForTest(h *AdminCustomersHandler, tier string) (int, int) { + return h.computeMRR(tier) +} +func ParsePromoAuditSinceForTest(raw string) (time.Time, error) { return parsePromoAuditSince(raw) } + +// ── billing.go pure-helper re-exports ── + +func FormatChargedAmountForTest(amountMinor int64, currency string) string { + return formatChargedAmount(amountMinor, currency) +} + +// resetOpenAPIOnceForTest resets the cached-prod-spec sync.Once to a fresh +// zero value so a test can re-exercise ServeOpenAPI's Do() body. Assigning a +// zero-value Once is copylocks-clean (no existing lock is copied). +func resetOpenAPIOnceForTest() { openAPISpecOnce = sync.Once{} } + +// CustomerDBCheckForTest exposes the unexported customerDBCheck CheckFunc so a +// test can drive the empty-DSN defensive arm directly (the public path only +// wires the check when CustomerDatabaseURL != ""). +func CustomerDBCheckForTest(h *ReadyzHandler) func(context.Context) readiness.CheckResult { + fn := h.customerDBCheck() + return func(ctx context.Context) readiness.CheckResult { return fn(ctx) } +} + +// StatusToFloatForTest exposes statusToFloat for direct enum-walk coverage. +func StatusToFloatForTest(s readiness.Status) float64 { return statusToFloat(s) } + +// SetReadyzSQLOpenForTest swaps the customer-DB sql.Open seam and returns a +// restore func. +func SetReadyzSQLOpenForTest(fn func(string, string) (*sql.DB, error)) (restore func()) { + prev := readyzSQLOpen + readyzSQLOpen = fn + return func() { readyzSQLOpen = prev } +} diff --git a/internal/handlers/onboarding_rbw_test.go b/internal/handlers/onboarding_rbw_test.go new file mode 100644 index 0000000..820c75a --- /dev/null +++ b/internal/handlers/onboarding_rbw_test.go @@ -0,0 +1,95 @@ +package handlers_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// TestIsValidEmail covers every rejection arm + the accept path. +func TestIsValidEmail(t *testing.T) { + valid := []string{"you@example.com", "a.b+c@sub.example.co.uk", "x@y.z"} + for _, s := range valid { + require.True(t, handlers.IsValidEmailForTest(s), "should accept %q", s) + } + invalid := map[string]string{ + "empty": "", + "inner_space": "you @example.com", + "inner_tab": "you\t@example.com", + "display_form": "Name ", + "angle_no_space": "", // parses but addr != input + "no_at": "notanemail", + "dotless_domain": "user@localhost", + "trailing_dot": "user@example.com.", + "leading_dot_dom": "user@.example.com", + "empty_local": "@example.com", + "too_long": string(make([]byte, 255)) + "@x.com", + } + for name, s := range invalid { + require.False(t, handlers.IsValidEmailForTest(s), "should reject %s (%q)", name, s) + } +} + +// TestMaskEmailForLog covers the masked path + the no-@ fallback. +func TestMaskEmailForLog(t *testing.T) { + require.Equal(t, "y***@example.com", handlers.MaskEmailForLogForTest("you@example.com")) + require.Equal(t, "***", handlers.MaskEmailForLogForTest("no-at-sign")) + require.Equal(t, "***", handlers.MaskEmailForLogForTest("@leadingat.com")) // at index 0 → fallback +} + +// TestEmitOnboardingClaimedAudit covers the insert-success path (real user) + +// the warn arm (closed DB), plus the userID==Nil branch of the NullUUID. +func TestEmitOnboardingClaimedAudit(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "pro")) + // success: valid user UUID + handlers.EmitOnboardingClaimedAuditForTest(db, teamID, uuid.New(), 3, "a@b.com") + // userID == Nil → NullUUID Valid=false branch + handlers.EmitOnboardingClaimedAuditForTest(db, teamID, uuid.Nil, 0, "c@d.com") + // warn arm: closed DB + cleanup() + handlers.EmitOnboardingClaimedAuditForTest(db, teamID, uuid.New(), 1, "e@f.com") +} + +// TestSendClaimVerificationEmail covers nil-mailer no-op, empty-email no-op, +// the send-success path, and the send-failure warn arm. +func TestSendClaimVerificationEmail(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + // nil mailer → no-op + handlers.SendClaimVerificationEmailForTest(db, nil, "x@y.com", "/dash") + + // empty email after normalize → no-op + m0 := &handlers.ClaimMailerForTest{} + handlers.SendClaimVerificationEmailForTest(db, m0, " ", "/dash") + require.False(t, m0.Called, "empty email must not send") + + // success + m1 := &handlers.ClaimMailerForTest{} + handlers.SendClaimVerificationEmailForTest(db, m1, "ok@example.com", "/dash") + require.True(t, m1.Called, "valid email should attempt send") + + // send-failure warn arm + m2 := &handlers.ClaimMailerForTest{Err: assertErr{}} + handlers.SendClaimVerificationEmailForTest(db, m2, "fail@example.com", "/dash") + require.True(t, m2.Called) +} + +// TestSendClaimVerificationEmail_CreateLinkError covers the CreateMagicLink +// failure arm (closed DB → no send attempt). +func TestSendClaimVerificationEmail_CreateLinkError(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + cleanup() // closed pool → CreateMagicLink errors + m := &handlers.ClaimMailerForTest{} + handlers.SendClaimVerificationEmailForTest(db, m, "x@example.com", "/dash") + require.False(t, m.Called, "must not send when magic-link creation fails") +} + +type assertErr struct{} + +func (assertErr) Error() string { return "send boom" } diff --git a/internal/handlers/openapi_rbw_test.go b/internal/handlers/openapi_rbw_test.go new file mode 100644 index 0000000..f74a158 --- /dev/null +++ b/internal/handlers/openapi_rbw_test.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" +) + +// TestSetOpenAPIEnvironment_SetsAndIgnoresEmpty covers SetOpenAPIEnvironment: +// a non-empty value updates the package var; an empty value is a no-op guard. +func TestSetOpenAPIEnvironment_SetsAndIgnoresEmpty(t *testing.T) { + prev := openAPIEnvironment + t.Cleanup(func() { openAPIEnvironment = prev }) + + SetOpenAPIEnvironment("staging") + if openAPIEnvironment != "staging" { + t.Fatalf("SetOpenAPIEnvironment: want staging, got %q", openAPIEnvironment) + } + + // Empty must NOT clobber the existing value. + SetOpenAPIEnvironment("") + if openAPIEnvironment != "staging" { + t.Fatalf("SetOpenAPIEnvironment(\"\"): want unchanged staging, got %q", openAPIEnvironment) + } +} + +// TestServeOpenAPI_DevelopmentServesRaw covers ServeOpenAPI's development +// branch: the unstripped spec (with /internal/set-tier) is served verbatim. +func TestServeOpenAPI_DevelopmentServesRaw(t *testing.T) { + prev := openAPIEnvironment + t.Cleanup(func() { openAPIEnvironment = prev }) + openAPIEnvironment = "development" + + app := fiber.New() + app.Get("/openapi.json", ServeOpenAPI) + resp, err := app.Test(httptest.NewRequest("GET", "/openapi.json", nil), 10000) + if err != nil { + t.Fatalf("Test: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"/internal/set-tier"`) { + t.Error("development ServeOpenAPI must serve the raw spec including /internal/set-tier") + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("content-type: got %q", ct) + } +} + +// TestServeOpenAPI_ProductionServesStripped covers the production branch + +// the sync.Once-cached stripped spec. Two calls exercise both the Do() body +// and the cached fast-path. +func TestServeOpenAPI_ProductionServesStripped(t *testing.T) { + prevEnv := openAPIEnvironment + prevSpec := openAPISpecProd + t.Cleanup(func() { + openAPIEnvironment = prevEnv + openAPISpecProd = prevSpec + // Reset the Once so a later test re-derives the cache cleanly. We + // assign a fresh zero-value Once via a helper to avoid copying a + // lock (go vet copylocks). + resetOpenAPIOnceForTest() + }) + + openAPIEnvironment = "production" + openAPISpecProd = "" + resetOpenAPIOnceForTest() + + app := fiber.New() + app.Get("/openapi.json", ServeOpenAPI) + + for i := 0; i < 2; i++ { // first populates the cache, second hits it + resp, err := app.Test(httptest.NewRequest("GET", "/openapi.json", nil), 10000) + if err != nil { + t.Fatalf("Test #%d: %v", i, err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if strings.Contains(string(body), `"/internal/set-tier"`) { + t.Errorf("production ServeOpenAPI #%d leaked /internal/set-tier", i) + } + } +} + +// TestStripInternalSetTierPath_EdgeGuards covers the early-return guards and +// the brace-mismatch fallback that the existing tests don't reach. +func TestStripInternalSetTierPath_EdgeGuards(t *testing.T) { + key := `"/internal/set-tier"` + cases := []struct { + name, in string + }{ + // key present but no colon anywhere after it → unchanged. + {"no_colon", key}, + // key + colon but no opening brace after the colon → unchanged. + {"no_open_brace", key + `: 123`}, + // key + colon + open brace but never closes → brace-mismatch fallback. + {"unbalanced", key + `: {"a": 1`}, + // escaped quote + escaped backslash inside the value string exercise + // the esc / backslash arms of the brace walker; the entry still + // terminates correctly and is removed. + {"escaped_string", `{"x":1,` + key + `: {"d":"a\"b\\c}{"}}`}, + // braces inside a plain quoted string must not confuse the walker. + {"string_with_braces", `{"x":1,` + key + `: {"d":"a}b{c"}}`}, + } + for _, tc := range cases { + out := stripInternalSetTierPath(tc.in) + switch tc.name { + case "no_colon", "no_open_brace", "unbalanced": + if out != tc.in { + t.Errorf("%s: expected unchanged, got %q", tc.name, out) + } + case "escaped_string", "string_with_braces": + if strings.Contains(out, key) { + t.Errorf("%s: entry not removed: %q", tc.name, out) + } + } + } +} + +// TestStripInternalSetTierPath_MiddleEntryEatsTrailingComma covers the branch +// where the stripped block IS followed by a comma (set-tier is not the last +// path entry) — the helper eats that trailing comma to keep JSON valid. +func TestStripInternalSetTierPath_MiddleEntryEatsTrailingComma(t *testing.T) { + key := `"/internal/set-tier"` + in := `{"paths":{` + key + `:{"post":{}},"/z":{"get":{}}}}` + out := stripInternalSetTierPath(in) + if strings.Contains(out, key) { + t.Fatalf("entry not removed: %q", out) + } + if strings.Contains(out, ":,") || strings.Contains(out, "{,") { + t.Errorf("malformed comma after strip: %q", out) + } + if want := `{"paths":{"/z":{"get":{}}}}`; out != want { + t.Errorf("middle-entry strip: got %q want %q", out, want) + } +} + +// TestStripInternalSetTierPath_LastEntryTrailingComma covers the "last entry" +// branch where the stripped block has no trailing comma, so the helper trims +// the *preceding* comma to keep the surrounding object valid. +func TestStripInternalSetTierPath_LastEntryTrailingComma(t *testing.T) { + key := `"/internal/set-tier"` + // set-tier is the LAST path entry → no trailing comma after its block. + in := `{"paths":{"/a":{"get":{}},` + key + `:{"post":{}}}}` + out := stripInternalSetTierPath(in) + if strings.Contains(out, key) { + t.Fatalf("entry not removed: %q", out) + } + if strings.Contains(out, ",}") { + t.Errorf("dangling comma before close brace: %q", out) + } + if want := `{"paths":{"/a":{"get":{}}}}`; out != want { + t.Errorf("last-entry strip: got %q want %q", out, want) + } +} diff --git a/internal/handlers/readyz.go b/internal/handlers/readyz.go index d7df95d..9492a65 100644 --- a/internal/handlers/readyz.go +++ b/internal/handlers/readyz.go @@ -238,7 +238,7 @@ func (h *ReadyzHandler) customerDBCheck() readiness.CheckFunc { if dsn == "" { return readiness.CheckResult{Status: readiness.StatusFailed, LastError: "customer_db_not_configured"} } - db, err := sql.Open("postgres", dsn) + db, err := readyzSQLOpen("postgres", dsn) if err != nil { return readiness.CheckResult{Status: readiness.StatusFailed, LastError: "open_failed"} } @@ -252,6 +252,12 @@ func (h *ReadyzHandler) customerDBCheck() readiness.CheckFunc { } } +// readyzSQLOpen is a seam over sql.Open for the customer-DB readiness check. +// lib/pq's Open is fully lazy and never errors on a DSN, so the open-failure +// arm of customerDBCheck (a defensive guard for a future eager driver) is +// only reachable in tests via this var. +var readyzSQLOpen = sql.Open + // redisPinger adapts *redis.Client to the readiness.Pinger interface. // We keep the adapter in this file (not common/) so common/ doesn't // pull in go-redis. diff --git a/internal/handlers/readyz_rbw_test.go b/internal/handlers/readyz_rbw_test.go new file mode 100644 index 0000000..68273f2 --- /dev/null +++ b/internal/handlers/readyz_rbw_test.go @@ -0,0 +1,176 @@ +package handlers_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "instant.dev/common/readiness" + "instant.dev/internal/config" + "instant.dev/internal/handlers" +) + +// runReadyz drives the public /readyz handler and returns the decoded body. +func runReadyz(t *testing.T, h *handlers.ReadyzHandler) (int, readiness.Response) { + t.Helper() + app := fiber.New() + app.Get("/readyz", h.Get) + resp, err := app.Test(httptest.NewRequest("GET", "/readyz", nil), 30000) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var got readiness.Response + require.NoError(t, json.Unmarshal(body, &got)) + return resp.StatusCode, got +} + +func readyzCheckByName(r readiness.Response, name string) (readiness.CheckResult, bool) { + for _, c := range r.Checks { + if c.Name == name { + return c, true + } + } + return readiness.CheckResult{}, false +} + +// TestReadyz_CustomerDBCheck_OK covers customerDBCheck happy path (line 251, +// status ok) by calling the CheckFunc directly with a generous context — the +// seam lets us point at the real reachable test DB without competing for the +// runner's 3s overall budget. Skips cleanly when no test DB is configured. +func TestReadyz_CustomerDBCheck_OK(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_CUSTOMERS_URL") + if dsn == "" { + dsn = os.Getenv("TEST_DATABASE_URL") + } + if dsn == "" { + t.Skip("no customer DSN configured") + } + + cfg := &config.Config{Environment: "test", CustomerDatabaseURL: dsn} + h := handlers.NewReadyzHandler(cfg, nil, nil, nil) + + fn := handlers.CustomerDBCheckForTest(h) + res := fn(context.Background()) + require.Equal(t, readiness.StatusOK, res.Status, "reachable customer DB → ok") +} + +// TestReadyz_CustomerDB_PingFailed covers the PingContext failure arm: a +// valid-looking DSN pointing at a closed port (open succeeds lazily, ping +// fails within the 2s timeout). +func TestReadyz_CustomerDB_PingFailed(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true)) + require.NoError(t, err) + defer db.Close() + mock.ExpectPing() + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + cfg := &config.Config{ + Environment: "test", + CustomerDatabaseURL: "postgres://nobody@127.0.0.1:1/x?sslmode=disable&connect_timeout=1", + } + h := handlers.NewReadyzHandler(cfg, db, rdb, nil) + + _, got := runReadyz(t, h) + cdb, ok := readyzCheckByName(got, "customer_db") + require.True(t, ok) + require.Equal(t, readiness.StatusFailed, cdb.Status) + require.Equal(t, "ping_failed", cdb.LastError) +} + +// TestReadyz_NilRedis_FailsRedisCheck covers redisPinger.Ping nil-client arm +// + redisFailedPing.Err()/Error(). A nil *redis.Client makes the redis check +// fail (non-critical → still degraded, not 503-from-redis). +func TestReadyz_NilRedis_FailsRedisCheck(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true)) + require.NoError(t, err) + defer db.Close() + mock.ExpectPing() + + cfg := &config.Config{Environment: "test"} + // nil redis client and nil provisioner + h := handlers.NewReadyzHandler(cfg, db, nil, nil) + + code, got := runReadyz(t, h) + require.Equal(t, http.StatusServiceUnavailable, code) // provisioner_grpc nil → 503 + rc, ok := readyzCheckByName(got, "redis") + require.True(t, ok) + require.Equal(t, readiness.StatusFailed, rc.Status, "nil redis client → redis check fails") +} + +// TestReadyz_CustomerDBCheck_EmptyDSN drives the defensive empty-DSN arm of +// customerDBCheck directly (the public path never wires the check with an +// empty DSN, so this seam is the only way to exercise the guard). +func TestReadyz_CustomerDBCheck_EmptyDSN(t *testing.T) { + cfg := &config.Config{Environment: "test"} // CustomerDatabaseURL == "" + h := handlers.NewReadyzHandler(cfg, nil, nil, nil) + fn := handlers.CustomerDBCheckForTest(h) + res := fn(context.Background()) + require.Equal(t, readiness.StatusFailed, res.Status) + require.Equal(t, "customer_db_not_configured", res.LastError) +} + +// TestReadyz_CustomerDBCheck_OpenFailed drives the sql.Open failure arm via +// the readyzSQLOpen seam (lib/pq's Open is lazy and never errors on a DSN). +func TestReadyz_CustomerDBCheck_OpenFailed(t *testing.T) { + cfg := &config.Config{Environment: "test", CustomerDatabaseURL: "postgres://x"} + h := handlers.NewReadyzHandler(cfg, nil, nil, nil) + restore := handlers.SetReadyzSQLOpenForTest(func(string, string) (*sql.DB, error) { + return nil, errors.New("driver open boom") + }) + defer restore() + fn := handlers.CustomerDBCheckForTest(h) + res := fn(context.Background()) + require.Equal(t, readiness.StatusFailed, res.Status) + require.Equal(t, "open_failed", res.LastError) +} + +// TestReadyz_StatusToFloat_AllArms walks the status→gauge mapping enum. +func TestReadyz_StatusToFloat_AllArms(t *testing.T) { + require.Equal(t, 1.0, handlers.StatusToFloatForTest(readiness.StatusOK)) + require.Equal(t, 0.5, handlers.StatusToFloatForTest(readiness.StatusDegraded)) + require.Equal(t, 0.0, handlers.StatusToFloatForTest(readiness.StatusFailed)) +} + +// TestReadyz_AllUpstreamsConfigured covers the buildChecks branches that add +// brevo / razorpay / do_spaces checks (each gated on config presence). We +// point them at an unreachable host so they fail fast but are still surfaced. +func TestReadyz_AllUpstreamsConfigured(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true)) + require.NoError(t, err) + defer db.Close() + mock.ExpectPing() + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer rdb.Close() + + cfg := &config.Config{ + Environment: "test", + BrevoAPIKey: "test-brevo-key", + RazorpayKeyID: "rzp_test_x", + RazorpayKeySecret: "secret", + ObjectStorePublicURL: "https://s3.example.invalid/bucket", + } + h := handlers.NewReadyzHandler(cfg, db, rdb, nil) + + _, got := runReadyz(t, h) + for _, name := range []string{"brevo", "razorpay", "do_spaces"} { + _, ok := readyzCheckByName(got, name) + require.True(t, ok, "check %q should be surfaced when configured", name) + } +} diff --git a/internal/handlers/resource.go b/internal/handlers/resource.go index 37901a0..f12bf8c 100644 --- a/internal/handlers/resource.go +++ b/internal/handlers/resource.go @@ -45,6 +45,17 @@ func NewResourceHandler(db *sql.DB, rdb *redis.Client, cfg *config.Config, reg * return &ResourceHandler{db: db, rdb: rdb, cfg: cfg, plans: reg, provisioner: prov, storageProvider: storageProv} } +// Seams over the lazy-init driver entry points used by the pause/resume/rotate +// provider helpers. lib/pq's sql.Open and the mongo driver's Connect are both +// lazy and never error on a DSN, so the open/connect error arms in those +// helpers are only reachable in tests by swapping these vars. +var ( + resourcePGOpen = sql.Open + resourceMongoConnect = func(ctx context.Context, uri string) (*mongo.Client, error) { + return mongo.Connect(ctx, mongooptions.Client().ApplyURI(uri).SetServerSelectionTimeout(3*time.Second)) + } +) + // List handles GET /api/v1/resources — lists resources for the authenticated team. // Accepts an optional ?env= query parameter to filter by environment. // Omitting it returns all envs (backward compat with pre-slice-1 callers). @@ -897,7 +908,7 @@ func revokePostgresConnect(ctx context.Context, dsn, dbName, username string) er if err := validateSQLIdent(username); err != nil { return fmt.Errorf("revokePostgresConnect: user: %w", err) } - conn, err := sql.Open("postgres", dsn) + conn, err := resourcePGOpen("postgres", dsn) if err != nil { return fmt.Errorf("revokePostgresConnect: open: %w", err) } @@ -929,7 +940,7 @@ func grantPostgresConnect(ctx context.Context, dsn, dbName, username string) err if err := validateSQLIdent(username); err != nil { return fmt.Errorf("grantPostgresConnect: user: %w", err) } - conn, err := sql.Open("postgres", dsn) + conn, err := resourcePGOpen("postgres", dsn) if err != nil { return fmt.Errorf("grantPostgresConnect: open: %w", err) } @@ -966,8 +977,7 @@ func setRedisACLEnabled(ctx context.Context, originalURL, username string, enabl // the customer DB. The user itself stays — only the role is dropped — so a // resume can re-grant cleanly without recreating the user. func revokeMongoRoles(ctx context.Context, adminURI, username, dbName string) error { - client, err := mongo.Connect(ctx, mongooptions.Client().ApplyURI(adminURI). - SetServerSelectionTimeout(3*time.Second)) + client, err := resourceMongoConnect(ctx, adminURI) if err != nil { return fmt.Errorf("revokeMongoRoles: connect: %w", err) } @@ -993,8 +1003,7 @@ func revokeMongoRoles(ctx context.Context, adminURI, username, dbName string) er // grantMongoRoles is the inverse — re-grants readWrite on the customer DB. func grantMongoRoles(ctx context.Context, adminURI, username, dbName string) error { - client, err := mongo.Connect(ctx, mongooptions.Client().ApplyURI(adminURI). - SetServerSelectionTimeout(3*time.Second)) + client, err := resourceMongoConnect(ctx, adminURI) if err != nil { return fmt.Errorf("grantMongoRoles: connect: %w", err) } @@ -1144,7 +1153,7 @@ func resourceToMap(r *models.Resource, reg *plans.Registry) fiber.Map { // new password. The username is derived from our own token — no user input — // so fmt.Sprintf is safe here. func rotatePostgresPassword(ctx context.Context, dsn, username, newPassword string) error { - db, err := sql.Open("postgres", dsn) + db, err := resourcePGOpen("postgres", dsn) if err != nil { return fmt.Errorf("rotatePostgresPassword: open: %w", err) } @@ -1189,8 +1198,7 @@ func rotateRedisPassword(ctx context.Context, originalURL, username, newPassword // Connects using the admin URI and runs updateUser on the admin database // (where users are created by the provisioner). func rotateMongoPassword(ctx context.Context, adminURI, username, newPassword string) error { - client, err := mongo.Connect(ctx, mongooptions.Client().ApplyURI(adminURI). - SetServerSelectionTimeout(3*time.Second)) + client, err := resourceMongoConnect(ctx, adminURI) if err != nil { return fmt.Errorf("rotateMongoPassword: connect: %w", err) } diff --git a/internal/handlers/resource_audit_rbw_test.go b/internal/handlers/resource_audit_rbw_test.go new file mode 100644 index 0000000..cd726c3 --- /dev/null +++ b/internal/handlers/resource_audit_rbw_test.go @@ -0,0 +1,79 @@ +package handlers + +// Direct coverage for the three best-effort audit emitters in resource.go. +// They are unexported and run as detached goroutines from the handlers, so +// they're exercised here directly. Cannot import testhelpers (it imports this +// package → cycle), so the DB is opened raw against TEST_DATABASE_URL; the +// schema is already migrated by the package's external testhelpers-driven +// tests sharing the same instant_dev_test database. + +import ( + "context" + "database/sql" + "os" + "testing" + "time" + + "github.com/google/uuid" + _ "github.com/lib/pq" +) + +func openAuditTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Skipf("open: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + db.Close() + t.Skipf("ping: %v", err) + } + return db +} + +func mustTeamRow(t *testing.T, db *sql.DB) uuid.UUID { + t.Helper() + var id string + if err := db.QueryRowContext(context.Background(), + `INSERT INTO teams (name, plan_tier) VALUES ($1,'pro') RETURNING id::text`, + "rbw-audit-"+uuid.NewString()[:8], + ).Scan(&id); err != nil { + t.Skipf("insert team (schema not migrated?): %v", err) + } + return uuid.MustParse(id) +} + +func TestEmitAuditFns_Success_RBW(t *testing.T) { + db := openAuditTestDB(t) + defer db.Close() + teamID := mustTeamRow(t, db) + userID := uuid.NewString() // valid UUID → user-actor arm + resID := uuid.New() + + emitResourceReadAudit(db, teamID, userID, resID, "postgres") + emitResourceListByTeamAudit(db, teamID, userID, 3, "production") + emitConnectionURLDecryptedAudit(db, teamID, userID, resID, "customer_reveal") + + // Non-UUID userID → parse guard false branch (actor stays system). + emitResourceReadAudit(db, teamID, "not-a-uuid", resID, "redis") + emitResourceListByTeamAudit(db, teamID, "not-a-uuid", 0, "") + emitConnectionURLDecryptedAudit(db, teamID, "not-a-uuid", resID, "credential_rotation") +} + +func TestEmitAuditFns_InsertError_RBW(t *testing.T) { + db := openAuditTestDB(t) + teamID := mustTeamRow(t, db) + db.Close() // close pool → InsertAuditEvent errors → warn arm + + userID := uuid.NewString() + resID := uuid.New() + emitResourceReadAudit(db, teamID, userID, resID, "postgres") + emitResourceListByTeamAudit(db, teamID, userID, 1, "production") + emitConnectionURLDecryptedAudit(db, teamID, userID, resID, "customer_reveal") +} diff --git a/internal/handlers/resource_handlers_rbw_test.go b/internal/handlers/resource_handlers_rbw_test.go new file mode 100644 index 0000000..d7d5595 --- /dev/null +++ b/internal/handlers/resource_handlers_rbw_test.go @@ -0,0 +1,455 @@ +package handlers_test + +// resource_handlers_rbw_test.go — error-branch coverage for the resource +// lifecycle handler methods (List/Get/Delete/GetCredentials/RotateCredentials/ +// Pause/Resume). The happy paths are covered by the full-backend lifecycle +// tests; this file drives the unauthorized / invalid-id / not-found / +// cross-team / DB-error arms that those flows don't reach. +// +// Auth is injected via a Locals-shim middleware (not RequireAuth) so we can +// set an empty / malformed team_id to hit the handler's own parseTeamID arm, +// which RequireAuth would otherwise short-circuit before the handler runs. + +import ( + "context" + "database/sql" + "errors" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "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" + "instant.dev/internal/testhelpers" +) + +// mustEncryptForTest encrypts plaintext with the shared test AES key. +func mustEncryptForTest(t *testing.T, plain string) string { + t.Helper() + key, err := crypto.ParseAESKey(testhelpers.TestAESKeyHex) + require.NoError(t, err) + enc, err := crypto.Encrypt(key, plain) + require.NoError(t, err) + return enc +} + +// mustInsertResourceWithURLStatus inserts a resource with both an (encrypted) +// connection_url and an explicit status. +func mustInsertResourceWithURLStatus(t *testing.T, db *sql.DB, teamID, resType, encURL, status string) string { + t.Helper() + var token string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO resources (team_id, resource_type, tier, status, connection_url) + VALUES ($1::uuid, $2, 'pro', $3, $4) RETURNING token::text`, + teamID, resType, status, encURL, + ).Scan(&token)) + return token +} + +// mustInsertResource inserts an active resource for a team and returns its +// token (UUID string). +func mustInsertResource(t *testing.T, db *sql.DB, teamID, resType string) string { + t.Helper() + var token string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO resources (team_id, resource_type, tier, status) + VALUES ($1::uuid, $2, 'pro', 'active') RETURNING token::text`, + teamID, resType, + ).Scan(&token)) + return token +} + +// localsApp mounts the resource routes behind a shim that sets team_id/user_id +// Locals to the given values (empty string = "no auth"). +func localsApp(t *testing.T, h *handlers.ResourceHandler, teamID, userID string) *fiber.App { + t.Helper() + app := fiber.New(fiber.Config{ErrorHandler: rbwErrorHandler}) + app.Use(middleware.RequestID()) + app.Use(func(c *fiber.Ctx) error { + if teamID != "" { + c.Locals(middleware.LocalKeyTeamID, teamID) + } + if userID != "" { + c.Locals(middleware.LocalKeyUserID, userID) + } + return c.Next() + }) + app.Get("/r/:id", h.Get) + app.Get("/r", h.List) + app.Delete("/r/:id", h.Delete) + app.Get("/r/:id/creds", h.GetCredentials) + app.Post("/r/:id/rotate", h.RotateCredentials) + app.Post("/r/:id/pause", h.Pause) + app.Post("/r/:id/resume", h.Resume) + return app +} + +func rbwErrorHandler(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return fiber.DefaultErrorHandler(c, err) +} + +func doReqRBW(t *testing.T, app *fiber.App, method, path string) int { + t.Helper() + resp, err := app.Test(httptest.NewRequest(method, path, nil), 10000) + require.NoError(t, err) + resp.Body.Close() + return resp.StatusCode +} + +func newResourceHandlerForTest(t *testing.T) (*handlers.ResourceHandler, func()) { + t.Helper() + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + return h, func() { dbClean(); rClean() } +} + +// mustInsertResourceWithURL inserts an active resource with a connection_url +// (already-encrypted ciphertext supplied by the caller). +func mustInsertResourceWithURL(t *testing.T, db *sql.DB, teamID, resType, encURL string) string { + t.Helper() + var token string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO resources (team_id, resource_type, tier, status, connection_url) + VALUES ($1::uuid, $2, 'pro', 'active', $3) RETURNING token::text`, + teamID, resType, encURL, + ).Scan(&token)) + return token +} + +// TestGetCredentials_NoConnectionURL covers the no_connection_url 400 arm. +func TestGetCredentials_NoConnectionURL(t *testing.T) { + h, clean := newResourceHandlerForTest(t) + defer clean() + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + team := testhelpers.MustCreateTeamDB(t, db, "pro") + token := mustInsertResource(t, db, team, "postgres") // null connection_url + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusBadRequest, doReqRBW(t, app, "GET", "/r/"+token+"/creds")) + require.Equal(t, fiber.StatusBadRequest, doReqRBW(t, app, "POST", "/r/"+token+"/rotate")) +} + +// TestGetCredentials_AESKeyInvalid covers the aes_key_invalid 500 arm of both +// GetCredentials and RotateCredentials (handler configured with a junk key). +func TestGetCredentials_AESKeyInvalid(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: "not-a-valid-hex-key"} + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + + team := testhelpers.MustCreateTeamDB(t, db, "pro") + token := mustInsertResourceWithURL(t, db, team, "postgres", "ciphertext-doesnt-matter") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusInternalServerError, doReqRBW(t, app, "GET", "/r/"+token+"/creds")) + require.Equal(t, fiber.StatusInternalServerError, doReqRBW(t, app, "POST", "/r/"+token+"/rotate")) +} + +// TestGetCredentials_DecryptFailed covers the decrypt_failed 500 arm: a valid +// AES key but ciphertext that wasn't produced by it. +func TestGetCredentials_DecryptFailed(t *testing.T) { + h, clean := newResourceHandlerForTest(t) // uses TestAESKeyHex + defer clean() + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + team := testhelpers.MustCreateTeamDB(t, db, "pro") + token := mustInsertResourceWithURL(t, db, team, "postgres", "deadbeefnotcipher") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusInternalServerError, doReqRBW(t, app, "GET", "/r/"+token+"/creds")) + require.Equal(t, fiber.StatusInternalServerError, doReqRBW(t, app, "POST", "/r/"+token+"/rotate")) +} + +// mustInsertResourceWithStatus inserts a resource with a specific status. +func mustInsertResourceWithStatus(t *testing.T, db *sql.DB, teamID, resType, status string) string { + t.Helper() + var token string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO resources (team_id, resource_type, tier, status) + VALUES ($1::uuid, $2, 'pro', $3) RETURNING token::text`, + teamID, resType, status, + ).Scan(&token)) + return token +} + +// pauseResumeFixture wires a handler whose CustomerDatabaseURL/MongoAdminURI +// are empty, so pauseProvider/resumeProvider no-op for postgres — letting the +// DB-flip success path run without a live backend. +func pauseResumeFixture(t *testing.T, planTier string) (*handlers.ResourceHandler, *sql.DB, string) { + t.Helper() + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { dbClean(); rClean() }) + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} // no CustomerDatabaseURL + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + team := testhelpers.MustCreateTeamDB(t, db, planTier) + return h, db, team +} + +// TestPause_Success covers the Pause happy path (provider no-op for postgres +// with no CustomerDatabaseURL → DB flip → 200). +func TestPause_Success(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "active") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+token+"/pause")) +} + +// TestResume_Success covers the Resume happy path (paused → active). +func TestResume_Success(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "paused") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+token+"/resume")) +} + +// TestPause_AlreadyPaused covers the already_paused 409 arm. +func TestPause_AlreadyPaused(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "paused") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusConflict, doReqRBW(t, app, "POST", "/r/"+token+"/pause")) +} + +// TestPause_InvalidState covers the invalid_state 409 arm (deleted resource). +func TestPause_InvalidState(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "deleted") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusConflict, doReqRBW(t, app, "POST", "/r/"+token+"/pause")) +} + +// TestPause_TierGate covers the pause upgrade-required arm for a hobby team. +func TestPause_TierGate(t *testing.T) { + h, db, team := pauseResumeFixture(t, "hobby") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "active") + app := localsApp(t, h, team, uuid.NewString()) + code := doReqRBW(t, app, "POST", "/r/"+token+"/pause") + require.Equal(t, fiber.StatusPaymentRequired, code, "hobby pause should require upgrade") +} + +// TestPauseResume_RedisMongo_NoOpArms covers pauseProvider/resumeProvider's +// redis (empty-URL no-op) and mongo (empty-MongoAdminURI no-op) branches via +// the no-backend fixture. Redis resources have no connection_url here so +// decryptOrEmpty returns "" → the no-op return; mongo has MongoAdminURI=="". +func TestPauseResume_RedisMongo_NoOpArms(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + app := localsApp(t, h, team, uuid.NewString()) + + // redis active → pause (decryptOrEmpty=="" no-op) → 200 + rtok := mustInsertResourceWithStatus(t, db, team, "redis", "active") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+rtok+"/pause")) + + // mongodb active → pause (MongoAdminURI=="" no-op) → 200 + mtok := mustInsertResourceWithStatus(t, db, team, "mongodb", "active") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+mtok+"/pause")) + + // redis/mongo paused → resume → 200 (inverse no-op arms) + rtok2 := mustInsertResourceWithStatus(t, db, team, "redis", "paused") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+rtok2+"/resume")) + mtok2 := mustInsertResourceWithStatus(t, db, team, "mongodb", "paused") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+mtok2+"/resume")) + + // storage/queue/webhook → default no-op arm + stok := mustInsertResourceWithStatus(t, db, team, "storage", "active") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+stok+"/pause")) +} + +// TestRotate_ProviderWarnArms covers RotateCredentials' non-fatal provider +// rotate arms for postgres/redis/mongo: the backend rotate fails (unreachable) +// but the handler still persists the new URL and returns 200. +func TestRotate_ProviderWarnArms(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { dbClean(); rClean() }) + cfg := &config.Config{ + Environment: "test", + AESKey: testhelpers.TestAESKeyHex, + CustomerDatabaseURL: "postgres://nobody@127.0.0.1:1/x?sslmode=disable&connect_timeout=1", + MongoAdminURI: "mongodb://127.0.0.1:1/?serverSelectionTimeoutMS=300", + } + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + team := testhelpers.MustCreateTeamDB(t, db, "pro") + app := localsApp(t, h, team, uuid.NewString()) + + // postgres: rotatePostgresPassword warns (unreachable) → still 200 + pgEnc := mustEncryptForTest(t, "postgres://usr_x:pw@127.0.0.1:1/db_x") + pgTok := mustInsertResourceWithURLStatus(t, db, team, "postgres", pgEnc, "active") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+pgTok+"/rotate")) + + // redis: rotateRedisPassword warns (unreachable host) → still 200 + rEnc := mustEncryptForTest(t, "redis://usr_x:pw@127.0.0.1:1/0") + rTok := mustInsertResourceWithURLStatus(t, db, team, "redis", rEnc, "active") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+rTok+"/rotate")) + + // mongodb: rotateMongoPassword warns (unreachable) → still 200 + mEnc := mustEncryptForTest(t, "mongodb://usr_x:pw@127.0.0.1:1/db_x") + mTok := mustInsertResourceWithURLStatus(t, db, team, "mongodb", mEnc, "active") + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "POST", "/r/"+mTok+"/rotate")) +} + +// TestResume_NotPaused covers the not_paused 409 arm (active resource). +func TestResume_NotPaused(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "active") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusConflict, doReqRBW(t, app, "POST", "/r/"+token+"/resume")) +} + +// TestDelete_Success covers the Delete happy path (soft-delete, nil provisioner +// → deprovision skipped, 200). +func TestDelete_Success(t *testing.T) { + h, db, team := pauseResumeFixture(t, "pro") + token := mustInsertResourceWithStatus(t, db, team, "postgres", "active") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusOK, doReqRBW(t, app, "DELETE", "/r/"+token)) +} + +// TestPause_ProviderFailed covers the provider_failed 503 arm: a postgres +// resource with a CustomerDatabaseURL pointing at a closed port makes +// pauseProvider's revokePostgresConnect fail, so the DB row stays active and +// the caller gets 503 (the iron-rule atomicity guarantee). +func TestPause_ProviderFailed(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { dbClean(); rClean() }) + cfg := &config.Config{ + Environment: "test", + AESKey: testhelpers.TestAESKeyHex, + CustomerDatabaseURL: "postgres://nobody@127.0.0.1:1/x?sslmode=disable&connect_timeout=1", + } + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + team := testhelpers.MustCreateTeamDB(t, db, "pro") + // Needs a decryptable connection_url so pauseProvider extracts a username + // and reaches revokePostgresConnect against the unreachable customer DB. + enc := mustEncryptForTest(t, "postgres://usr_x:pw@host:5432/db_x") + token := mustInsertResourceWithURLStatus(t, db, team, "postgres", enc, "active") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "POST", "/r/"+token+"/pause")) +} + +// TestResume_ProviderFailed mirrors TestPause_ProviderFailed for resume. +func TestResume_ProviderFailed(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + t.Cleanup(func() { dbClean(); rClean() }) + cfg := &config.Config{ + Environment: "test", + AESKey: testhelpers.TestAESKeyHex, + CustomerDatabaseURL: "postgres://nobody@127.0.0.1:1/x?sslmode=disable&connect_timeout=1", + } + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + team := testhelpers.MustCreateTeamDB(t, db, "pro") + enc := mustEncryptForTest(t, "postgres://usr_x:pw@host:5432/db_x") + token := mustInsertResourceWithURLStatus(t, db, team, "postgres", enc, "paused") + app := localsApp(t, h, team, uuid.NewString()) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "POST", "/r/"+token+"/resume")) +} + +// TestResourceMethods_Unauthorized covers the parseTeamID unauthorized arm of +// every method (empty team_id Local). +func TestResourceMethods_Unauthorized(t *testing.T) { + h, clean := newResourceHandlerForTest(t) + defer clean() + app := localsApp(t, h, "", "") + id := uuid.NewString() + cases := []struct { + method, path string + }{ + {"GET", "/r"}, + {"GET", "/r/" + id}, + {"DELETE", "/r/" + id}, + {"GET", "/r/" + id + "/creds"}, + {"POST", "/r/" + id + "/rotate"}, + {"POST", "/r/" + id + "/pause"}, + {"POST", "/r/" + id + "/resume"}, + } + for _, tc := range cases { + require.Equal(t, fiber.StatusUnauthorized, doReqRBW(t, app, tc.method, tc.path), + "%s %s should be 401 with no team_id", tc.method, tc.path) + } +} + +// TestResourceMethods_InvalidUUID covers the uuid.Parse invalid_id arm. +func TestResourceMethods_InvalidUUID(t *testing.T) { + h, clean := newResourceHandlerForTest(t) + defer clean() + app := localsApp(t, h, uuid.NewString(), uuid.NewString()) + for _, p := range []string{"/r/not-a-uuid", "/r/not-a-uuid/creds"} { + require.Equal(t, fiber.StatusBadRequest, doReqRBW(t, app, "GET", p), "GET %s", p) + } + for _, p := range []string{"/r/not-a-uuid", "/r/not-a-uuid/rotate", "/r/not-a-uuid/pause", "/r/not-a-uuid/resume"} { + m := "POST" + if p == "/r/not-a-uuid" { + m = "DELETE" + } + require.Equal(t, fiber.StatusBadRequest, doReqRBW(t, app, m, p), "%s %s", m, p) + } +} + +// TestResourceMethods_NotFound covers the not_found arm (valid UUID, no row). +func TestResourceMethods_NotFound(t *testing.T) { + h, clean := newResourceHandlerForTest(t) + defer clean() + app := localsApp(t, h, uuid.NewString(), uuid.NewString()) + id := uuid.NewString() + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "GET", "/r/"+id)) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "DELETE", "/r/"+id)) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "GET", "/r/"+id+"/creds")) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "POST", "/r/"+id+"/rotate")) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "POST", "/r/"+id+"/pause")) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "POST", "/r/"+id+"/resume")) +} + +// TestResourceMethods_DBError covers the fetch_failed / list_failed arms by +// closing the platform DB pool so every query errors. +func TestResourceMethods_DBError(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + h := handlers.NewResourceHandler(db, rdb, cfg, plans.Default(), nil, nil) + dbClean() // close the pool — every query now errors + + app := localsApp(t, h, uuid.NewString(), uuid.NewString()) + id := uuid.NewString() + // List → list_failed (503) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "GET", "/r")) + // Get/Delete/GetCredentials → fetch_failed (503) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "GET", "/r/"+id)) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "DELETE", "/r/"+id)) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "GET", "/r/"+id+"/creds")) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "POST", "/r/"+id+"/rotate")) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "POST", "/r/"+id+"/pause")) + require.Equal(t, fiber.StatusServiceUnavailable, doReqRBW(t, app, "POST", "/r/"+id+"/resume")) +} + +// TestResourceMethods_CrossTeam covers the cross-team 404 arm: a resource that +// belongs to a different team must 404 (never 403) for the requesting team. +func TestResourceMethods_CrossTeam(t *testing.T) { + h, clean := newResourceHandlerForTest(t) + defer clean() + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + + ownerTeam := testhelpers.MustCreateTeamDB(t, db, "pro") + token := mustInsertResource(t, db, ownerTeam, "postgres") + + // Request as a DIFFERENT team. + otherTeam := uuid.NewString() + app := localsApp(t, h, otherTeam, uuid.NewString()) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "GET", "/r/"+token)) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "DELETE", "/r/"+token)) + require.Equal(t, fiber.StatusNotFound, doReqRBW(t, app, "GET", "/r/"+token+"/creds")) +} diff --git a/internal/handlers/resource_providers_rbw_test.go b/internal/handlers/resource_providers_rbw_test.go new file mode 100644 index 0000000..2fe8b09 --- /dev/null +++ b/internal/handlers/resource_providers_rbw_test.go @@ -0,0 +1,269 @@ +package handlers + +// resource_providers_rbw_test.go — direct coverage for the unexported +// pause/resume/rotate provider helpers in resource.go. These talk to a real +// Postgres / Redis / Mongo, so success paths run against the isolated test +// infra (skipped when the corresponding TEST_* env var is unset) and the +// validation / connection / command-error arms run with no infra. +// +// Rule-17 coverage block: +// Symptom: revoke/grantPostgresConnect, setRedisACLEnabled, +// revoke/grantMongoRoles, rotate{Postgres,Redis,Mongo}Password +// all 60-75% (open/exec/validate arms uncovered). +// Enumeration: go tool cover -func | grep resource.go | awk '$NF<95' +// Sites touched: all 8 helpers + validateSQLIdent. +// Coverage test: this file. + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + "time" + + "go.mongodb.org/mongo-driver/mongo" +) + +func customersDSN(t *testing.T) string { + t.Helper() + dsn := os.Getenv("TEST_POSTGRES_CUSTOMERS_URL") + if dsn == "" { + dsn = os.Getenv("TEST_DATABASE_URL") + } + return dsn +} + +func redisURL(t *testing.T) string { + t.Helper() + u := os.Getenv("TEST_REDIS_URL") + return u +} + +func mongoURI() string { return os.Getenv("TEST_MONGO_URI") } + +// ---- validateSQLIdent ---- + +func TestValidateSQLIdent_RBW(t *testing.T) { + if err := validateSQLIdent(""); err == nil { + t.Error("empty ident should error") + } + if err := validateSQLIdent("ok_name-1"); err != nil { + t.Errorf("valid ident rejected: %v", err) + } + for _, bad := range []string{"Drop Table", "a;b", `x"y`, "café"} { + if err := validateSQLIdent(bad); err == nil { + t.Errorf("unsafe ident %q accepted", bad) + } + } +} + +// ---- revokePostgresConnect / grantPostgresConnect ---- + +func TestRevokeGrantPostgresConnect_ValidationArms_RBW(t *testing.T) { + ctx := context.Background() + const dsn = "postgres://x" // never reached — validation fails first + if err := revokePostgresConnect(ctx, dsn, "bad name", "user"); err == nil || !strings.Contains(err.Error(), "db:") { + t.Errorf("revoke: expected db-ident error, got %v", err) + } + if err := revokePostgresConnect(ctx, dsn, "okdb", "bad user"); err == nil || !strings.Contains(err.Error(), "user:") { + t.Errorf("revoke: expected user-ident error, got %v", err) + } + if err := grantPostgresConnect(ctx, dsn, "bad name", "user"); err == nil || !strings.Contains(err.Error(), "db:") { + t.Errorf("grant: expected db-ident error, got %v", err) + } + if err := grantPostgresConnect(ctx, dsn, "okdb", "bad user"); err == nil || !strings.Contains(err.Error(), "user:") { + t.Errorf("grant: expected user-ident error, got %v", err) + } +} + +func TestRevokeGrantPostgresConnect_ExecError_RBW(t *testing.T) { + dsn := customersDSN(t) + if dsn == "" { + t.Skip("no customer DSN") + } + ctx := context.Background() + // REVOKE/GRANT against a non-existent role → Postgres errors → exec arm. + if err := revokePostgresConnect(ctx, dsn, "db-rbwtest", "no-such-role-xyz"); err == nil { + t.Error("revoke on missing role should error at REVOKE") + } + if err := grantPostgresConnect(ctx, dsn, "db-rbwtest", "no-such-role-xyz"); err == nil { + t.Error("grant on missing role should error at GRANT") + } +} + +func TestRevokeGrantPostgresConnect_Success_RBW(t *testing.T) { + dsn := customersDSN(t) + if dsn == "" { + t.Skip("no customer DSN") + } + ctx := context.Background() + // db_rbwtest + role usr_rbwtest are created by the test harness setup. + if err := grantPostgresConnect(ctx, dsn, "db_rbwtest", "usr_rbwtest"); err != nil { + t.Skipf("grant success path needs db_rbwtest+usr_rbwtest: %v", err) + } + // revoke also exercises the pg_terminate_backend follow-up (warn arm is + // best-effort; the function returns nil regardless). + if err := revokePostgresConnect(ctx, dsn, "db_rbwtest", "usr_rbwtest"); err != nil { + t.Errorf("revoke success: %v", err) + } +} + +// ---- setRedisACLEnabled / rotateRedisPassword ---- + +func TestSetRedisACLEnabled_ParseError_RBW(t *testing.T) { + if err := setRedisACLEnabled(context.Background(), "://not-a-redis-url", "u", true); err == nil { + t.Error("expected parse error") + } + if err := rotateRedisPassword(context.Background(), "://bad", "u", "p"); err == nil { + t.Error("rotateRedisPassword: expected parse error") + } +} + +func TestSetRedisACLEnabled_Success_RBW(t *testing.T) { + u := redisURL(t) + if u == "" { + t.Skip("no redis URL") + } + ctx := context.Background() + const user = "usr_rbwtest" + // usr_rbwtest is created by the harness; toggle off then on. + if err := setRedisACLEnabled(ctx, u, user, false); err != nil { + t.Skipf("redis ACL setuser off (needs usr_rbwtest): %v", err) + } + if err := setRedisACLEnabled(ctx, u, user, true); err != nil { + t.Errorf("redis ACL setuser on: %v", err) + } + if err := rotateRedisPassword(ctx, u, user, "newpass123"); err != nil { + t.Errorf("rotateRedisPassword success: %v", err) + } +} + +// ---- rotatePostgresPassword ---- + +func TestRotatePostgresPassword_RBW(t *testing.T) { + ctx := context.Background() + // unsafe username arm (open succeeds lazily, validation rejects). + dsn := customersDSN(t) + if dsn == "" { + dsn = "postgres://x" + } + if err := rotatePostgresPassword(ctx, dsn, "bad user", "p"); err == nil || !strings.Contains(err.Error(), "unsafe username") { + t.Errorf("expected unsafe-username error, got %v", err) + } + if customersDSN(t) == "" { + t.Skip("no customer DSN for ALTER ROLE arms") + } + // ALTER ROLE on missing role → exec error arm. + if err := rotatePostgresPassword(ctx, dsn, "no_such_role_xyz", "p1"); err == nil { + t.Error("ALTER ROLE on missing role should error") + } + // success against the harness role. + if err := rotatePostgresPassword(ctx, dsn, "usr_rbwtest", "newpw1"); err != nil { + t.Skipf("ALTER ROLE success needs usr_rbwtest: %v", err) + } +} + +// ---- mongo helpers ---- + +func TestMongoRoleHelpers_RBW(t *testing.T) { + uri := mongoURI() + if uri == "" { + t.Skip("no mongo URI") + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + // These run RunCommand against a non-existent user → command error arm + // (connect succeeds, the admin command fails). That covers the result.Err + // branch of all three helpers without needing a provisioned mongo user. + if err := revokeMongoRoles(ctx, uri, "no_such_user_xyz", "db_x"); err == nil { + t.Error("revokeMongoRoles on missing user should error") + } + if err := grantMongoRoles(ctx, uri, "no_such_user_xyz", "db_x"); err == nil { + t.Error("grantMongoRoles on missing user should error") + } + if err := rotateMongoPassword(ctx, uri, "no_such_user_xyz", "p"); err == nil { + t.Error("rotateMongoPassword on missing user should error") + } +} + +// TestMongoRoleHelpers_Success_RBW covers the success-return arm of all three +// mongo helpers against a real provisioned user (created by the harness). +func TestMongoRoleHelpers_Success_RBW(t *testing.T) { + uri := mongoURI() + if uri == "" { + t.Skip("no mongo URI") + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + const ( + user = "usr_rbwtest" + dbN = "db_rbwtest" + ) + if err := grantMongoRoles(ctx, uri, user, dbN); err != nil { + t.Skipf("grantMongoRoles success needs %s (admin user): %v", user, err) + } + if err := revokeMongoRoles(ctx, uri, user, dbN); err != nil { + t.Errorf("revokeMongoRoles success: %v", err) + } + if err := rotateMongoPassword(ctx, uri, user, "newpw1"); err != nil { + t.Errorf("rotateMongoPassword success: %v", err) + } +} + +func TestMongoHelpers_ConnectError_RBW(t *testing.T) { + // An unreachable URI with a tiny server-selection timeout exercises the + // command-error arm quickly (mongo.Connect is lazy; the RunCommand fails + // on server selection). + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + const uri = "mongodb://127.0.0.1:1/?serverSelectionTimeoutMS=500" + if err := revokeMongoRoles(ctx, uri, "u", "db_x"); err == nil { + t.Error("revokeMongoRoles unreachable should error") + } + if err := grantMongoRoles(ctx, uri, "u", "db_x"); err == nil { + t.Error("grantMongoRoles unreachable should error") + } + if err := rotateMongoPassword(ctx, uri, "u", "p"); err == nil { + t.Error("rotateMongoPassword unreachable should error") + } +} + +// TestPGHelpers_OpenError_RBW drives the resourcePGOpen failure arm of the +// three postgres helpers (lib/pq's Open is lazy → only reachable via the seam). +func TestPGHelpers_OpenError_RBW(t *testing.T) { + prev := resourcePGOpen + resourcePGOpen = func(string, string) (*sql.DB, error) { return nil, errors.New("pg open boom") } + defer func() { resourcePGOpen = prev }() + ctx := context.Background() + if err := revokePostgresConnect(ctx, "x", "okdb", "okuser"); err == nil || !strings.Contains(err.Error(), "open") { + t.Errorf("revoke open arm: %v", err) + } + if err := grantPostgresConnect(ctx, "x", "okdb", "okuser"); err == nil || !strings.Contains(err.Error(), "open") { + t.Errorf("grant open arm: %v", err) + } + if err := rotatePostgresPassword(ctx, "x", "okuser", "p"); err == nil || !strings.Contains(err.Error(), "open") { + t.Errorf("rotate open arm: %v", err) + } +} + +// TestMongoHelpers_ConnectSeamError_RBW drives the resourceMongoConnect failure +// arm (mongo.Connect is lazy → only reachable via the seam). +func TestMongoHelpers_ConnectSeamError_RBW(t *testing.T) { + prev := resourceMongoConnect + resourceMongoConnect = func(context.Context, string) (*mongo.Client, error) { + return nil, errors.New("mongo connect boom") + } + defer func() { resourceMongoConnect = prev }() + ctx := context.Background() + if err := revokeMongoRoles(ctx, "x", "u", "db"); err == nil || !strings.Contains(err.Error(), "connect") { + t.Errorf("revoke connect arm: %v", err) + } + if err := grantMongoRoles(ctx, "x", "u", "db"); err == nil || !strings.Contains(err.Error(), "connect") { + t.Errorf("grant connect arm: %v", err) + } + if err := rotateMongoPassword(ctx, "x", "u", "p"); err == nil || !strings.Contains(err.Error(), "connect") { + t.Errorf("rotate connect arm: %v", err) + } +} diff --git a/internal/handlers/webhook_rbw_test.go b/internal/handlers/webhook_rbw_test.go new file mode 100644 index 0000000..7dcb17e --- /dev/null +++ b/internal/handlers/webhook_rbw_test.go @@ -0,0 +1,207 @@ +package handlers_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "os" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/crypto" + "instant.dev/internal/handlers" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +func newWebhookHandlerForTest(t *testing.T) (*handlers.WebhookHandler, func()) { + t.Helper() + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + h := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + return h, func() { dbClean(); rClean() } +} + +// TestWebhookMaxStored covers all three arms: unlimited (-1 → 10000), the +// configured-positive value, and the safe floor. +func TestWebhookMaxStored(t *testing.T) { + h, clean := newWebhookHandlerForTest(t) + defer clean() + // team tier → unlimited webhook stored → 10000 cap + require.Equal(t, int64(10_000), handlers.WebhookMaxStoredForTest(h, "team")) + // hobby → a finite positive cap (1000 per plans.yaml) + require.Greater(t, handlers.WebhookMaxStoredForTest(h, "hobby"), int64(0)) + // unknown tier → falls back to anonymous (100) via the int64(n) path + require.Equal(t, int64(100), handlers.WebhookMaxStoredForTest(h, "no_such_tier")) +} + +// TestWebhookMaxStored_FloorArm covers the n<=0 safe-floor branch via a custom +// plans registry whose tier has webhook_requests_stored: 0. +func TestWebhookMaxStored_FloorArm(t *testing.T) { + limits := ` + limits: + provisions_per_day: 5 + postgres_storage_mb: 10 + postgres_connections: 2 + redis_memory_mb: 5 + mongodb_storage_mb: 5 + mongodb_connections: 2 + webhook_requests_stored: 0` + yaml := ` +plans: + anonymous: + display_name: "Anonymous" + price_monthly_cents: 0` + limits + ` + zerohook: + display_name: "ZeroHook" + price_monthly_cents: 0` + limits + ` +` + dir := t.TempDir() + path := dir + "/plans.yaml" + require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) + reg, err := plans.Load(path) + require.NoError(t, err) + + 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, reg) + require.Equal(t, int64(100), handlers.WebhookMaxStoredForTest(h, "zerohook"), "0-stored tier → safe floor 100") +} + +// TestStoreEncryptedURL covers the success path + the update-failure arm +// (closed DB) + the AES-key-parse arm (junk key). +func TestStoreEncryptedURL(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + h := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + + team := testhelpers.MustCreateTeamDB(t, db, "pro") + var resID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO resources (team_id, resource_type, tier, status) VALUES ($1::uuid,'webhook','pro','active') RETURNING id::text`, + team).Scan(&resID)) + + // success + require.NoError(t, handlers.StoreEncryptedURLForTest(h, context.Background(), + uuid.MustParse(resID), "https://hook.example/x", "req-1")) + + // update-failure: close the pool + dbClean() + require.Error(t, handlers.StoreEncryptedURLForTest(h, context.Background(), + uuid.MustParse(resID), "https://hook.example/y", "req-2")) + + // AES-key-parse failure + badCfg := &config.Config{Environment: "test", AESKey: "not-hex"} + rdb2, r2Clean := testhelpers.SetupTestRedis(t) + defer r2Clean() + db2, db2Clean := testhelpers.SetupTestDB(t) + defer db2Clean() + hBad := handlers.NewWebhookHandler(db2, rdb2, badCfg, plans.Default()) + err := handlers.StoreEncryptedURLForTest(hBad, context.Background(), uuid.New(), "https://x", "req-3") + require.Error(t, err) + require.Contains(t, err.Error(), "parse key") +} + +// TestDecryptWebhookURL covers empty / bad-key / bad-ciphertext / success. +func TestDecryptWebhookURL(t *testing.T) { + h, clean := newWebhookHandlerForTest(t) + defer clean() + require.Equal(t, "", handlers.DecryptWebhookURLForTest(h, "", "r")) + // bad ciphertext → returns ciphertext unchanged (fail open) + require.Equal(t, "garbage", handlers.DecryptWebhookURLForTest(h, "garbage", "r")) + // success round-trip + key, _ := crypto.ParseAESKey(testhelpers.TestAESKeyHex) + enc, _ := crypto.Encrypt(key, "https://hook/abc") + require.Equal(t, "https://hook/abc", handlers.DecryptWebhookURLForTest(h, enc, "r")) +} + +// TestIdempotentReceive covers store + lookup hit + lookup miss + bad-json miss. +func TestIdempotentReceive(t *testing.T) { + h, clean := newWebhookHandlerForTest(t) + defer clean() + ctx := context.Background() + const token, key = "tok-1", "idem-1" + + // miss before store + _, ok := handlers.LookupIdempotentReceiveForTest(h, ctx, token, key) + require.False(t, ok) + + // store then hit + handlers.StoreIdempotentReceiveForTest(h, ctx, token, key, fiber.Map{"ok": true, "n": 1}, time.Minute) + got, ok := handlers.LookupIdempotentReceiveForTest(h, ctx, token, key) + require.True(t, ok) + require.Equal(t, true, got["ok"]) +} + +// TestStoreIdempotentReceive_ErrorArms covers the json.Marshal-failure arm +// (an unmarshalable channel value) and the Redis Set-error arm (closed client). +func TestStoreIdempotentReceive_ErrorArms(t *testing.T) { + h, clean := newWebhookHandlerForTest(t) + defer clean() + ctx := context.Background() + // channel value is not JSON-marshalable → marshal returns early (no panic). + handlers.StoreIdempotentReceiveForTest(h, ctx, "tok", "k", fiber.Map{"bad": make(chan int)}, time.Minute) + + // closed redis client → Set errors → metric arm. Build a handler whose + // redis client is closed. + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + rdb, _ := testhelpers.SetupTestRedis(t) + rdb.Close() // closed → Set errors + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + hClosed := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + handlers.StoreIdempotentReceiveForTest(hClosed, ctx, "tok2", "k2", fiber.Map{"ok": true}, time.Minute) +} + +// TestLookupIdempotentReceive_BadJSON covers the json.Unmarshal-failure miss +// arm: a key holding non-JSON bytes returns (nil,false). +func TestLookupIdempotentReceive_BadJSON(t *testing.T) { + h, clean := newWebhookHandlerForTest(t) + defer clean() + ctx := context.Background() + rdb := handlers.WebhookRedisForTest(h) + // write raw non-JSON under the exact idempotency key the lookup computes. + require.NoError(t, rdb.Set(ctx, handlers.WebhookIdempotencyKeyForTest("tok-bad", "k-bad"), "{not-json", time.Minute).Err()) + _, ok := handlers.LookupIdempotentReceiveForTest(h, ctx, "tok-bad", "k-bad") + require.False(t, ok) +} + +// TestDecryptWebhookURL_BadKey covers the aes-key-parse-failure arm: a junk +// AES key makes decrypt fail open, returning the ciphertext unchanged. +func TestDecryptWebhookURL_BadKey(t *testing.T) { + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: "not-a-valid-hex"} + h := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default()) + require.Equal(t, "ciphertext", handlers.DecryptWebhookURLForTest(h, "ciphertext", "r")) +} + +// TestVerifyWebhookHMAC covers every branch: empty header, wrong prefix, bad +// hex, mismatch, and the valid signature. +func TestVerifyWebhookHMAC(t *testing.T) { + body := []byte(`{"event":"x"}`) + secret := "shh" + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + valid := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + require.False(t, handlers.VerifyWebhookHMACForTest(secret, body, "")) + require.False(t, handlers.VerifyWebhookHMACForTest(secret, body, "md5=abc")) + require.False(t, handlers.VerifyWebhookHMACForTest(secret, body, "sha256=zzznothex")) + require.False(t, handlers.VerifyWebhookHMACForTest(secret, body, "sha256="+hex.EncodeToString([]byte("wrong")))) + require.True(t, handlers.VerifyWebhookHMACForTest(secret, body, valid)) +}