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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user