Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions internal/handlers/billing_block_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package handlers_test

// billing_block_helpers_test.go — shared helpers for the W3 billing-block
// integration suite (billing_block_*_test.go). These are W3-local helpers
// (prefixed billingBlock*) so they do not collide with the existing cov2*/
// billing* helpers this suite also reuses. NOTHING here redefines an existing
// helper — seedVerifiedTeamUser, cov2CheckoutApp, changePlanAppReal,
// changePlanReq, postCheckoutReq, signRazorpayPayload,
// makeSubscriptionChargedPayloadWithPlan, makeSubscriptionCancelledPayload and
// cov2WebhookAppReal all already exist in the package and are used as-is.

import (
"context"
"database/sql"
"os"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"instant.dev/internal/models"
"instant.dev/internal/testhelpers"
)

// billingBlockJWTSecret is the ≥32-byte HMAC secret the W3 suite stamps onto
// every test cfg. Identical value to testhelpers.TestJWTSecret; named locally
// so the intent ("any valid secret, never a real one") is explicit at call
// sites.
const billingBlockJWTSecret = testhelpers.TestJWTSecret

// billingBlockSkipNoDB skips a W3 test when no test Postgres is configured.
// The billing block is a real-backend integration surface — these tests
// assert on actual rows in teams/resources/audit_log, so a missing DB is a
// loud skip, never a false green.
func billingBlockSkipNoDB(t *testing.T) bool {
t.Helper()
if os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("W3 billing-block integration: TEST_DATABASE_URL not set")
return true
}
return false
}

// billingBlockDB opens a fresh migrated test DB and returns it with its
// cleanup. Thin wrapper over testhelpers.SetupTestDB so every W3 test reads
// the same way.
func billingBlockDB(t *testing.T) (*sql.DB, func()) {
t.Helper()
return testhelpers.SetupTestDB(t)
}

// mustSeedTeam creates a team row at the given plan tier and registers a
// cleanup. Returns the team id as a string (the shape changePlanAppReal +
// the webhook payload builders consume).
func mustSeedTeam(t *testing.T, db *sql.DB, tier string) string {
t.Helper()
id := testhelpers.MustCreateTeamDB(t, db, tier)
t.Cleanup(func() {
db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, id)
})
return id
}

// billingBlockSeedResource inserts an active resource owned by teamID at the
// given tier and returns its id. Used by the webhook-transition tests to
// prove that an upgrade ELEVATES existing resources and a downgrade LEAVES
// them. expires_at is left NULL (a claimed, permanent resource) so the
// reaper-race guard in the elevation UPDATE never excludes it.
func billingBlockSeedResource(t *testing.T, db *sql.DB, teamID uuid.UUID, resourceType, tier string) uuid.UUID {
t.Helper()
res, err := models.CreateResource(context.Background(), db, models.CreateResourceParams{
TeamID: &teamID,
ResourceType: resourceType,
Name: "w3-" + resourceType + "-" + uuid.NewString()[:8],
Tier: tier,
Env: "production",
})
require.NoError(t, err, "seed resource (%s/%s)", resourceType, tier)
// CreateResource inserts a 'pending' row; the tier-elevation UPDATE only
// touches active/paused/suspended rows. Flip to 'active' so the resource
// is in the state a real claimed resource would be in when an upgrade
// webhook fires.
require.NoError(t, models.MarkResourceActive(context.Background(), db, res.ID),
"activate seeded resource (%s/%s)", resourceType, tier)
t.Cleanup(func() {
db.Exec(`DELETE FROM resources WHERE id = $1`, res.ID)
})
return res.ID
}

// billingBlockResourceTier reads back the current tier of a resource row so a
// test can assert whether a webhook elevated or left it.
func billingBlockResourceTier(t *testing.T, db *sql.DB, id uuid.UUID) string {
t.Helper()
var tier string
require.NoError(t,
db.QueryRow(`SELECT tier FROM resources WHERE id = $1`, id).Scan(&tier),
"read resource tier")
return tier
}

// billingBlockTeamTier reads back the current plan_tier of a team row.
func billingBlockTeamTier(t *testing.T, db *sql.DB, teamID string) string {
t.Helper()
var tier string
require.NoError(t,
db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&tier),
"read team plan_tier")
return tier
}
216 changes: 216 additions & 0 deletions internal/handlers/billing_block_no_cancel_downgrade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package handlers_test

// billing_block_no_cancel_downgrade_test.go — W3 §E10: there is NO self-serve
// cancel or downgrade path.
//
// Policy (memory: project_no_self_serve_cancel_downgrade): cancellation and
// downgrade are SUPPORT-ONLY. Downgrade flows through the Razorpay
// subscription.cancelled / .updated webhook or a support agent; a paying team
// must NOT be able to drop itself to a cheaper tier or cancel via any
// session-authenticated endpoint. The self-serve POST /billing/cancel was
// REMOVED (router.go documents the removal next to /billing/change-plan).
//
// Two complementary assertions:
// 1. ROUTE NEGATIVE: string-parse the live router.go and prove no route
// registers a self-serve cancel/downgrade verb. This is the same
// source-scan technique the OpenAPI route-parity test uses
// (extractRouterRoutes) so it tracks the real registration table, not a
// stale mental model. If someone re-adds POST /billing/cancel, this reds.
// 2. HANDLER NEGATIVE: drive the real ChangePlanAPI with a lower-or-equal
// target tier and assert it is rejected with downgrade_not_self_serve +
// a mailto:support agent_action — the exact policy in
// billing.go:ChangePlanAPI. This is verified against the code, not
// assumed: a downgrade returns 400 downgrade_not_self_serve, NOT a
// silent tier drop.

import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"instant.dev/internal/config"
)

// blockRouterRoute is a (method, path, isAdmin) tuple parsed from router.go.
// Local to this W3 file because the OpenAPI test's identically-shaped parser
// lives in the white-box `handlers` test package and is not reachable from
// this black-box `handlers_test` package.
type blockRouterRoute struct {
method string
path string
isAdmin bool
}

// blockExtractRouterRoutes string-parses router.go and returns every literal
// route registration. Same conservative technique the OpenAPI route-parity
// test uses: it expects a literal "(" after the verb and a quoted path as the
// first arg, skipping any dynamic registration (router.go uses only literal
// paths today). Groups carry their URL prefix so the returned path is fully
// qualified.
func blockExtractRouterRoutes(src string) []blockRouterRoute {
patterns := []struct {
groupRe *regexp.Regexp
urlPrefix string
isAdmin bool
}{
{regexp.MustCompile(`\bapp\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "", false},
{regexp.MustCompile(`\bapi\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/api/v1", false},
{regexp.MustCompile(`\badminGroup\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/api/v1/<admin>", true},
{regexp.MustCompile(`\bdeployGroup\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/deploy", false},
{regexp.MustCompile(`\binternal\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/internal", false},
}
var out []blockRouterRoute
for _, p := range patterns {
for _, m := range p.groupRe.FindAllStringSubmatch(src, -1) {
path := m[2]
if p.urlPrefix != "" {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
path = p.urlPrefix + path
}
out = append(out, blockRouterRoute{method: strings.ToUpper(m[1]), path: path, isAdmin: p.isAdmin})
}
}
return out
}

// forbiddenSelfServeBillingPaths is the set of route SUFFIXES that, if they
// ever appear as a registered self-serve (session-authenticated, non-admin,
// non-webhook) route, would constitute a self-serve cancel/downgrade surface
// the policy forbids. Matched as a suffix against the parsed router path so
// both the legacy alias and the /api/v1 group form are caught.
var forbiddenSelfServeBillingPaths = []string{
"/billing/cancel",
"/billing/downgrade",
"/billing/subscription/cancel",
"/subscription/cancel",
}

// TestBillingBlock_NoSelfServeCancelOrDowngradeRoute parses router.go and
// asserts none of the forbidden self-serve cancel/downgrade paths are
// registered on a non-admin route. Admin routes (e.g. an operator demote) are
// allowed and excluded — cancellation IS supported, just support/operator-side.
//
// This does not require a DB — it reads the router source, the same way
// TestOpenAPI route-parity does, so it runs even in the -short unit lane.
func TestBillingBlock_NoSelfServeCancelOrDowngradeRoute(t *testing.T) {
routerPath := filepath.Join("..", "router", "router.go")
src, err := os.ReadFile(routerPath)
require.NoError(t, err, "read router.go")

routes := blockExtractRouterRoutes(string(src))
require.NotEmpty(t, routes,
"blockExtractRouterRoutes returned 0 — parser is out of sync with router.go (the negative assertion would pass vacuously)")

// Guard against a vacuous pass: confirm the parser actually sees the
// billing block by requiring the legitimate change-plan route to be
// present. If the parser silently broke, this trips before the negative
// assertion can give a false green.
var sawChangePlan bool
for _, r := range routes {
if strings.HasSuffix(r.path, "/billing/change-plan") {
sawChangePlan = true
break
}
}
require.True(t, sawChangePlan,
"expected the router parser to see POST /billing/change-plan — if it doesn't, the no-cancel negative assertion is meaningless")

for _, r := range routes {
if r.isAdmin {
continue // operator/support-side cancellation is allowed.
}
for _, forbidden := range forbiddenSelfServeBillingPaths {
assert.Falsef(t, strings.HasSuffix(r.path, forbidden),
"self-serve cancel/downgrade is support-only (§E10, memory project_no_self_serve_cancel_downgrade) — "+
"router.go must not register a non-admin route ending in %q, but found %s %s",
forbidden, r.method, r.path)
}
}
}

// TestBillingBlock_ChangePlanRejectsDowngrade pins the handler-level policy: a
// paying team requesting a LOWER or EQUAL tier via the in-app change-plan path
// is rejected with downgrade_not_self_serve and routed to support, NOT
// silently dropped. Verified against billing.go:ChangePlanAPI (it returns 400
// downgrade_not_self_serve + a mailto:support@instanode.dev agent_action for
// any target whose rank ≤ the current tier's rank).
func TestBillingBlock_ChangePlanRejectsDowngrade(t *testing.T) {
if billingBlockSkipNoDB(t) {
return
}

cases := []struct {
name string
startTier string
target string
}{
{"pro → hobby is a downgrade", "pro", "hobby"},
{"pro → hobby_plus is a downgrade", "pro", "hobby_plus"},
{"hobby_plus → hobby is a downgrade", "hobby_plus", "hobby"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db, clean := billingBlockDB(t)
defer clean()
teamID := mustSeedTeam(t, db, tc.startTier)
cfg := &config.Config{
JWTSecret: billingBlockJWTSecret,
RazorpayKeyID: "rzp_test_k",
RazorpayKeySecret: "s",
RazorpayPlanIDHobby: "plan_hobby",
RazorpayPlanIDHobbyPlus: "plan_hobby_plus",
RazorpayPlanIDPro: "plan_pro",
}
app := changePlanAppReal(t, db, cfg, teamID)
code, body := changePlanReq(t, app, map[string]any{"target_plan": tc.target})

assert.Equal(t, http.StatusBadRequest, code, "downgrade must be a 400, body=%v", body)
assert.Equal(t, "downgrade_not_self_serve", body["error"],
"%s must be rejected as a support-only downgrade, not applied", tc.name)
// The agent_action must route the user to support so an agent does
// not retry or invent a different path.
action, _ := body["agent_action"].(string)
assert.Contains(t, strings.ToLower(action), "support",
"downgrade rejection must carry a support-routing agent_action (got %q)", action)

// And CRITICALLY: the team's tier must be UNCHANGED — a downgrade
// rejection that still mutated the row would be the worst outcome.
assert.Equal(t, tc.startTier, billingBlockTeamTier(t, db, teamID),
"a rejected downgrade must not mutate the team's plan_tier")
})
}
}

// TestBillingBlock_ChangePlanSamePlanRejected covers the lateral/no-op edge:
// requesting the tier the team already holds is rejected with same_plan (not
// treated as a downgrade, not a no-op success that churns the Razorpay
// subscription). Part of the §E10 surface — no self-serve tier mutation that
// isn't a genuine upgrade.
func TestBillingBlock_ChangePlanSamePlanRejected(t *testing.T) {
if billingBlockSkipNoDB(t) {
return
}
db, clean := billingBlockDB(t)
defer clean()
teamID := mustSeedTeam(t, db, "pro")
cfg := &config.Config{
JWTSecret: billingBlockJWTSecret,
RazorpayKeyID: "rzp_test_k",
RazorpayKeySecret: "s",
RazorpayPlanIDPro: "plan_pro",
}
app := changePlanAppReal(t, db, cfg, teamID)
code, body := changePlanReq(t, app, map[string]any{"target_plan": "pro"})
assert.Equal(t, http.StatusBadRequest, code, "body=%v", body)
assert.Equal(t, "same_plan", body["error"],
"requesting the current tier must return same_plan, not a no-op success")
assert.Equal(t, "pro", billingBlockTeamTier(t, db, teamID))
}
Loading
Loading