Files
jellyfin-ha-src/tests/Jellyfin.Server.Tests/Devices/DeviceManagerReplicaTests.cs
unkin-agent 56919b9565
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
fix(devices): read devices and device options through to the database
The device cache was filled once at construction, so a token minted by one
replica was unknown to every other replica already running and a token revoked
on one replica stayed valid on the others until they restarted.

- drop the eager device and device options dictionaries
- read devices and device options from the database on every query
- push the device query filters and ordering into SQL
- open the request's database context only for the api key fallback
- cover both directions against real PostgreSQL with two manager instances
2026-09-13 13:08:47 +10:00

198 lines
8.5 KiB
C#

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;
/// <summary>
/// Two independently constructed <see cref="DeviceManager"/> 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.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class DeviceManagerReplicaTests : IAsyncLifetime
{
private PostgreSqlTestServer _server = null!;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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);
}
/// <summary>
/// Revocation has to propagate at least as fast as creation: a token logged out on one replica must not
/// still authenticate on another.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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<IUserManager>();
userManager.Setup(manager => manager.GetUserById(user.Id)).Returns(user);
return new DeviceManager(new DataSourceContextFactory(dataSource), userManager.Object);
}
private static async Task<User> 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<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
/// <summary>
/// Hands every <see cref="DeviceManager"/> its own context over the one shared database, the way the
/// pooled factory does in the server.
/// </summary>
private sealed class DataSourceContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly NpgsqlDataSource _dataSource;
public DataSourceContextFactory(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public JellyfinDbContext CreateDbContext() => CreateContext(_dataSource);
}
}