Files
SilverMetal/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs
sysadmin f44fa150e2 fix(first-boot): run hardening from toolbox, repair branding online re-apply, bake winget into image, Apply UX
Three regressions surfaced by VM 102 validation, plus the winget reliability fix:

- Hardening never ran. SetupComplete.cmd DEFERS hardening to the toolbox when the
  Welcome app is present ("hardening deferred to SilverOS Welcome"), but ApplyService
  only did apps->bitlocker->done — the call was dropped in the collector slim-down, so
  all 8 modules were staged-but-never-executed. Add IHardeningService/HardeningService
  and run it (with the flavour's module selection) as the last Apply step.

- Branding disappeared. Apply-Branding.ps1 -Mode Online crashed looking for
  C:\branding.manifest.json (param default's $PSScriptRoot came back unrooted under
  -File), so the post-OOBE re-apply never ran and personalization reverted. Resolve the
  manifest/assets robustly in the body, falling back to the script's own directory.

- Apps didn't install. The runtime winget bootstrap failed silently on IoT LTSC
  (exit 1, no diag). Provision App Installer + VCLibs + UI.Xaml into the offline image
  at build time (Add-AppxProvisionedPackage) so winget is present at first boot. The
  runtime bootstrap remains as a non-fatal fallback.

- Apply UX looked hung. Add a continuous progress-bar sheen + spinner + "this can take
  several minutes" hint, and make the percentages monotonic (apps 30->70, bitlocker 75,
  hardening 90, done 100).

Tests: 32 passing (ApplyService now verifies apps->bitlocker->hardening order + that
hardening receives the flavour modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:34:07 +01:00

108 lines
4.8 KiB
C#

using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Apps;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyServiceTests
{
private static Mock<IAppInstaller> NoApps()
{
var installer = new Mock<IAppInstaller>();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<AppInstallResult>());
return installer;
}
private static FlavourManifest Flavour() =>
new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } };
[Fact]
public async Task Runs_apps_then_bitlocker_then_hardening_when_pin_supplied()
{
var order = new List<string>();
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var installer = NoApps();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var hard = new Mock<IHardeningService>();
hard.Setup(h => h.RunAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("hardening")).Returns(Task.CompletedTask);
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[] { "apps", "bitlocker", "hardening" }, order);
Assert.Contains("Installing apps", progress);
Assert.Contains("Applying security hardening", progress);
Assert.Contains("Done", progress);
}
[Fact]
public async Task Empty_pin_skips_bitlocker_but_still_hardens()
{
var order = new List<string>();
var bl = new Mock<IBitLockerService>();
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var installer = NoApps();
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
var hard = new Mock<IHardeningService>();
hard.Setup(h => h.RunAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.Callback(() => order.Add("hardening")).Returns(Task.CompletedTask);
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
var req = new ApplyRequest(Flavour(), "", System.Array.Empty<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
Assert.Equal(new[] { "apps", "hardening" }, order);
bl.Verify(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Hardening_runs_with_the_flavour_modules()
{
var bl = new Mock<IBitLockerService>();
var installer = NoApps();
var hard = new Mock<IHardeningService>();
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
// Flavour() declares Modules = ["00"]; hardening must be invoked with exactly that.
hard.Verify(h => h.RunAsync(
It.Is<IReadOnlyList<string>>(m => m.Count == 1 && m[0] == "00"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Installs_the_requested_apps()
{
var bl = new Mock<IBitLockerService>();
var installer = NoApps();
var hard = new Mock<IHardeningService>();
var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } };
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
var req = new ApplyRequest(Flavour(), "123456", apps);
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
installer.Verify(i => i.InstallAsync(apps, It.IsAny<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once);
bl.Verify(b => b.EnableAsync("123456", It.IsAny<CancellationToken>()), Times.Once);
}
}