diff --git a/server/internal/nodes/entitlement_override_sqlite_test.go b/server/internal/nodes/entitlement_override_sqlite_test.go new file mode 100644 index 0000000..1fc8bc2 --- /dev/null +++ b/server/internal/nodes/entitlement_override_sqlite_test.go @@ -0,0 +1,177 @@ +package nodes_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/nodes" + "github.com/wangjia/pangolin/server/internal/store" +) + +// -------------------------------------------------------------------------- +// C1 · EntitlementForUser is the *other* consumer of migration 000025's +// users.max_devices_override — the per-connection backstop in +// internal/httpapi/nodes.go (DEVICE_LIMIT_EXCEEDED). Before this fix it never +// read the override, so an operator-granted override (e.g. 6) would pass the +// login gate (internal/devices.ResolvePlan reads it) but still get the +// account's *next VPN connection* rejected under the un-overridden plan cap. +// +// No container needed — modernc.org/sqlite is pure Go — so these run in +// normal CI alongside run_sqlite_test.sh's data-layer tests, mirroring +// internal/devices/device_limit_override_test.go. +// -------------------------------------------------------------------------- + +func openEntitlementTestDB(t *testing.T) *sql.DB { + t.Helper() + cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"} + db, err := store.Open(cfg) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("MigrateUp: %v", err) + } + return db +} + +// insertEntitlementTestUser inserts an active user with an optional +// max_devices_override (NULL when override.Valid is false). +func insertEntitlementTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 { + t.Helper() + var ( + res sql.Result + err error + ) + if override.Valid { + res, err = db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override) + VALUES (?, ?, 'x', ?, 'active', ?)`, + "uuid-"+email, email, "dp-"+email, override.Int64) + } else { + res, err = db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) + VALUES (?, ?, 'x', ?, 'active')`, + "uuid-"+email, email, "dp-"+email) + } + if err != nil { + t.Fatalf("insert user %s: %v", email, err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("last insert id: %v", err) + } + return id +} + +// giveEntitlementTestSubscription inserts an active subscription for userID +// on the given seeded plan code (free/pro/team, from migration 000007). +func giveEntitlementTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) { + t.Helper() + var planID int64 + if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil { + t.Fatalf("plan lookup %s: %v", planCode, err) + } + if _, err := db.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`, + userID, planID, expiresAt.UTC(), source); err != nil { + t.Fatalf("give subscription: %v", err) + } +} + +// TestEntitlementForUser_FreeUserNoOverride: a free user with no override set +// gets the plan's hard-coded free default (1) — unchanged baseline behavior. +func TestEntitlementForUser_FreeUserNoOverride(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + userID := insertEntitlementTestUser(t, db, "free-none@example.com", sql.NullInt64{}) + + ent, err := st.EntitlementForUser(context.Background(), userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent == nil { + t.Fatal("ent = nil, want entitlement") + } + if ent.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free", ent.PlanCode) + } + if ent.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", ent.MaxDevices) + } +} + +// TestEntitlementForUser_FreeUserWithOverride: same free user, override=6 — +// the free-fallback branch (no active subscription row) must be overridden +// too, otherwise the connect backstop rejects the 2nd..6th device even +// though the login gate (devices.ResolvePlan) already allows them. +func TestEntitlementForUser_FreeUserWithOverride(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + userID := insertEntitlementTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true}) + + ent, err := st.EntitlementForUser(context.Background(), userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free (override must not change plan identity)", ent.PlanCode) + } + if ent.MaxDevices != 6 { + t.Errorf("MaxDevices = %d, want 6 (override) — connect backstop must agree with login gate", ent.MaxDevices) + } +} + +// TestEntitlementForUser_ProSubscriptionOverrideWins: a pro-subscription user +// (plan default max_devices=3, migration 000019) with override=2 — the +// subscription branch must also be overridden, even though the override is +// *smaller* than the plan's own cap (operators can tighten a specific +// account too, per internal/devices/store.go:305-306's documented semantics). +func TestEntitlementForUser_ProSubscriptionOverrideWins(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + userID := insertEntitlementTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true}) + giveEntitlementTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour)) + + ent, err := st.EntitlementForUser(context.Background(), userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent.PlanCode != "pro" { + t.Errorf("PlanCode = %q, want pro", ent.PlanCode) + } + if ent.MaxDevices != 2 { + t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", ent.MaxDevices) + } +} + +// TestEntitlementForUser_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean +// "no override" — the plan-derived cap must stand (free=1 here). +func TestEntitlementForUser_NullOrZeroOverrideDoesNotApply(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + + nullUser := insertEntitlementTestUser(t, db, "null-override@example.com", sql.NullInt64{}) + zeroUser := insertEntitlementTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true}) + + for _, tc := range []struct { + name string + userID int64 + }{ + {"NULL", nullUser}, + {"zero", zeroUser}, + } { + t.Run(tc.name, func(t *testing.T) { + ent, err := st.EntitlementForUser(context.Background(), tc.userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (no override should apply)", ent.MaxDevices) + } + }) + } +} diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index b9291e4..6e2e7a2 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -227,18 +227,37 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E err := s.db.QueryRowContext(ctx, q, userID, time.Now().UTC()).Scan( &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.MaxDevices, &e.ExpiresAt, ) - if err == sql.ErrNoRows { + switch { + case err == sql.ErrNoRows: // No active subscription → free plan defaults (mirrors the free plan seed). e.PlanCode = "free" e.AdGate = true e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10} e.DailyMB = sql.NullInt64{Valid: true, Int64: 500} e.MaxDevices = 1 - return e, nil - } - if err != nil { + case err != nil: return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err) } + + // Per-user device-cap override (migration 000025, users.max_devices_override): + // applies uniformly to BOTH branches above (subscription hit + free + // fallback) — mirrors internal/devices/service.go's ResolvePlan funnel, so + // the per-connection backstop (internal/httpapi/nodes.go's + // DEVICE_LIMIT_EXCEEDED check, which reads Entitlement.MaxDevices) agrees + // with the login gate / /v1/me / devices.CheckDeviceLimit. NULL or <=0 + // means "no override" (same semantics as devices/store.go's + // GetMaxDevicesOverride); a positive value always wins, even if smaller + // than the plan's own cap. + var override sql.NullInt64 + if err := s.db.QueryRowContext(ctx, + `SELECT max_devices_override FROM users WHERE id = ?`, userID, + ).Scan(&override); err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: override: %w", err) + } + if override.Valid && override.Int64 > 0 { + e.MaxDevices = int(override.Int64) + } + return e, nil }