Fix tests, add tests for user limits

This commit is contained in:
Pat Hartl 2026-06-28 14:09:34 -05:00
parent fa1559c367
commit 69f8447cae
13 changed files with 331 additions and 59 deletions

View file

@ -1,12 +1,18 @@
using System.Data.Common;
using System.Text;
using LANCommander.SDK.Providers;
using LANCommander.Server.Data;
using LANCommander.Server.Services.Abstractions;
using LANCommander.Server.Tests.Mocks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ServerSettings = LANCommander.Server.Settings.Settings;
namespace LANCommander.Server.Tests;
@ -54,6 +60,28 @@ public class ApplicationFactory<TProgram>
services.AddSingleton(GitHubServiceMockFactory.Create());
#endregion
#region JWT signing key alignment
// The server snapshots the JWT signing secret from configuration when AddIdentity runs,
// but ValidateSettings regenerates the secret at startup whenever it is missing (which is
// always the case in tests, where no persisted settings file provides one). That leaves the
// bearer validation key pinned to the empty pre-regeneration value while tokens are signed
// with the regenerated secret, so every authenticated API call fails with 401. Re-bind the
// validation key from the live settings provider once the regenerated secret exists.
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, AlignJwtSigningKey>();
#endregion
});
}
private sealed class AlignJwtSigningKey(SettingsProvider<ServerSettings> settingsProvider)
: IPostConfigureOptions<JwtBearerOptions>
{
public void PostConfigure(string? name, JwtBearerOptions options)
{
var secret = settingsProvider.CurrentValue.Server.Authentication.TokenSecret;
options.TokenValidationParameters.IssuerSigningKey =
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
}
}
}

View file

@ -1,28 +1,78 @@
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Providers;
using LANCommander.SDK.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Server.Tests;
public class ApplicationFixture : ApplicationFactory<Program>
{
public static ApplicationFixture Instance;
public SDK.Client Client { get; set; }
/// <summary>Server-side service provider (the in-memory app under test).</summary>
public IServiceProvider ServiceProvider { get; set; }
/// <summary>Client-side SDK service provider, wired to talk to the in-memory server.</summary>
public IServiceProvider ClientServiceProvider { get; set; }
/// <summary>HttpClient whose handler routes to the in-memory server.</summary>
public HttpClient HttpClient { get; }
public Uri ServerAddress { get; }
public AuthenticationClient AuthenticationClient { get; }
public GameClient GameClient { get; }
public SaveClient SaveClient { get; }
public TagClient TagClient { get; }
public ApplicationFixture(ApplicationFactory<Program> factory)
{
if (Instance != null)
return;
Client = new SDK.Client(factory.CreateClient(), "C:\\Games");
ServiceProvider = factory.Services;
HttpClient = factory.CreateClient();
ServerAddress = HttpClient.BaseAddress!;
// Build a separate SDK client container (mirrors the launcher's composition) whose
// injected HttpClient is the in-memory test handler, so all API calls route to the
// server under test instead of hitting the network.
var services = new ServiceCollection();
services.AddLogging();
services.AddOptions<SDK.Models.Settings>().Configure(_ => { });
services.AddSingleton<IServerConfigurationRefresher>(NoopRefresher.Instance);
services.AddLANCommanderClient<SDK.Models.Settings>();
services.AddSingleton(HttpClient);
ClientServiceProvider = services.BuildServiceProvider();
ClientServiceProvider.GetRequiredService<IServerAddressProvider>().SetServerAddress(ServerAddress);
AuthenticationClient = ClientServiceProvider.GetRequiredService<AuthenticationClient>();
GameClient = ClientServiceProvider.GetRequiredService<GameClient>();
SaveClient = ClientServiceProvider.GetRequiredService<SaveClient>();
TagClient = ClientServiceProvider.GetRequiredService<TagClient>();
Instance = this;
}
/// <summary>Authenticates against the in-memory server and stores the token for subsequent client calls.</summary>
public Task AuthenticateAsync(string username, string password)
=> AuthenticationClient.AuthenticateAsync(username, password, ServerAddress);
private sealed class NoopRefresher : IServerConfigurationRefresher
{
public static readonly NoopRefresher Instance = new();
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
[CollectionDefinition("Application")]
public class ApplicationCollection : ICollectionFixture<ApplicationFactory<Program>>
{
}
}

View file

@ -1,5 +1,6 @@
using System.Formats.Asn1;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using LANCommander.Server.Data.Models;
using LANCommander.Server.Services;
using Microsoft.EntityFrameworkCore;
@ -12,17 +13,25 @@ namespace LANCommander.Server.Tests;
[TestCaseOrderer(DependencyOrderer.TypeName, DependencyOrderer.AssemblyName)]
public abstract class BaseTest : IClassFixture<ApplicationFixture>, IDisposable
{
protected readonly SDK.Client Client = ApplicationFixture.Instance.Client;
protected AuthenticationClient AuthenticationClient => ApplicationFixture.Instance.AuthenticationClient;
protected GameClient GameClient => ApplicationFixture.Instance.GameClient;
protected SaveClient SaveClient => ApplicationFixture.Instance.SaveClient;
protected TagClient TagClient => ApplicationFixture.Instance.TagClient;
protected readonly IServiceProvider ServiceProvider;
private AsyncServiceScope? _scope;
public BaseTest(ApplicationFixture fixture)
{
_scope = ApplicationFixture.Instance.ServiceProvider.CreateAsyncScope();
ServiceProvider = _scope?.ServiceProvider;
}
/// <summary>Authenticates against the in-memory server; the token is reused by the SDK clients.</summary>
protected Task AuthenticateAsync(string username, string password)
=> ApplicationFixture.Instance.AuthenticateAsync(username, password);
protected T GetService<T>() => ServiceProvider.GetService<T>();

View file

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc.Testing;
using LANCommander.SDK.Extensions;
using Shouldly;
namespace LANCommander.Server.Tests.Client;
@ -16,8 +16,18 @@ public class AuthenticationTests : IClassFixture<ApplicationFixture>
[Fact]
public async Task PingShouldWork()
{
var response = await _fixture.Client.PingAsync();
response.ShouldBeTrue();
// ConnectionClient.PingAsync uses a static HttpClient that does real network I/O and so
// cannot reach the in-memory test server. Exercise the server's PingMiddleware directly
// through the in-memory handler instead, asserting the same X-Ping/X-Pong contract.
var pingId = Guid.NewGuid().ToString();
var request = new HttpRequestMessage(HttpMethod.Head, _fixture.ServerAddress);
request.Headers.Add("X-Ping", pingId);
var response = await _fixture.HttpClient.SendAsync(request);
response.IsSuccessStatusCode.ShouldBeTrue();
response.Headers.Contains("X-Pong").ShouldBeTrue();
response.Headers.GetValues("X-Pong").First().ShouldBe(pingId.FastReverse());
}
}

View file

@ -1,4 +1,3 @@
using LANCommander.SDK.Services;
using Shouldly;
using LANCommander.Server.Services;
@ -12,13 +11,12 @@ public class VersioningTests(ApplicationFixture fixture) : BaseTest(fixture)
{
// Simple service that's not bound to change much
var tagService = GetService<TagService>();
var authenticationClient = GetService<AuthenticationClient>();
var user = await EnsureAdminUserCreatedAsync();
await authenticationClient.AuthenticateAsync(TestConstants.AdminUserName, TestConstants.AdminInitialPassword);
var response = await Client.Tags.CreateAsync(new SDK.Models.Tag
var user = await EnsureAdminUserCreatedAsync();
await AuthenticateAsync(TestConstants.AdminUserName, TestConstants.AdminInitialPassword);
var response = await TagClient.CreateAsync(new SDK.Models.Tag
{
Name = "Test Tag",
});

View file

@ -0,0 +1,60 @@
using System.Diagnostics;
using LANCommander.Server.Endpoints;
using Shouldly;
namespace LANCommander.Server.Tests.Endpoints;
public class ThrottledStreamTests
{
[Fact]
public async Task UnlimitedStreamReadsAllBytesWithoutDelay()
{
var data = new byte[256 * 1024];
Random.Shared.NextBytes(data);
using var inner = new MemoryStream(data);
await using var throttled = new ThrottledStream(inner, 0);
var output = new MemoryStream();
var stopwatch = Stopwatch.StartNew();
await throttled.CopyToAsync(output, 16 * 1024);
stopwatch.Stop();
output.ToArray().ShouldBe(data);
stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
}
[Fact]
public async Task ThrottledStreamLimitsThroughput()
{
// 200 KB at 100 KB/s should take at least ~1 second (one full window beyond the first).
const int rate = 100 * 1024;
var data = new byte[200 * 1024];
using var inner = new MemoryStream(data);
await using var throttled = new ThrottledStream(inner, rate);
var output = new MemoryStream();
var stopwatch = Stopwatch.StartNew();
await throttled.CopyToAsync(output, 16 * 1024);
stopwatch.Stop();
output.Length.ShouldBe(data.Length);
stopwatch.Elapsed.ShouldBeGreaterThan(TimeSpan.FromMilliseconds(800));
}
[Fact]
public void LengthAndSeekAreDelegatedToInner()
{
var data = new byte[1024];
using var inner = new MemoryStream(data);
using var throttled = new ThrottledStream(inner, 1024);
throttled.Length.ShouldBe(data.Length);
throttled.CanSeek.ShouldBeTrue();
throttled.Seek(100, SeekOrigin.Begin);
throttled.Position.ShouldBe(100);
inner.Position.ShouldBe(100);
}
}

View file

@ -1,5 +1,6 @@
using LANCommander.Server.Services.Abstractions;
using LANCommander.Server.Services.Models;
using LANCommander.Server.Settings.Enums;
using Moq;
using Octokit;
using Semver;
@ -79,6 +80,20 @@ public static class GitHubServiceMockFactory
return CreateRelease(version);
});
// Setup GetReleaseAsync (tag overload) — UpdateService resolves releases by string tag
// (e.g. "v1.0.0" or "nightly"); without this the mock returns null and callers NRE.
mock.Setup(x => x.GetReleaseAsync(It.IsAny<string>()))
.ReturnsAsync((string tag) =>
{
var trimmed = tag?.TrimStart('v');
var version = SemVersion.TryParse(trimmed, SemVersionStyles.Any, out var parsed)
? parsed
: Version;
return CreateRelease(version);
});
// Setup GetReleasesAsync
mock.Setup(x => x.GetReleasesAsync(It.IsAny<int>()))
.ReturnsAsync((int count) =>

View file

@ -1,5 +1,8 @@
using LANCommander.Server.Services.Abstractions;
using LANCommander.Server.Services.Models;
using System;
using System.Linq;
using LANCommander.Server.Settings.Enums;
using Semver;
namespace LANCommander.Server.Tests.Mocks;
@ -15,7 +18,16 @@ public class VersionProviderMock : IVersionProvider
public ReleaseChannel GetReleaseChannel(SemVersion version)
{
throw new NotImplementedException();
if (version.IsRelease)
return ReleaseChannel.Stable;
if (version.IsPrerelease && version.PrereleaseIdentifiers.Any(pi => pi.Value == "nightly"))
return ReleaseChannel.Nightly;
if (version.IsPrerelease)
return ReleaseChannel.Prerelease;
throw new ArgumentException("Could not parse version number", nameof(version));
}
public static void SetVersion(string version)

View file

@ -1,7 +1,7 @@
using LANCommander.Server.Services.Factories;
using Action = LANCommander.SDK.Models.Manifest.Action;
using LANCommander.SDK.Enums;
using LANCommander.Server.ImportExport.Factories;
using LANCommander.Server.Services;
namespace LANCommander.Server.Tests.Services;
@ -244,32 +244,23 @@ public class ImporterTests(ApplicationFixture fixture) : BaseTest(fixture)
}
};
// Set the manifest on the import context
var gameService = GetService<GameService>();
// Prepare the import queue with all flags
var importFlags = ImportRecordFlags.Actions | ImportRecordFlags.Archives | ImportRecordFlags.Collections |
ImportRecordFlags.CustomFields | ImportRecordFlags.Developers | ImportRecordFlags.Engine |
ImportRecordFlags.Genres | ImportRecordFlags.Keys | ImportRecordFlags.Media |
ImportRecordFlags.MultiplayerModes | ImportRecordFlags.Platforms | ImportRecordFlags.PlaySessions |
ImportRecordFlags.Publishers | ImportRecordFlags.Saves | ImportRecordFlags.SavePaths |
ImportRecordFlags.Scripts | ImportRecordFlags.Tags;
// Queue the manifest's metadata records (developers, engine, genres, multiplayer modes,
// publishers, save paths, tags and the game itself) and run the import pipeline. This is
// the archive-free entry point used for in-memory imports.
await importContext.InitializeMetadataUpdateAsync(manifest);
await importContext.PrepareGameImportQueueAsync(manifest, importFlags);
// Import the queue
await importContext.ImportQueueAsync();
// Assert that the game was imported successfully
Assert.NotNull(importContext.DataRecord);
Assert.IsType<LANCommander.Server.Data.Models.Game>(importContext.DataRecord);
var importedGame = (LANCommander.Server.Data.Models.Game)importContext.DataRecord;
// Assert that the game was imported successfully by reading it back from the service
var importedGame = await gameService.GetAsync(manifest.Id);
Assert.NotNull(importedGame);
Assert.Equal("Test Game", importedGame.Title);
Assert.Equal("A comprehensive test game for import testing", importedGame.Description);
// Assert that all items were processed
// Note: The exact count may vary depending on how the importers handle the data
// We expect at least some items to be processed
// Assert that all queued items were processed
Assert.True(importContext.Processed > 0, "At least some items should have been processed");
// Clean up

View file

@ -24,19 +24,19 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
{
File.WriteAllText("test.txt", "Hello World!");
using (var archive = ZipArchive.Create())
using (var archive = ZipArchive.CreateArchive())
{
archive.AddEntry("test.txt", "test.txt");
archive.SaveTo("test.zip", CompressionType.None);
var fileInfo = new FileInfo("test.zip");
fileInfo.Length.ShouldBe(126);
}
using (Stream stream = File.OpenRead("test.zip"))
using (var reader = ReaderFactory.Open(stream))
using (var reader = ReaderFactory.OpenReader(stream))
{
while (reader.MoveToNextEntry())
{
@ -63,7 +63,7 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
File.WriteAllText("test.txt", "Hello World!");
using (var ms = new MemoryStream())
using (var archive = ZipArchive.Create())
using (var archive = ZipArchive.CreateArchive())
{
archive.AddEntry("test.txt", "test.txt");
@ -73,7 +73,7 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
ms.Length.ShouldBe(126);
using (var reader = ReaderFactory.Open(ms))
using (var reader = ReaderFactory.OpenReader(ms))
{
while (reader.MoveToNextEntry())
{
@ -103,9 +103,9 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
var saveService = GetService<GameSaveService>();
var user = await EnsureAdminUserCreatedAsync();
await Client.AuthenticateAsync(TestConstants.AdminUserName, TestConstants.AdminInitialPassword);
await AuthenticateAsync(TestConstants.AdminUserName, TestConstants.AdminInitialPassword);
var installDirectory = GetTemporaryDirectory();
var tempPath = await EnsureStorageLocationsExistAsync();
@ -128,10 +128,10 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
game = await gameService.AddAsync(game);
// Mock game install directory
var sdkGame = await gameClient.GetAsync(game.Id);
var sdkGame = await GameClient.GetAsync(game.Id);
var gameInstallDirectory = await gameClient.GetInstallDirectory(sdkGame, installDirectory);
var manifest = gameClient.GetManifest(game.Id);
var gameInstallDirectory = await GameClient.GetInstallDirectory(sdkGame, installDirectory);
var manifest = await GameClient.GetManifestAsync(game.Id);
Directory.CreateDirectory(Path.Combine(gameInstallDirectory, ".lancommander"));
Directory.CreateDirectory(Path.Combine(gameInstallDirectory, "save"));
@ -160,7 +160,7 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
var stream = await savePacker.PackAsync();
using (var reader = ReaderFactory.Open(stream, new ReaderOptions()
using (var reader = ReaderFactory.OpenReader(stream, new ReaderOptions()
{
LeaveStreamOpen = true,
}))
@ -184,7 +184,7 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
packedSize = stream.Length;
uploadedSave = await Client.Saves.UploadAsync(stream, manifest);
uploadedSave = await SaveClient.UploadAsync(stream, manifest);
}
#endregion
@ -209,7 +209,7 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
// Check contents of file
using (var fs = File.OpenRead(uploadedSavePath))
using (var reader = ReaderFactory.Open(fs, new ReaderOptions()
using (var reader = ReaderFactory.OpenReader(fs, new ReaderOptions()
{
LeaveStreamOpen = true,
}))

View file

@ -1,6 +1,7 @@
using LANCommander.Server.Data.Models;
using LANCommander.Server.Services;
using LANCommander.Server.Services.Models;
using LANCommander.Server.Settings.Enums;
using LANCommander.Server.Tests.Mocks;
using Moq;
using Shouldly;

View file

@ -0,0 +1,94 @@
using LANCommander.Server.Services;
using Shouldly;
namespace LANCommander.Server.Tests.Services;
public class UserLimitsResolveTests
{
[Theory]
[InlineData(50, 50)] // explicit override wins over role
[InlineData(0, 0)] // override of 0 (unlimited) wins
[InlineData(5, 5)] // override below role values still wins
public void UserOverrideTakesPrecedence(int? userOverride, int expected)
{
var result = UserService.Resolve(userOverride, new int?[] { 10, 20 });
result.ShouldBe(expected);
}
[Fact]
public void NoOverrideUsesLowestNonZeroRoleValue()
{
var result = UserService.Resolve(null, new int?[] { 20, 5, 50 });
result.ShouldBe(5);
}
[Fact]
public void NullAndZeroRoleValuesAreIgnoredWhenResolving()
{
// null = not configured, 0 = explicit unlimited; both ignored so the only real limit (15) wins.
var result = UserService.Resolve(null, new int?[] { null, 0, 15 });
result.ShouldBe(15);
}
[Fact]
public void AllRolesUnsetMeansUnlimited()
{
var result = UserService.Resolve(null, new int?[] { null, null });
result.ShouldBe(0);
}
[Fact]
public void AllRolesExplicitlyUnlimitedMeansUnlimited()
{
var result = UserService.Resolve(null, new int?[] { 0, 0 });
result.ShouldBe(0);
}
[Fact]
public void NoRolesAndNoOverrideMeansUnlimited()
{
var result = UserService.Resolve(null, Array.Empty<int?>());
result.ShouldBe(0);
}
[Theory]
[InlineData(true, true)] // override true (enabled) wins even though a role disables
[InlineData(false, false)] // override false (disabled) wins even though roles enable
public void BoolUserOverrideTakesPrecedence(bool? userOverride, bool expected)
{
var result = UserService.Resolve(userOverride, new bool?[] { true, false });
result.ShouldBe(expected);
}
[Fact]
public void BoolAnyRoleDisabledMeansDisabled()
{
// false = explicitly disabled; most restrictive wins so the effective permission is disabled.
var result = UserService.Resolve((bool?)null, new bool?[] { true, false, null });
result.ShouldBeFalse();
}
[Fact]
public void BoolAllRolesUnsetOrEnabledMeansEnabled()
{
var result = UserService.Resolve((bool?)null, new bool?[] { null, true });
result.ShouldBeTrue();
}
[Fact]
public void BoolNoRolesAndNoOverrideMeansEnabled()
{
var result = UserService.Resolve((bool?)null, Array.Empty<bool?>());
result.ShouldBeTrue();
}
}

View file

@ -23,11 +23,15 @@ public class UserServiceTests(ApplicationFixture fixture) : BaseTest(fixture)
var userService = GetService<UserService>();
var result = await userService.ChangePassword(TestConstants.AdminUserName, TestConstants.AdminInitialPassword, TestConstants.AdminPassword);
result.Succeeded.ShouldBeTrue();
var validPassword = await userService.CheckPassword(TestConstants.AdminUserName, TestConstants.AdminPassword);
validPassword.ShouldBeTrue();
// The admin account is shared across the in-memory test database, so restore the original
// password to avoid breaking other tests that authenticate with AdminInitialPassword.
await userService.ChangePassword(TestConstants.AdminUserName, TestConstants.AdminPassword, TestConstants.AdminInitialPassword);
}
}