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
后端:新端点 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>
257 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|