1e102a5a79
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>
178 lines
6.4 KiB
Go
178 lines
6.4 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|