diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 94215bed79..13bf42f437 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -256,7 +256,7 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(deviceId); var activityDate = DateTime.UtcNow; - var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user); + var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); var lastActivityDate = session.LastActivityDate; session.LastActivityDate = activityDate; @@ -491,7 +491,7 @@ namespace Emby.Server.Implementations.Session /// The remote end point. /// The user. /// SessionInfo. - private SessionInfo GetSessionInfo( + private async Task GetSessionInfo( string appName, string appVersion, string deviceId, @@ -504,7 +504,7 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(deviceId); var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty); - SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user); + SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession); if (ReferenceEquals(newSession, sessionInfo)) { @@ -532,7 +532,7 @@ namespace Emby.Server.Implementations.Session return sessionInfo; } - private SessionInfo CreateSessionInfo( + private async Task CreateSessionInfo( string key, string appName, string appVersion, @@ -562,7 +562,7 @@ namespace Emby.Server.Implementations.Session deviceName = "Network Device"; } - var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new() + var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new() { DeviceId = deviceId }; @@ -1768,12 +1768,12 @@ namespace Emby.Server.Implementations.Session // This should be validated above, but if it isn't don't delete all tokens. ArgumentException.ThrowIfNullOrEmpty(deviceId); - var existing = _deviceManager.GetDevices( + var existing = (await _deviceManager.GetDevices( new DeviceQuery { DeviceId = deviceId, UserId = user.Id - }).Items; + }).ConfigureAwait(false)).Items; foreach (var auth in existing) { @@ -1801,12 +1801,12 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(accessToken); - var existing = _deviceManager.GetDevices( + var existing = (await _deviceManager.GetDevices( new DeviceQuery { Limit = 1, AccessToken = accessToken - }).Items; + }).ConfigureAwait(false)).Items; if (existing.Count > 0) { @@ -1845,10 +1845,10 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - var existing = _deviceManager.GetDevices(new DeviceQuery + var existing = await _deviceManager.GetDevices(new DeviceQuery { UserId = userId - }); + }).ConfigureAwait(false); foreach (var info in existing.Items) { @@ -2045,11 +2045,11 @@ namespace Emby.Server.Implementations.Session /// public async Task GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint) { - var items = _deviceManager.GetDevices(new DeviceQuery + var items = (await _deviceManager.GetDevices(new DeviceQuery { AccessToken = token, Limit = 1 - }).Items; + }).ConfigureAwait(false)).Items; if (items.Count == 0) { diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 2bbfeb40b8..59244588bb 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -50,10 +50,10 @@ public class DevicesController : BaseJellyfinApiController /// An containing the list of devices. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetDevices([FromQuery] Guid? userId) + public async Task>> GetDevices([FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - return _deviceManager.GetDevicesForUser(userId); + return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false); } /// @@ -66,9 +66,9 @@ public class DevicesController : BaseJellyfinApiController [HttpGet("Info")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetDeviceInfo([FromQuery, Required] string id) + public async Task> GetDeviceInfo([FromQuery, Required] string id) { - var deviceInfo = _deviceManager.GetDevice(id); + var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); if (deviceInfo is null) { return NotFound(); @@ -87,9 +87,9 @@ public class DevicesController : BaseJellyfinApiController [HttpGet("Options")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetDeviceOptions([FromQuery, Required] string id) + public async Task> GetDeviceOptions([FromQuery, Required] string id) { - var deviceInfo = _deviceManager.GetDeviceOptions(id); + var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); if (deviceInfo is null) { return NotFound(); @@ -127,7 +127,12 @@ public class DevicesController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task DeleteDevice([FromQuery] string[] id) { - var devices = id.Select(_deviceManager.GetDevice).ToArray(); + var devices = new List(id.Length); + foreach (var deviceId in id) + { + devices.Add(await _deviceManager.GetDevice(deviceId).ConfigureAwait(false)); + } + if (devices.Any(f => f is null)) { return BadRequest(); @@ -135,7 +140,7 @@ public class DevicesController : BaseJellyfinApiController foreach (var device in devices) { - var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }); + var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }).ConfigureAwait(false); foreach (var session in sessions.Items) { diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index d0d52a23fb..672693138f 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -31,8 +31,6 @@ namespace Jellyfin.Server.Implementations.Devices private readonly IDbContextFactory _dbProvider; private readonly IUserManager _userManager; private readonly ConcurrentDictionary _capabilitiesMap = new(); - private readonly ConcurrentDictionary _devices; - private readonly ConcurrentDictionary _deviceOptions; /// /// Initializes a new instance of the class. @@ -43,23 +41,6 @@ namespace Jellyfin.Server.Implementations.Devices { _dbProvider = dbProvider; _userManager = userManager; - _devices = new ConcurrentDictionary(); - _deviceOptions = new ConcurrentDictionary(); - - using var dbContext = _dbProvider.CreateDbContext(); - foreach (var device in dbContext.Devices - .OrderBy(d => d.Id) - .AsEnumerable()) - { - _devices.TryAdd(device.Id, device); - } - - foreach (var deviceOption in dbContext.DeviceOptions - .OrderBy(d => d.Id) - .AsEnumerable()) - { - _deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption); - } } /// @@ -89,8 +70,6 @@ namespace Jellyfin.Server.Implementations.Devices await dbContext.SaveChangesAsync().ConfigureAwait(false); } - _deviceOptions[deviceId] = deviceOptions; - DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs>(new Tuple(deviceId, deviceOptions))); } @@ -102,21 +81,24 @@ namespace Jellyfin.Server.Implementations.Devices { dbContext.Devices.Add(device); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _devices.TryAdd(device.Id, device); } return device; } /// - public DeviceOptionsDto? GetDeviceOptions(string deviceId) + public async Task GetDeviceOptions(string deviceId) { - if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions)) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - return ToDeviceOptionsDto(deviceOptions); - } + var deviceOptions = await dbContext.DeviceOptions + .AsNoTracking() + .FirstOrDefaultAsync(dev => dev.DeviceId == deviceId) + .ConfigureAwait(false); - return null; + return deviceOptions is null ? null : ToDeviceOptionsDto(deviceOptions); + } } /// @@ -133,43 +115,79 @@ namespace Jellyfin.Server.Implementations.Devices } /// - public DeviceInfoDto? GetDevice(string id) + public async Task GetDevice(string id) { - var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault(); - _deviceOptions.TryGetValue(id, out var deviceOption); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var device = await dbContext.Devices + .AsNoTracking() + .Where(d => d.DeviceId == id) + .OrderByDescending(d => d.DateLastActivity) + .FirstOrDefaultAsync() + .ConfigureAwait(false); - var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption); - return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo); + if (device is null) + { + return null; + } + + var deviceOption = await dbContext.DeviceOptions + .AsNoTracking() + .FirstOrDefaultAsync(dev => dev.DeviceId == id) + .ConfigureAwait(false); + + return ToDeviceInfoDto(ToDeviceInfo(device, deviceOption)); + } } /// - public QueryResult GetDevices(DeviceQuery query) + public async Task> GetDevices(DeviceQuery query) { - IEnumerable devices = _devices.Values - .Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value)) - .Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId) - .Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken) - .OrderBy(d => d.Id) - .ToList(); - var count = devices.Count(); - - if (query.Skip.HasValue) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - devices = devices.Skip(query.Skip.Value); - } + IQueryable filtered = dbContext.Devices.AsNoTracking(); - if (query.Limit.HasValue && query.Limit.Value > 0) - { - devices = devices.Take(query.Limit.Value); - } + if (query.UserId.HasValue) + { + filtered = filtered.Where(device => device.UserId.Equals(query.UserId.Value)); + } - return new QueryResult(query.Skip, count, devices.ToList()); + if (query.DeviceId is not null) + { + filtered = filtered.Where(device => device.DeviceId == query.DeviceId); + } + + if (query.AccessToken is not null) + { + filtered = filtered.Where(device => device.AccessToken == query.AccessToken); + } + + // Every filter is an exact match on an indexed column and the table holds one row per + // user and device, so paging the materialised set costs less than a second round trip. + var matched = await filtered.OrderBy(d => d.Id).ToListAsync().ConfigureAwait(false); + + IEnumerable devices = matched; + + if (query.Skip.HasValue) + { + devices = devices.Skip(query.Skip.Value); + } + + if (query.Limit.HasValue && query.Limit.Value > 0) + { + devices = devices.Take(query.Limit.Value); + } + + return new QueryResult(query.Skip, matched.Count, devices.ToList()); + } } /// - public QueryResult GetDeviceInfos(DeviceQuery query) + public async Task> GetDeviceInfos(DeviceQuery query) { - var devices = GetDevices(query); + var devices = await GetDevices(query).ConfigureAwait(false); return new QueryResult( devices.StartIndex, @@ -178,38 +196,49 @@ namespace Jellyfin.Server.Implementations.Devices } /// - public QueryResult GetDevicesForUser(Guid? userId) + public async Task> GetDevicesForUser(Guid? userId) { - IEnumerable devices = _devices.Values - .OrderByDescending(d => d.DateLastActivity) - .ThenBy(d => d.DeviceId); - - if (!userId.IsNullOrEmpty()) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - var user = _userManager.GetUserById(userId.Value); - if (user is null) + IEnumerable devices = await dbContext.Devices + .AsNoTracking() + .OrderByDescending(d => d.DateLastActivity) + .ThenBy(d => d.DeviceId) + .ToListAsync() + .ConfigureAwait(false); + + if (!userId.IsNullOrEmpty()) { - throw new ResourceNotFoundException(); + var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + devices = devices.Where(i => CanAccessDevice(user, i.DeviceId)); } - devices = devices.Where(i => CanAccessDevice(user, i.DeviceId)); + var options = await dbContext.DeviceOptions + .AsNoTracking() + .ToDictionaryAsync(dev => dev.DeviceId) + .ConfigureAwait(false); + + var array = devices.Select(device => + { + options.TryGetValue(device.DeviceId, out var option); + return ToDeviceInfo(device, option); + }) + .Select(ToDeviceInfoDto) + .ToArray(); + + return new QueryResult(array); } - - var array = devices.Select(device => - { - _deviceOptions.TryGetValue(device.DeviceId, out var option); - return ToDeviceInfo(device, option); - }) - .Select(ToDeviceInfoDto) - .ToArray(); - - return new QueryResult(array); } /// public async Task DeleteDevice(Device device) { - _devices.TryRemove(device.Id, out _); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { @@ -229,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Devices dbContext.Devices.Update(device); await dbContext.SaveChangesAsync().ConfigureAwait(false); } - - _devices[device.Id] = device; } /// diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 8657cb7dbb..8a50082e07 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -126,95 +126,95 @@ namespace Jellyfin.Server.Implementations.Security return authInfo; } + var device = (await _deviceManager.GetDevices( + new DeviceQuery { AccessToken = token }).ConfigureAwait(false)).Items.FirstOrDefault(); + + if (device is not null) + { + authInfo.IsAuthenticated = true; + var updateToken = false; + + // TODO: Remove these checks for IsNullOrWhiteSpace + if (string.IsNullOrWhiteSpace(authInfo.Client)) + { + authInfo.Client = device.AppName; + } + + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + { + authInfo.DeviceId = device.DeviceId; + } + + // Temporary. TODO - allow clients to specify that the token has been shared with a casting device + var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = device.DeviceName; + } + else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) + { + updateToken = true; + device.DeviceName = authInfo.Device; + } + } + + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = device.AppVersion; + } + else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) + { + updateToken = true; + device.AppVersion = authInfo.Version; + } + } + + if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3) + { + device.DateLastActivity = DateTime.UtcNow; + updateToken = true; + } + + authInfo.User = _userManager.GetUserById(device.UserId); + + if (updateToken) + { + await _deviceManager.UpdateDevice(device).ConfigureAwait(false); + } + + return authInfo; + } + var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var device = _deviceManager.GetDevices( - new DeviceQuery { AccessToken = token }).Items.FirstOrDefault(); - - if (device is not null) + var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false); + if (key is not null) { authInfo.IsAuthenticated = true; - var updateToken = false; - - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = device.AppName; - } - + authInfo.Client = key.Name; + authInfo.Token = key.AccessToken; if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) { - authInfo.DeviceId = device.DeviceId; + authInfo.DeviceId = _serverApplicationHost.SystemId; } - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); - if (string.IsNullOrWhiteSpace(authInfo.Device)) { - authInfo.Device = device.DeviceName; - } - else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - device.DeviceName = authInfo.Device; - } + authInfo.Device = _serverApplicationHost.Name; } if (string.IsNullOrWhiteSpace(authInfo.Version)) { - authInfo.Version = device.AppVersion; - } - else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - device.AppVersion = authInfo.Version; - } + authInfo.Version = _serverApplicationHost.ApplicationVersionString; } - if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3) - { - device.DateLastActivity = DateTime.UtcNow; - updateToken = true; - } - - authInfo.User = _userManager.GetUserById(device.UserId); - - if (updateToken) - { - await _deviceManager.UpdateDevice(device).ConfigureAwait(false); - } - } - else - { - var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false); - if (key is not null) - { - authInfo.IsAuthenticated = true; - authInfo.Client = key.Name; - authInfo.Token = key.AccessToken; - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) - { - authInfo.DeviceId = _serverApplicationHost.SystemId; - } - - if (string.IsNullOrWhiteSpace(authInfo.Device)) - { - authInfo.Device = _serverApplicationHost.Name; - } - - if (string.IsNullOrWhiteSpace(authInfo.Version)) - { - authInfo.Version = _serverApplicationHost.ApplicationVersionString; - } - - authInfo.IsApiKey = true; - } + authInfo.IsApiKey = true; } return authInfo; diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs index 92e2bb4fa7..1f53b52f6a 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs @@ -61,10 +61,10 @@ public sealed class DeviceAccessHost : IHostedService private async Task UpdateDeviceAccess(User user) { - var existing = _deviceManager.GetDevices(new DeviceQuery + var existing = (await _deviceManager.GetDevices(new DeviceQuery { UserId = user.Id - }).Items; + }).ConfigureAwait(false)).Items; foreach (var device in existing) { diff --git a/MediaBrowser.Controller/Devices/IDeviceManager.cs b/MediaBrowser.Controller/Devices/IDeviceManager.cs index ea38950d32..df127e2f7b 100644 --- a/MediaBrowser.Controller/Devices/IDeviceManager.cs +++ b/MediaBrowser.Controller/Devices/IDeviceManager.cs @@ -47,29 +47,29 @@ public interface IDeviceManager /// Gets the device information. /// /// The identifier. - /// DeviceInfoDto. - DeviceInfoDto? GetDevice(string id); + /// A representing the retrieval of the device information. + Task GetDevice(string id); /// /// Gets devices based on the provided query. /// /// The device query. /// A representing the retrieval of the devices. - QueryResult GetDevices(DeviceQuery query); + Task> GetDevices(DeviceQuery query); /// /// Gets device information based on the provided query. /// /// The device query. /// A representing the retrieval of the device information. - QueryResult GetDeviceInfos(DeviceQuery query); + Task> GetDeviceInfos(DeviceQuery query); /// /// Gets the device information. /// /// The user's id, or null. - /// IEnumerable<DeviceInfoDto>. - QueryResult GetDevicesForUser(Guid? userId); + /// A representing the retrieval of the device information. + Task> GetDevicesForUser(Guid? userId); /// /// Deletes a device. @@ -105,8 +105,8 @@ public interface IDeviceManager /// Gets the options of a device. /// /// The device id. - /// of the device. - DeviceOptionsDto? GetDeviceOptions(string deviceId); + /// A representing the retrieval of the of the device. + Task GetDeviceOptions(string deviceId); /// /// Gets the dto for client capabilities. diff --git a/tests/Jellyfin.Server.Tests/Devices/DeviceManagerReplicaTests.cs b/tests/Jellyfin.Server.Tests/Devices/DeviceManagerReplicaTests.cs new file mode 100644 index 0000000000..9647a3fc09 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Devices/DeviceManagerReplicaTests.cs @@ -0,0 +1,197 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.DbConfiguration; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.PostgreSQL; +using Jellyfin.Server.Implementations.Devices; +using Jellyfin.Server.Tests.Migrations; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Npgsql; +using Xunit; + +namespace Jellyfin.Server.Tests.Devices; + +/// +/// Two independently constructed instances over one PostgreSQL database are the +/// in-process stand-in for two replicas sharing one database: what either of them writes, the other has to +/// see on its very next read. +/// +[Trait("Category", "RequiresDocker")] +public sealed class DeviceManagerReplicaTests : IAsyncLifetime +{ + private PostgreSqlTestServer _server = null!; + + /// + public async ValueTask InitializeAsync() + { + _server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false); + } + + /// + public async ValueTask DisposeAsync() + { + await _server.DisposeAsync().ConfigureAwait(false); + } + + /// + /// A client that logs in against one replica has to be authenticated by every other replica that was + /// already running when the token was minted - a rolling update or a scale-up must not 401 it. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task TokenMintedOnOneReplica_IsValidOnAnother() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await _server.CreateDatabaseAsync("device_replica_create", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken); + + // Both replicas start before the login, so neither has the device in hand when it is created. + var replicaA = CreateManager(dataSource, user); + var replicaB = CreateManager(dataSource, user); + + var device = await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1")); + + var seenByB = await replicaB.GetDevices(new DeviceQuery { AccessToken = device.AccessToken }); + + Assert.Equal(device.Id, Assert.Single(seenByB.Items).Id); + Assert.Equal(user.Id, seenByB.Items[0].UserId); + } + + /// + /// Revocation has to propagate at least as fast as creation: a token logged out on one replica must not + /// still authenticate on another. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task TokenRevokedOnOneReplica_IsInvalidOnAnother() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await _server.CreateDatabaseAsync("device_replica_revoke", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken); + + var replicaA = CreateManager(dataSource, user); + var device = await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1")); + + // Replica B comes up after the login, so it starts out agreeing that the token is valid. + var replicaB = CreateManager(dataSource, user); + Assert.Single((await replicaB.GetDevices(new DeviceQuery { AccessToken = device.AccessToken })).Items); + + await replicaA.DeleteDevice(device); + + Assert.Empty((await replicaB.GetDevices(new DeviceQuery { AccessToken = device.AccessToken })).Items); + } + + /// + /// A device renamed on one replica has to be reported under its new name by the others, and the rename has + /// to survive being read back through a replica that never saw the write. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeviceOptionsWrittenOnOneReplica_AreReadOnAnother() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await _server.CreateDatabaseAsync("device_replica_options", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken); + + var replicaA = CreateManager(dataSource, user); + var replicaB = CreateManager(dataSource, user); + + await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1")); + await replicaA.UpdateDeviceOptions("device-1", "Kitchen TV"); + + var options = await replicaB.GetDeviceOptions("device-1"); + Assert.Equal("Kitchen TV", options?.CustomName); + + var info = await replicaB.GetDevice("device-1"); + Assert.Equal("Kitchen TV", info?.CustomName); + } + + /// + /// Activity written by the replica serving the request has to be visible to the others, because the next + /// request from the same client can land anywhere. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeviceUpdatedOnOneReplica_IsReadOnAnother() + { + var cancellationToken = TestContext.Current.CancellationToken; + var connectionString = await _server.CreateDatabaseAsync("device_replica_update", cancellationToken); + + await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); + var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken); + + var replicaA = CreateManager(dataSource, user); + var replicaB = CreateManager(dataSource, user); + + var device = await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1")); + device.AppVersion = "2.0.0"; + await replicaA.UpdateDevice(device); + + var seenByB = Assert.Single((await replicaB.GetDevices(new DeviceQuery { DeviceId = "device-1" })).Items); + Assert.Equal("2.0.0", seenByB.AppVersion); + } + + private static DeviceManager CreateManager(NpgsqlDataSource dataSource, User user) + { + var userManager = new Mock(); + userManager.Setup(manager => manager.GetUserById(user.Id)).Returns(user); + return new DeviceManager(new DataSourceContextFactory(dataSource), userManager.Object); + } + + private static async Task CreateSchemaWithUserAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + var context = CreateContext(dataSource); + await using (context.ConfigureAwait(false)) + { + await context.Database.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false); + + var user = new User("replica-user", "provider", "provider"); + context.Users.Add(user); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return user; + } + } + + private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource) + { + var optionsBuilder = new DbContextOptionsBuilder(); + var provider = new PostgreSqlDatabaseProvider(dataSource); + provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" }); + return new JellyfinDbContext( + optionsBuilder.Options, + NullLogger.Instance, + provider, + new NoLockBehavior(NullLogger.Instance)); + } + + /// + /// Hands every its own context over the one shared database, the way the + /// pooled factory does in the server. + /// + private sealed class DataSourceContextFactory : IDbContextFactory + { + private readonly NpgsqlDataSource _dataSource; + + public DataSourceContextFactory(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public JellyfinDbContext CreateDbContext() => CreateContext(_dataSource); + } +}