diff --git a/server/internal/devices/device_limit_override_test.go b/server/internal/devices/device_limit_override_test.go new file mode 100644 index 0000000..5abd5dc --- /dev/null +++ b/server/internal/devices/device_limit_override_test.go @@ -0,0 +1,209 @@ +package devices + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +// -------------------------------------------------------------------------- +// SQLite fixture for migration 000025 (users.max_devices_override). No +// container needed — modernc.org/sqlite is pure Go — so these run in normal +// CI alongside run_sqlite_test.sh's data-layer tests. +// -------------------------------------------------------------------------- + +func openOverrideTestDB(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 +} + +// insertOverrideTestUser inserts an active user with an optional +// max_devices_override (NULL when override.Valid is false). +func insertOverrideTestUser(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 +} + +// giveOverrideTestSubscription inserts an active subscription for userID on +// the given seeded plan code (free/pro/team, from migration 000007). +func giveOverrideTestSubscription(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) + } +} + +// insertOverrideTestDevice inserts an active (recently-seen) device row so it +// counts toward CountActiveDevices. +func insertOverrideTestDevice(t *testing.T, db *sql.DB, userID int64, uuidSuffix string) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO devices (uuid, user_id, name, platform, last_seen) VALUES (?, ?, ?, ?, ?)`, + "dev-"+uuidSuffix, userID, "Device "+uuidSuffix, "ios", time.Now().UTC()); err != nil { + t.Fatalf("insert device: %v", err) + } +} + +// -------------------------------------------------------------------------- +// ResolvePlan: override applied at the single funnel (both the free fallback +// and the subscription path flow through it). +// -------------------------------------------------------------------------- + +// TestResolvePlan_FreeUserNoOverride: a free user with no override set gets +// the plan's default cap (free = 1, per migration 000007 seed). +func TestResolvePlan_FreeUserNoOverride(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "free-none@example.com", sql.NullInt64{}) + + plan, apiErr := svc.ResolvePlan(context.Background(), userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free", plan.PlanCode) + } + if plan.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", plan.MaxDevices) + } +} + +// TestResolvePlan_FreeUserWithOverride: same free user, override=6 → the +// free-fallback path (GetFreePlan) must be overridden too. +func TestResolvePlan_FreeUserWithOverride(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true}) + + plan, apiErr := svc.ResolvePlan(context.Background(), userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free (override must not change plan identity)", plan.PlanCode) + } + if plan.MaxDevices != 6 { + t.Errorf("MaxDevices = %d, want 6 (override)", plan.MaxDevices) + } +} + +// TestResolvePlan_ProSubscriptionOverrideWins: a pro-subscription user +// (plan default max_devices=3) with override=2 — the subscription path +// (resolveEffectivePlan's "best" branch) must also be overridden, even +// though the override is *smaller* than the plan's own cap. +func TestResolvePlan_ProSubscriptionOverrideWins(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true}) + giveOverrideTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour)) + + plan, apiErr := svc.ResolvePlan(context.Background(), userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "pro" { + t.Errorf("PlanCode = %q, want pro", plan.PlanCode) + } + if plan.MaxDevices != 2 { + t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", plan.MaxDevices) + } +} + +// TestResolvePlan_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean +// "no override" — the plan-derived cap must stand. +func TestResolvePlan_NullOrZeroOverrideDoesNotApply(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + + nullUser := insertOverrideTestUser(t, db, "null-override@example.com", sql.NullInt64{}) + zeroUser := insertOverrideTestUser(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) { + plan, apiErr := svc.ResolvePlan(context.Background(), tc.userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (no override should apply)", plan.MaxDevices) + } + }) + } +} + +// -------------------------------------------------------------------------- +// CheckDeviceLimit: behavioral assertion using the existing rig (it calls +// ResolvePlan internally) — override raises the effective cap so a device +// count that would be rejected under the free default is no longer flagged. +// -------------------------------------------------------------------------- + +func TestCheckDeviceLimit_OverrideAllowsMoreDevicesThanFreeCap(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "backstop@example.com", sql.NullInt64{Int64: 6, Valid: true}) + + // Free plan cap is 1; without the override 3 active devices would be + // rejected (Over=true). The override raises the cap to 6. + for i := 0; i < 3; i++ { + insertOverrideTestDevice(t, db, userID, fmt.Sprintf("%d", i)) + } + + status, apiErr := svc.CheckDeviceLimit(context.Background(), userID) + if apiErr != nil { + t.Fatalf("CheckDeviceLimit: %v", apiErr) + } + if status.MaxDevices != 6 { + t.Errorf("MaxDevices = %d, want 6", status.MaxDevices) + } + if status.Over { + t.Errorf("Over = true, want false (override=6 covers 3 active devices; free default of 1 would reject them)") + } +} diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index 4779fbd..03dc1a9 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -450,7 +450,21 @@ func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apier if err != nil { return Plan{}, apierr.ErrInternal } - return resolveEffectivePlan(time.Now().UTC(), status, subs, free) + plan, apiErr := resolveEffectivePlan(time.Now().UTC(), status, subs, free) + if apiErr != nil { + return Plan{}, apiErr + } + // Per-user device-cap override (migration 000025, users.max_devices_override): + // NULL/0 = no override, keep the plan-derived cap resolved above. Applied + // once here — the single funnel both the subscription path and the free + // fallback flow through — so every consumer (subscription middleware, + // /v1/me, CheckDeviceLimit) sees it uniformly. + if override, ok, err := svc.store.GetMaxDevicesOverride(ctx, userID); err != nil { + return Plan{}, apierr.ErrInternal + } else if ok { + plan.MaxDevices = override + } + return plan, nil } // CheckDeviceLimit prunes stale devices (best-effort churn cleanup), resolves the diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index d3f4ddf..2db3ce1 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -299,6 +299,26 @@ func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) { return p, nil } +// GetMaxDevicesOverride returns the per-user device-cap override set by +// migration 000025 (users.max_devices_override). NULL or <=0 means "no +// override" (ok=false) — the caller should fall back to the plan-derived cap. +// A positive value always wins, even if it is *smaller* than the plan's cap, +// so operators can also tighten a specific account. +func (s *Store) GetMaxDevicesOverride(ctx context.Context, userID int64) (int, bool, error) { + var override sql.NullInt64 + row := s.db.QueryRowContext(ctx, `SELECT max_devices_override FROM users WHERE id=?`, userID) + if err := row.Scan(&override); err != nil { + if err == sql.ErrNoRows { + return 0, false, nil + } + return 0, false, fmt.Errorf("store.GetMaxDevicesOverride: %w", err) + } + if !override.Valid || override.Int64 <= 0 { + return 0, false, nil + } + return int(override.Int64), true, nil +} + // -------------------------------------------------------------------------- // Audit log // -------------------------------------------------------------------------- diff --git a/server/internal/store/codes_lib_migrate_test.go b/server/internal/store/codes_lib_migrate_test.go index 214082b..ebcbc01 100644 --- a/server/internal/store/codes_lib_migrate_test.go +++ b/server/internal/store/codes_lib_migrate_test.go @@ -104,10 +104,14 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { // the reviewer's repro hit: the lib's `codes` table collides with the // name 000022's down script renames legacy_codes back to. m := newSQLiteStepper(t, db) - // 000024 (invite_rewards) and 000023 (pay_purchases/source-enum) now sit - // on top of 000022 (codes_lib_legacy_rename) and are unrelated to this - // collision — step them back down first so we land exactly on the - // 000022 boundary the test targets. + // 000025 (user_device_limit_override), 000024 (invite_rewards) and 000023 + // (pay_purchases/source-enum) now sit on top of 000022 + // (codes_lib_legacy_rename) and are unrelated to this collision — step + // them back down first so we land exactly on the 000022 boundary the + // test targets. + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000025 down: %v", err) + } if err := m.Steps(-1); err != nil { t.Fatalf("step 000024 down: %v", err) } diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 0323a91..ed40a07 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 24 { - t.Errorf("version = %d, want 24", v) + if v != 25 { + t.Errorf("version = %d, want 25", v) } // 2. Core tables exist. diff --git a/server/migrations/mysql/000025_user_device_limit_override.down.sql b/server/migrations/mysql/000025_user_device_limit_override.down.sql new file mode 100644 index 0000000..2d7984a --- /dev/null +++ b/server/migrations/mysql/000025_user_device_limit_override.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN max_devices_override; diff --git a/server/migrations/mysql/000025_user_device_limit_override.up.sql b/server/migrations/mysql/000025_user_device_limit_override.up.sql new file mode 100644 index 0000000..262b6b8 --- /dev/null +++ b/server/migrations/mysql/000025_user_device_limit_override.up.sql @@ -0,0 +1,2 @@ +-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。 +ALTER TABLE users ADD COLUMN max_devices_override INT NULL; diff --git a/server/migrations/sqlite/000025_user_device_limit_override.down.sql b/server/migrations/sqlite/000025_user_device_limit_override.down.sql new file mode 100644 index 0000000..2d7984a --- /dev/null +++ b/server/migrations/sqlite/000025_user_device_limit_override.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN max_devices_override; diff --git a/server/migrations/sqlite/000025_user_device_limit_override.up.sql b/server/migrations/sqlite/000025_user_device_limit_override.up.sql new file mode 100644 index 0000000..f1e7274 --- /dev/null +++ b/server/migrations/sqlite/000025_user_device_limit_override.up.sql @@ -0,0 +1,2 @@ +-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。 +ALTER TABLE users ADD COLUMN max_devices_override INTEGER;