diff --git a/server/internal/store/devices_login_swap_test.go b/server/internal/store/devices_login_swap_test.go new file mode 100644 index 0000000..723314e --- /dev/null +++ b/server/internal/store/devices_login_swap_test.go @@ -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) +}