LANCommander/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs

229 lines
9.3 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Enums;
using LANCommander.SDK.PowerShell;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
using SdkSettings = LANCommander.SDK.Models.Settings;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
namespace LANCommander.Launcher.Tests.Tests;
/// <summary>
/// Verifies the admin-elevation path for launcher scripts. When a script is flagged
/// <c>#Requires -RunAsAdministrator</c> and the launcher is not already elevated, the interceptor
/// must re-launch the launcher as a minimal elevated process, pass it every runtime parameter the
/// script needs, wait until that process exits, and only then report the script as handled. In every
/// other case (no admin required, already elevated, or a failure) it must fall through so the script
/// runs in-process.
/// </summary>
public class ElevatedScriptInterceptorTests
{
private static PowerShellScript CreateScript(ScriptType type)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISettingsProvider, FakeSettingsProvider>();
var provider = services.BuildServiceProvider();
return new PowerShellScript(provider, type, Options.Create(new SdkSettings()));
}
[Fact]
public async Task NonAdminScript_ReturnsFalse_AndDoesNotLaunchElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install);
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
// Note: not calling AsAdmin() — script does not require elevation.
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
Assert.Equal(0, launcher.LaunchCount);
}
[Fact]
public async Task AdminScript_WhenAlreadyElevated_ReturnsFalse_AndDoesNotLaunchElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = true };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
Assert.Equal(0, launcher.LaunchCount);
}
[Fact]
public async Task AdminScript_WhenNotElevated_LaunchesMinimalLauncherWithRunAsParametersAndWaits()
{
var gameId = Guid.NewGuid();
var processInfo = new FakeCurrentProcessInfo
{
IsElevated = false,
ExecutablePath = @"C:\LANCommander\LANCommander.Launcher.exe",
};
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin().UseWorkingDirectory("WorkDir");
script.AddVariable("GameManifest", new ManifestGame { Id = gameId });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.True(handled);
Assert.Equal(1, launcher.LaunchCount);
var request = Assert.Single(launcher.Requests);
// Re-launches this same launcher executable as the elevated process.
Assert.Equal(processInfo.ExecutablePath, request.FileName);
// Preserves the working directory so the elevated script runs in the right place.
Assert.Equal("WorkDir", request.WorkingDirectory);
// Passes the RunScript verb plus every parameter the elevated process needs to run the script.
Assert.Contains("RunScript", request.Arguments);
Assert.Contains(gameId.ToString(), request.Arguments);
Assert.Contains("InstallDir", request.Arguments);
Assert.Contains(ScriptType.Install.ToString(), request.Arguments);
// The interceptor must not report the script handled until the elevated process has exited.
Assert.True(launcher.CompletedBeforeReturn);
}
[Fact]
public async Task KeyChangeScript_ForwardsAllocatedKeyToElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.KeyChange).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
script.AddVariable("AllocatedKey", "KEY-12345");
var handled = await interceptor.ExecuteAsync(script);
Assert.True(handled);
var request = Assert.Single(launcher.Requests);
Assert.Contains(ScriptType.KeyChange.ToString(), request.Arguments);
Assert.Contains("KEY-12345", request.Arguments);
}
[Fact]
public async Task NameChangeScript_ForwardsOldAndNewAliasesToElevatedProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.NameChange).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
script.AddVariable("OldPlayerAlias", "OldAlias");
script.AddVariable("NewPlayerAlias", "NewAlias");
var handled = await interceptor.ExecuteAsync(script);
Assert.True(handled);
var request = Assert.Single(launcher.Requests);
Assert.Contains(ScriptType.NameChange.ToString(), request.Arguments);
Assert.Contains("OldAlias", request.Arguments);
Assert.Contains("NewAlias", request.Arguments);
}
[Fact]
public async Task WhenElevationCheckThrows_ReturnsFalse_SoScriptRunsInProcess()
{
var processInfo = new ThrowingCurrentProcessInfo();
var launcher = new RecordingElevatedProcessLauncher();
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
Assert.Equal(0, launcher.LaunchCount);
}
[Fact]
public async Task WhenElevatedLaunchFails_ReturnsFalse_SoScriptRunsInProcess()
{
var processInfo = new FakeCurrentProcessInfo { IsElevated = false };
var launcher = new RecordingElevatedProcessLauncher { ThrowOnLaunch = true };
var interceptor = new ElevatedScriptInterceptor(processInfo, launcher);
var script = CreateScript(ScriptType.Install).AsAdmin();
script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() });
script.AddVariable("InstallDirectory", "InstallDir");
var handled = await interceptor.ExecuteAsync(script);
Assert.False(handled);
}
private sealed class FakeCurrentProcessInfo : ICurrentProcessInfo
{
public string ExecutablePath { get; init; } = @"C:\LANCommander\LANCommander.Launcher.exe";
public bool IsElevated { get; init; }
}
private sealed class ThrowingCurrentProcessInfo : ICurrentProcessInfo
{
public string ExecutablePath => throw new InvalidOperationException("path unavailable");
public bool IsElevated => throw new InvalidOperationException("cannot determine elevation");
}
private sealed class RecordingElevatedProcessLauncher : IElevatedProcessLauncher
{
public List<ElevatedProcessRequest> Requests { get; } = new();
public int LaunchCount => Requests.Count;
public bool ThrowOnLaunch { get; init; }
/// <summary>Set once the (awaited) launch has fully completed. Proves the caller waited.</summary>
public bool CompletedBeforeReturn { get; private set; }
public async Task LaunchAndWaitAsync(ElevatedProcessRequest request)
{
Requests.Add(request);
if (ThrowOnLaunch)
throw new InvalidOperationException("elevated launch failed");
// Simulate the elevated process running for a moment; if the interceptor did not await
// this, CompletedBeforeReturn would still be false when ExecuteAsync returns.
await Task.Delay(20);
CompletedBeforeReturn = true;
}
}
private sealed class FakeSettingsProvider : ISettingsProvider
{
public SdkSettings CurrentValue { get; } = new();
public void Update(Action<SdkSettings> patch) => patch(CurrentValue);
}
}