fix(server): devices 唯一键改 (user_id,uuid) —— 同机换账号不再 403 死结(F3,#27)

device_id 按安装持久、跨账号复用;旧全局 UNIQUE(uuid) 使同机第二账号注册永远
403 → ConnectNode 判 DEVICE_NOT_REGISTERED,提示的「重新登录」无法自救。

- migration 21(sqlite/mysql):UNIQUE(uuid)→UNIQUE(user_id,uuid);platform 放行
  linux(normalizePlatform 早已接受,旧 CHECK/ENUM 会拒)。SQLite 表重建用
  rename→重建→复制→drop 次序,单事务内不触发 sessions 的级联清空(FK ON)。
- 查找全部收口为按 (user,uuid) 作用域(重复 uuid 跨用户后全局查询歧义):
  findDeviceByUserUUIDTx / FindByUserUUID;Register 删跨用户 Forbidden 分支;
  Delete/ForceLogout/Rename 对他人设备返回 404(不可见);SessionActive 删
  「非本人 fail-safe」分支,dev==nil→false 语义不变。
- 测试:SQLite 真迁移库 F3 回归(两账号同 uuid 各自成行/同用户重复拒/linux 入库/
  sessions 重建后级联仍成立)+ MySQL 集成测试 schema 同步与双账号用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-02 13:16:40 +08:00
parent bbe149fd41
commit 63d1baeb01
9 changed files with 321 additions and 51 deletions
@@ -70,13 +70,18 @@ func applySchema(db *sql.DB) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
platform ENUM('ios','android','windows','macos') NOT NULL,
last_seen DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
platform ENUM('ios','android','windows','macos','linux') NOT NULL,
last_seen DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
client_version VARCHAR(32) NULL,
totp_trusted_until DATETIME(6) NULL,
dp_uuid CHAR(36) NULL,
UNIQUE KEY uniq_devices_user_uuid (user_id, uuid),
UNIQUE KEY idx_devices_dp_uuid (dp_uuid),
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
@@ -330,9 +335,10 @@ func TestDeleteOthersDevice(t *testing.T) {
t.Fatalf("register: %v", apiErr)
}
// Other user cannot delete it → 403 FORBIDDEN.
if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" {
t.Errorf("want FORBIDDEN, got %v", apiErr)
// Other user cannot delete it → 404 NOT_FOUND(查找按 (user,uuid) 作用域,
// 他人名下的行不可见,migration 21 起不再是 403)。
if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "NOT_FOUND" {
t.Errorf("want NOT_FOUND, got %v", apiErr)
}
// Non-existent device → 404 NOT_FOUND.
if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" {
@@ -340,6 +346,65 @@ func TestDeleteOthersDevice(t *testing.T) {
}
}
// TestSameDeviceUUIDTwoAccounts:F3 回归——同一物理设备(同 device uuid)先后登录
// 两个账号,双方都能注册成功、各自成行,互不 403;各自的删除只影响自己名下的行。
func TestSameDeviceUUIDTwoAccounts(t *testing.T) {
db := setupMySQL(t)
svc := devices.NewService(devices.NewStore(db), nil)
ctx := context.Background()
userA := createUser(t, db, "a-shared@example.com", "active")
userB := createUser(t, db, "b-shared@example.com", "active")
devUUID := newUUID(t, db) // 同一台机器的持久 device_id
if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userA, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5,
}); apiErr != nil {
t.Fatalf("register user A: %v", apiErr)
}
// 换账号:同 uuid 注册到 user B —— 旧全局 UNIQUE(uuid) 下这里是 403 死结。
if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userB, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5,
}); apiErr != nil {
t.Fatalf("register user B (same device uuid): %v", apiErr)
}
// 各自名下都各有一行。
for _, uid := range []int64{userA, userB} {
list, apiErr := svc.ListDevices(ctx, uid)
if apiErr != nil {
t.Fatalf("list %d: %v", uid, apiErr)
}
n := 0
for _, d := range list {
if d.UUID == devUUID {
n++
}
}
if n != 1 {
t.Errorf("user %d: want 1 row for shared uuid, got %d", uid, n)
}
}
// A 删除自己的行,不影响 B 的行。
if apiErr := svc.DeleteDevice(ctx, userA, devUUID); apiErr != nil {
t.Fatalf("delete A: %v", apiErr)
}
listB, apiErr := svc.ListDevices(ctx, userB)
if apiErr != nil {
t.Fatalf("list B after A delete: %v", apiErr)
}
found := false
for _, d := range listB {
if d.UUID == devUUID {
found = true
}
}
if !found {
t.Errorf("user B's row must survive user A's delete")
}
}
// TestBannedUserRejected verifies the resolver/middleware path rejects banned users.
func TestBannedUserRejected(t *testing.T) {
db := setupMySQL(t)