Files
pangolin/server/internal/devices/service_test.go
T
wangjia bcc114088c
ci-pangolin / Lint — shellcheck (push) Successful in 9s
ci-pangolin / OpenAPI Sync Check (push) Successful in 17s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 5s
ci-pangolin / Flutter — analyze + test (push) Successful in 26s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 12s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m4s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
feat(devices): P3 强制退出 + 清除增强(per-device 凭证吊销)
后端:新端点 POST /v1/me/devices/{uuid}/logout(ForceLogout:吊销该设备会话+
丢 Redis JTI,设备留列表)。DeleteDevice 增强:先吊销会话再删设备(FK ON DELETE
CASCADE 清理会话行)+ 按 dp_uuid 吊销数据面凭证。CredentialRevoker 接口改
per-device RevokeDevice(dpUUID),由 nodes.Service 实现(查 connect_credentials
持有节点→推 CommandTypeRevoke + 删凭证行),main 注入替 NoopRevoker;devices 注入
SessionPort/JTIRevoker。修 SQLite 跨连接死锁(会话吊销移到 delete tx 之前)。
migration 000016 sessions FK 加 ON DELETE CASCADE。
客户端:account_api.forceLogout + devicesProvider.forceLogout(UI 留 P6)。
测试:ForceLogout(吊销会话+JTI+设备保留+403/404)+ DeleteDevice(级联+按 dp_uuid
吊销);NoopRevoker 改 dp_uuid;全量 server/flutter 测试绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 06:01:41 +08:00

257 lines
8.0 KiB
Go

package devices
import (
"context"
"database/sql"
"strings"
"testing"
"time"
)
// freePlan is the standard free fallback used in resolver tests
// (numbers per design/CLAUDE.md §7).
func freePlan() Plan {
m := 10
return Plan{PlanCode: "free", MaxDevices: 1, DailyMinutes: &m, AdGate: true, Source: "free"}
}
func mkSub(code string, maxDevices int, expiresAt time.Time, source string) effSub {
return effSub{PlanCode: code, MaxDevices: maxDevices, ExpiresAt: expiresAt, Source: source}
}
func TestResolveEffectivePlan_TrialActiveGivesPro(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
subs := []effSub{mkSub("pro", 5, now.Add(24*time.Hour), "trial")}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "pro" || got.MaxDevices != 5 {
t.Fatalf("want pro/5, got %s/%d", got.PlanCode, got.MaxDevices)
}
if got.Source != "trial" {
t.Errorf("want source trial, got %s", got.Source)
}
if got.ExpiresAt == nil {
t.Fatal("expected non-nil expires_at")
}
}
func TestResolveEffectivePlan_ExpiredFallsBackToFree(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
// pro trial expired one second ago.
subs := []effSub{mkSub("pro", 5, now.Add(-time.Second), "trial")}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "free" {
t.Fatalf("want free, got %s", got.PlanCode)
}
if got.ExpiresAt != nil {
t.Errorf("free fallback should have nil expires_at, got %v", *got.ExpiresAt)
}
if got.MaxDevices != 1 {
t.Errorf("want free max_devices 1, got %d", got.MaxDevices)
}
}
func TestResolveEffectivePlan_BannedRejected(t *testing.T) {
now := time.Now().UTC()
subs := []effSub{mkSub("pro", 5, now.Add(time.Hour), "trial")}
_, apiErr := resolveEffectivePlan(now, "banned", subs, freePlan())
if apiErr == nil {
t.Fatal("expected banned to be rejected")
}
if apiErr.Code != "ACCOUNT_BANNED" {
t.Errorf("want ACCOUNT_BANNED, got %s", apiErr.Code)
}
}
func TestResolveEffectivePlan_HighestTierWins(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
subs := []effSub{
mkSub("pro", 5, now.Add(100*24*time.Hour), "code"), // longer pro
mkSub("team", 10, now.Add(24*time.Hour), "code"), // shorter team
mkSub("free", 1, now.Add(50*24*time.Hour), "trial"),
}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
// Highest tier (team) wins even though pro expires later.
if got.PlanCode != "team" || got.MaxDevices != 10 {
t.Fatalf("want team/10, got %s/%d", got.PlanCode, got.MaxDevices)
}
}
func TestResolveEffectivePlan_SameTierLatestExpiryWins(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
early := now.Add(24 * time.Hour)
late := now.Add(48 * time.Hour)
subs := []effSub{
mkSub("pro", 5, early, "code"),
mkSub("pro", 5, late, "code"),
}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(late) {
t.Fatalf("want latest expiry %v, got %v", late, got.ExpiresAt)
}
}
func TestResolveEffectivePlan_UTCBoundaryStrict(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
// expires_at exactly == now must count as expired (strict >).
subs := []effSub{mkSub("pro", 5, now, "trial")}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "free" {
t.Fatalf("expires_at == now should be expired; want free, got %s", got.PlanCode)
}
// One microsecond after now is still active.
subs2 := []effSub{mkSub("pro", 5, now.Add(time.Microsecond), "trial")}
got2, _ := resolveEffectivePlan(now, "active", subs2, freePlan())
if got2.PlanCode != "pro" {
t.Fatalf("expires_at just after now should be active; want pro, got %s", got2.PlanCode)
}
}
func TestResolveEffectivePlan_NoSubsGivesFree(t *testing.T) {
got, apiErr := resolveEffectivePlan(time.Now().UTC(), "active", nil, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "free" || got.Source != "free" {
t.Fatalf("want free/free, got %s/%s", got.PlanCode, got.Source)
}
}
func TestResolveEffectivePlan_DailyMinutesPropagated(t *testing.T) {
now := time.Now().UTC()
s := mkSub("pro", 5, now.Add(time.Hour), "code")
s.DailyMinutes = sql.NullInt64{} // pro: unlimited
got, _ := resolveEffectivePlan(now, "active", []effSub{s}, freePlan())
if got.DailyMinutes != nil {
t.Errorf("pro daily_minutes should be nil (unlimited), got %d", *got.DailyMinutes)
}
free := resolveFree(t, now)
if free.DailyMinutes == nil || *free.DailyMinutes != 10 {
t.Errorf("free daily_minutes should be 10, got %v", free.DailyMinutes)
}
}
func resolveFree(t *testing.T, now time.Time) Plan {
t.Helper()
got, apiErr := resolveEffectivePlan(now, "active", nil, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
return got
}
func TestCheckDeviceQuota(t *testing.T) {
free := Plan{PlanCode: "free", MaxDevices: 1}
pro := Plan{PlanCode: "pro", MaxDevices: 5}
if err := CheckDeviceQuota(free, 0); err != nil {
t.Errorf("0 < 1 should pass, got %v", err)
}
if err := CheckDeviceQuota(free, 1); err == nil {
t.Error("1 >= 1 should be rejected")
} else if err.Code != "DEVICE_LIMIT_EXCEEDED" {
t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", err.Code)
}
if err := CheckDeviceQuota(pro, 4); err != nil {
t.Errorf("4 < 5 should pass, got %v", err)
}
if err := CheckDeviceQuota(pro, 5); err == nil {
t.Error("5 >= 5 should be rejected")
}
}
func TestErrDeviceLimitContainsNumber(t *testing.T) {
e := errDeviceLimit(5)
if !strings.Contains(e.MessageZH, "5") {
t.Errorf("zh message should contain the limit 5: %q", e.MessageZH)
}
if !strings.Contains(e.MessageEn, "5") {
t.Errorf("en message should contain the limit 5: %q", e.MessageEn)
}
}
func TestRequirePaidTier(t *testing.T) {
if err := RequirePaidTier(Plan{PlanCode: "free"}); err == nil {
t.Error("free should require paid tier")
} else if err.Code != "PAID_TIER_REQUIRED" {
t.Errorf("want PAID_TIER_REQUIRED, got %s", err.Code)
}
if err := RequirePaidTier(Plan{PlanCode: "pro"}); err != nil {
t.Errorf("pro should pass, got %v", err)
}
if err := RequirePaidTier(Plan{PlanCode: "team"}); err != nil {
t.Errorf("team should pass, got %v", err)
}
}
func TestNormalizePlatform(t *testing.T) {
cases := map[string]bool{
"ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true, "linux": true,
"": false, "blackberry": false,
}
for in, wantOK := range cases {
_, ok := normalizePlatform(in)
if ok != wantOK {
t.Errorf("normalizePlatform(%q) ok=%v, want %v", in, ok, wantOK)
}
}
}
func TestNormalizeName(t *testing.T) {
if got := normalizeName("", "ios"); got != "ios" {
t.Errorf("empty name should fall back to platform, got %q", got)
}
if got := normalizeName(" iPhone 15 ", "ios"); got != "iPhone 15" {
t.Errorf("name should be trimmed, got %q", got)
}
long := strings.Repeat("名", 100)
if got := normalizeName(long, "ios"); len([]rune(got)) != 64 {
t.Errorf("name should be truncated to 64 runes, got %d", len([]rune(got)))
}
}
func TestUserIDContextRoundTrip(t *testing.T) {
ctx := WithUserID(context.Background(), 42)
id, ok := UserIDFromContext(ctx)
if !ok || id != 42 {
t.Fatalf("want 42/true, got %d/%v", id, ok)
}
if _, ok := UserIDFromContext(context.Background()); ok {
t.Error("empty context should yield ok=false")
}
// userID 0 is treated as absent.
if _, ok := UserIDFromContext(WithUserID(context.Background(), 0)); ok {
t.Error("userID 0 should be treated as absent")
}
}
func TestNoopRevokerRecordsCalls(t *testing.T) {
n := &NoopRevoker{}
_ = n.RevokeDevice(context.Background(), "dp-uuid-123")
if len(n.Calls) != 1 || n.Calls[0] != "dp-uuid-123" {
t.Fatalf("unexpected calls: %+v", n.Calls)
}
}