fix(server/nodes): 连接设备上限闸读 max_devices_override(登录闸已读,连接闸漏)
EntitlementForUser 是每次连接 backstop(httpapi/nodes.go DEVICE_LIMIT_EXCEEDED) 唯一读的设备上限来源,但订阅命中分支与 free 兜底分支都从未读 users.max_devices_override (migration 000025)——运营设 override 只对登录闸(devices.ResolvePlan)生效,一连 VPN 仍按套餐默认上限被拒。两分支统一叠加 override(NULL/<=0 不生效,正值覆盖,语义与 devices/store.go:GetMaxDevicesOverride 一致),新增 sqlite 用例覆盖 free 无覆盖/ free 覆盖/pro 覆盖收紧/NULL-0 不生效四种场景。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user