Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
@@ -0,0 +1,75 @@
package store_test
import (
"context"
"testing"
"github.com/wangjia/pangolin/server/internal/devices"
)
// TestSQLite_LoginRegistersSameDeviceForTwoAccounts reproduces the #27 (F3) fix
// at the exact code path login drives: recordLogin → devReg.RegisterDevice →
// devices.Service.RegisterIfAbsent{MaxDevices:0}. Two accounts on the SAME physical
// device (same device uuid) must each get their own devices row.
//
// Under the old global UNIQUE(uuid) the second account's registration failed
// (silently on login, best-effort) → the device row for account B never existed →
// ConnectNode later reported DEVICE_NOT_REGISTERED (the 403 deadlock, F3).
func TestSQLite_LoginRegistersSameDeviceForTwoAccounts(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
mkUser := func(u, email string) int64 {
res, err := db.ExecContext(ctx,
`INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`,
u, email, "dp-"+u)
if err != nil {
t.Fatalf("user %s: %v", u, err)
}
id, _ := res.LastInsertId()
return id
}
userA := mkUser("u-a", "a@x.c")
userB := mkUser("u-b", "b@x.c")
svc := devices.NewService(devices.NewStore(db), nil)
const sharedUUID = "shared-install-uuid"
// Account A "logs in" on the device.
idA, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userA, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0,
})
if apiErr != nil || idA == 0 {
t.Fatalf("A register: id=%d err=%v", idA, apiErr)
}
// Account B "logs in" on the SAME physical device (same uuid) — the F3 case.
idB, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userB, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0,
})
if apiErr != nil {
t.Fatalf("B register on same device uuid must succeed (F3 fix), got %v", apiErr)
}
if idB == 0 || idB == idA {
t.Fatalf("B must get its own device row, idA=%d idB=%d", idA, idB)
}
// A logs in again → idempotent refresh of A's own row (not a new row).
idA2, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userA, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0,
})
if apiErr != nil || idA2 != idA {
t.Fatalf("A re-register must refresh same row: id=%d (want %d) err=%v", idA2, idA, apiErr)
}
// Both rows coexist for the shared uuid, one per account.
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM devices WHERE uuid=?`, sharedUUID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("shared uuid must have 2 rows (one per account), got %d", n)
}
t.Logf("OK: device uuid %q shared by account A (row #%d) + account B (row #%d)", sharedUUID, idA, idB)
}
+108
View File
@@ -57,6 +57,51 @@ func TestSQLite_UsageAccumulate(t *testing.T) {
}
}
// AddAdBonusMinutes 是免费版累加式看广告加时的核心原语:每次广告 +N 分钟,
// 封顶 ceiling,返回新总额与本次实际加时(封顶后为 0)。
func TestSQLite_AddAdBonusMinutes(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
us := usage.NewStore(db)
day := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
// First ad on a fresh day → insert row, bonus 10.
newBonus, granted, err := us.AddAdBonusMinutes(ctx, 7, day, 10, 25)
if err != nil || newBonus != 10 || granted != 10 {
t.Fatalf("first: newBonus=%d granted=%d err=%v, want 10/10", newBonus, granted, err)
}
// Second ad → accumulate to 20.
newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25)
if err != nil || newBonus != 20 || granted != 10 {
t.Fatalf("second: newBonus=%d granted=%d err=%v, want 20/10", newBonus, granted, err)
}
// Third ad → clamped at ceiling 25, so only +5 granted.
newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25)
if err != nil || newBonus != 25 || granted != 5 {
t.Fatalf("third: newBonus=%d granted=%d err=%v, want 25/5", newBonus, granted, err)
}
// Fourth ad → already at ceiling, 0 granted.
newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25)
if err != nil || newBonus != 25 || granted != 0 {
t.Fatalf("fourth: newBonus=%d granted=%d err=%v, want 25/0", newBonus, granted, err)
}
// Existing usage_daily columns are preserved alongside the bonus.
if err := us.AggregateUsage(ctx, 7, day, 0, 0, 4); err != nil {
t.Fatalf("aggregate: %v", err)
}
d, err := us.GetDay(ctx, 7, day)
if err != nil || d == nil {
t.Fatalf("getday: %v", err)
}
if d.AdBonusMinutes != 25 || d.MinutesUsed != 4 {
t.Errorf("getday wrong: bonus=%d used=%d, want 25/4", d.AdBonusMinutes, d.MinutesUsed)
}
}
// HasActiveSession 是「近实时远程下线」的服务端判据:有非吊销会话=在线,
// 强制退出(RevokeByDevice)后=离线 → 客户端轮询到即登出。
func TestSQLite_SessionHasActiveSession(t *testing.T) {
@@ -100,6 +145,69 @@ func TestSQLite_SessionHasActiveSession(t *testing.T) {
}
}
// TestSQLite_DevicesUserScopedUUID:F3 回归(migration 21)——同一物理设备的
// device uuid 在两个账号下各自成行(UNIQUE(user_id,uuid)),同用户重复注册仍被
// 唯一键拒绝;linux 平台可入库(CHECK 已放行);sessions 表在重建后 FK 仍指向新
// devices(级联删除成立)。
func TestSQLite_DevicesUserScopedUUID(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
mkUser := func(u, email string) int64 {
res, err := db.ExecContext(ctx,
`INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`,
u, email, "dp-"+u)
if err != nil {
t.Fatalf("user %s: %v", u, err)
}
id, _ := res.LastInsertId()
return id
}
userA := mkUser("u-a", "a@x.c")
userB := mkUser("u-b", "b@x.c")
// 同一 device uuid,两个账号各自成行(旧全局 UNIQUE(uuid) 下第二条会失败)。
if _, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userA); err != nil {
t.Fatalf("register A: %v", err)
}
res, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userB)
if err != nil {
t.Fatalf("register B (same uuid, other user) must succeed: %v", err)
}
devB, _ := res.LastInsertId()
// 同用户重复注册仍被 UNIQUE(user_id,uuid) 拒绝。
if _, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac2', 'macos')`, userA); err == nil {
t.Fatalf("duplicate (user,uuid) must be rejected")
}
// linux 平台可入库(migration 21 顺手放行,normalizePlatform 早已接受)。
if _, err := db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('linux-dev', ?, 'NUC', 'linux')`, userA); err != nil {
t.Fatalf("linux platform must be accepted: %v", err)
}
// sessions FK 重建后仍指向新 devices:删 B 的设备,B 的会话级联消失。
ss := sessions.NewStore(db)
if err := ss.Create(ctx, userB, devB, "jti-b", "", ""); err != nil {
t.Fatalf("session B: %v", err)
}
if _, err := db.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, devB); err != nil {
t.Fatalf("delete devB: %v", err)
}
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(1) FROM sessions WHERE device_id=?`, devB).Scan(&n); err != nil {
t.Fatalf("count sessions: %v", err)
}
if n != 0 {
t.Errorf("sessions must cascade on device delete after rebuild, got %d rows", n)
}
}
func TestSQLite_NodeAccumulateUsage(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)