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>
This commit is contained in:
@@ -42,6 +42,14 @@ function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan }
|
|||||||
|
|
||||||
if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' }
|
if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' }
|
||||||
|
|
||||||
|
# Resolve the manifest/assets robustly. The param defaults reference $PSScriptRoot,
|
||||||
|
# which can come back unrooted when this script is launched via -File from
|
||||||
|
# SetupComplete.cmd (the online re-apply crashed looking for C:\branding.manifest.json,
|
||||||
|
# wiping all branding after OOBE). Fall back to the script's own directory.
|
||||||
|
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||||
|
if (-not (Test-Path -LiteralPath $Manifest)) { $Manifest = Join-Path $scriptDir 'branding.manifest.json' }
|
||||||
|
if (-not (Test-Path -LiteralPath $AssetsDir)) { $AssetsDir = Join-Path $scriptDir 'assets' }
|
||||||
|
|
||||||
$m = Get-Content $Manifest -Raw | ConvertFrom-Json
|
$m = Get-Content $Manifest -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,48 @@ function Copy-WelcomePayload {
|
|||||||
Write-Host " Welcome payload staged at $dest"
|
Write-Host " Welcome payload staged at $dest"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Provision winget (App Installer) + its runtime deps into the OFFLINE image.
|
||||||
|
# IoT Enterprise LTSC ships no winget and the runtime online bootstrap proved
|
||||||
|
# unreliable (silent Add-AppxPackage failures), so we bake it in: download the
|
||||||
|
# msixbundle + VCLibs + UI.Xaml on the (internet-connected) build host, then
|
||||||
|
# Add-AppxProvisionedPackage into the mount so every first-boot user gets winget.
|
||||||
|
# Non-fatal: a transient download failure must not brick the whole ISO build — the
|
||||||
|
# staged runtime bootstrap-winget.ps1 remains as a last-resort fallback.
|
||||||
|
function Add-WingetToImage {
|
||||||
|
param([Parameter(Mandatory)][string]$MountPath, [Parameter(Mandatory)][string]$CacheDir)
|
||||||
|
Write-Stage 'Stage 3c2: provision winget (App Installer + VCLibs + UI.Xaml) into image'
|
||||||
|
try {
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$null = New-Item -ItemType Directory -Force $CacheDir
|
||||||
|
|
||||||
|
$bundle = Join-Path $CacheDir 'Microsoft.DesktopAppInstaller.msixbundle'
|
||||||
|
$vclibs = Join-Path $CacheDir 'Microsoft.VCLibs.x64.14.00.Desktop.appx'
|
||||||
|
$xamlNupkg = Join-Path $CacheDir 'microsoft.ui.xaml.2.8.6.nupkg'
|
||||||
|
$xamlDir = Join-Path $CacheDir 'uixaml'
|
||||||
|
|
||||||
|
Write-Host ' downloading App Installer (aka.ms/getwinget)'
|
||||||
|
Invoke-WebRequest -UseBasicParsing 'https://aka.ms/getwinget' -OutFile $bundle
|
||||||
|
Write-Host ' downloading VCLibs desktop framework'
|
||||||
|
Invoke-WebRequest -UseBasicParsing 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx' -OutFile $vclibs
|
||||||
|
Write-Host ' downloading Microsoft.UI.Xaml 2.8.6 (nupkg)'
|
||||||
|
Invoke-WebRequest -UseBasicParsing 'https://globalcdn.nuget.org/packages/microsoft.ui.xaml.2.8.6.nupkg' -OutFile $xamlNupkg
|
||||||
|
|
||||||
|
# The UI.Xaml appx lives inside the nupkg (a zip): tools\AppX\x64\Release\Microsoft.UI.Xaml.2.8.appx
|
||||||
|
if (Test-Path $xamlDir) { Remove-Item $xamlDir -Recurse -Force }
|
||||||
|
Expand-Archive -LiteralPath $xamlNupkg -DestinationPath $xamlDir -Force
|
||||||
|
$xaml = Get-ChildItem (Join-Path $xamlDir 'tools\AppX\x64\Release') -Filter 'Microsoft.UI.Xaml.*.appx' -EA SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $xaml) { throw "UI.Xaml appx not found inside nupkg at tools\AppX\x64\Release" }
|
||||||
|
|
||||||
|
Write-Host ' provisioning App Installer into image (Add-AppxProvisionedPackage)'
|
||||||
|
Add-AppxProvisionedPackage -Path $MountPath -PackagePath $bundle `
|
||||||
|
-DependencyPackagePath $vclibs, $xaml.FullName -SkipLicense | Out-Null
|
||||||
|
Write-Host ' winget provisioned into image OK' -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Warning " winget provisioning FAILED (continuing; runtime bootstrap remains as fallback): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- 3. Service the WIM offline (DISM) -------------------------------------
|
# --- 3. Service the WIM offline (DISM) -------------------------------------
|
||||||
function Invoke-ServiceWim {
|
function Invoke-ServiceWim {
|
||||||
Write-Stage 'Stage 3: offline-service install.wim'
|
Write-Stage 'Stage 3: offline-service install.wim'
|
||||||
@@ -303,6 +345,14 @@ function Invoke-ServiceWim {
|
|||||||
# Stage Welcome app + flavours while the WIM is still mounted.
|
# Stage Welcome app + flavours while the WIM is still mounted.
|
||||||
Copy-WelcomePayload
|
Copy-WelcomePayload
|
||||||
|
|
||||||
|
# Provision winget (App Installer) + its deps INTO the image. IoT Enterprise LTSC
|
||||||
|
# ships no winget, and the runtime online bootstrap proved unreliable (silent
|
||||||
|
# Add-AppxPackage failures). Baking it in means winget is present for every user
|
||||||
|
# at first boot, so the toolbox's app installs work without a runtime download.
|
||||||
|
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
|
||||||
|
Add-WingetToImage -MountPath $mount -CacheDir (Join-Path $WorkDir 'winget')
|
||||||
|
}
|
||||||
|
|
||||||
# Bake the four branding layers into the offline hives (must be inside the mount).
|
# Bake the four branding layers into the offline hives (must be inside the mount).
|
||||||
Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)'
|
Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)'
|
||||||
& (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount
|
& (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount
|
||||||
|
|||||||
@@ -54,11 +54,13 @@ public static class MauiProgram
|
|||||||
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
|
builder.Services.AddSingleton<IFlavourLoader, FlavourLoader>();
|
||||||
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
builder.Services.AddSingleton<IAppCatalog, AppCatalog>();
|
||||||
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
builder.Services.AddSingleton<IBitLockerService, BitLockerService>();
|
||||||
|
builder.Services.AddSingleton<IHardeningService, HardeningService>();
|
||||||
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
var appsDir = Path.Combine(AppContext.BaseDirectory, "apps");
|
||||||
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
|
builder.Services.AddSingleton<IAppInstaller>(sp => new AppInstaller(sp.GetRequiredService<IProcessRunner>(), appsDir));
|
||||||
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
builder.Services.AddSingleton<IApplyService>(sp => new ApplyService(
|
||||||
sp.GetRequiredService<IBitLockerService>(),
|
sp.GetRequiredService<IBitLockerService>(),
|
||||||
sp.GetRequiredService<IAppInstaller>()));
|
sp.GetRequiredService<IAppInstaller>(),
|
||||||
|
sp.GetRequiredService<IHardeningService>()));
|
||||||
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
|
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
|
||||||
builder.Services.AddScoped<WizardState>();
|
builder.Services.AddScoped<WizardState>();
|
||||||
|
|
||||||
|
|||||||
@@ -754,12 +754,28 @@ h1:focus { outline: none; }
|
|||||||
animation: step-enter 0.35s var(--ease-out) both;
|
animation: step-enter 0.35s var(--ease-out) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apply-stage-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-spinner {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--clr-accent-glow);
|
||||||
|
border-top-color: var(--clr-accent);
|
||||||
|
animation: sm-spin 0.8s linear infinite;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.apply-stage-label {
|
.apply-stage-label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.apply-progress-track {
|
.apply-progress-track {
|
||||||
@@ -772,11 +788,29 @@ h1:focus { outline: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.apply-progress-bar {
|
.apply-progress-bar {
|
||||||
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%);
|
background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
transition: width 0.4s var(--ease-out);
|
transition: width 0.4s var(--ease-out);
|
||||||
box-shadow: 0 0 10px var(--clr-accent-glow);
|
box-shadow: 0 0 10px var(--clr-accent-glow);
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Continuous sheen so the bar visibly animates even when a single step (e.g. a
|
||||||
|
long winget install) holds the percentage static — it never looks hung. */
|
||||||
|
.apply-progress-bar.working::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: sm-shimmer 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sm-shimmer {
|
||||||
|
to { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.apply-percent-label {
|
.apply-percent-label {
|
||||||
@@ -787,6 +821,13 @@ h1:focus { outline: none; }
|
|||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apply-hint {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--clr-text-lo);
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Complete state */
|
/* Complete state */
|
||||||
.apply-complete {
|
.apply-complete {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using SilverOS.Welcome.Core.Apps;
|
using SilverOS.Welcome.Core.Apps;
|
||||||
namespace SilverOS.Welcome.Core.Apply;
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
|
|
||||||
// Toolbox Apply pipeline: apps -> bitlocker -> done.
|
// Toolbox Apply pipeline: apps -> bitlocker -> hardening -> done.
|
||||||
// Account creation moved to Windows Setup (WinPE collector); OS hardening runs from
|
// Account creation moved to Windows Setup (WinPE collector); sm-bootstrap teardown is
|
||||||
// SetupComplete; sm-bootstrap teardown is owned by Setup, not the toolbox.
|
// owned by Setup. Hardening is DEFERRED to the toolbox by SetupComplete.cmd, so it runs
|
||||||
public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer) : IApplyService
|
// HERE (last, after the network-dependent app installs and the disk enrol).
|
||||||
|
public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller installer, IHardeningService hardening) : IApplyService
|
||||||
{
|
{
|
||||||
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -16,6 +17,12 @@ public sealed class ApplyService(IBitLockerService bitlocker, IAppInstaller inst
|
|||||||
progress.Report(new("Encrypting the disk", 75));
|
progress.Report(new("Encrypting the disk", 75));
|
||||||
await bitlocker.EnableAsync(req.BitLockerPin, ct);
|
await bitlocker.EnableAsync(req.BitLockerPin, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run hardening last: it can lock down the network/execution surface, so it must
|
||||||
|
// come after the (network-dependent) app installs and the BitLocker PowerShell call.
|
||||||
|
progress.Report(new("Applying security hardening", 90));
|
||||||
|
await hardening.RunAsync(req.Flavour?.Hardening.Modules ?? Array.Empty<string>(), ct);
|
||||||
|
|
||||||
progress.Report(new("Done", 100));
|
progress.Report(new("Done", 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
|
|
||||||
|
// Runs the §A–H hardening modules. SetupComplete.cmd DEFERS hardening to the toolbox
|
||||||
|
// when the Welcome app is present ("hardening deferred to SilverOS Welcome"), so the
|
||||||
|
// toolbox MUST actually run it here — a collector slim-down dropped this call, which
|
||||||
|
// left hardening staged-but-never-executed on every install.
|
||||||
|
public sealed class HardeningService(IProcessRunner runner) : IHardeningService
|
||||||
|
{
|
||||||
|
// build.ps1 stages the payload here (Windows\Setup\Scripts\hardening).
|
||||||
|
private const string ScriptPath = @"C:\Windows\Setup\Scripts\hardening\Invoke-Hardening.ps1";
|
||||||
|
|
||||||
|
public async Task RunAsync(IReadOnlyList<string> modules, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Dev/VM image without the staged payload: no-op rather than crash the apply.
|
||||||
|
if (!File.Exists(ScriptPath)) return;
|
||||||
|
|
||||||
|
var args = $"-NoProfile -ExecutionPolicy Bypass -File \"{ScriptPath}\"";
|
||||||
|
if (modules is { Count: > 0 })
|
||||||
|
// Invoke-Hardening.ps1 takes a single CSV token ("00,03,05") so -File binding
|
||||||
|
// delivers it reliably regardless of quoting.
|
||||||
|
args += $" -Modules \"{string.Join(",", modules)}\"";
|
||||||
|
|
||||||
|
// Invoke-Hardening.ps1 runs with ErrorActionPreference=Continue and try/catches each
|
||||||
|
// module, so it returns 0 and logs its own per-module warnings. We deliberately do NOT
|
||||||
|
// EnsureSuccess: a single hardening warning must never block onboarding completion.
|
||||||
|
await runner.RunAsync("powershell.exe", args, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SilverOS.Welcome.Core.Apply;
|
||||||
|
|
||||||
|
public interface IHardeningService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the §A–H hardening modules from the image-staged payload. <paramref name="modules"/>
|
||||||
|
/// is the flavour's numeric module selection (e.g. ["00","03","05"]); empty = all modules.
|
||||||
|
/// </summary>
|
||||||
|
Task RunAsync(IReadOnlyList<string> modules, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -45,7 +45,9 @@ public sealed class AppInstaller(IProcessRunner runner, string appsDir) : IAppIn
|
|||||||
foreach (var app in apps)
|
foreach (var app in apps)
|
||||||
{
|
{
|
||||||
i++;
|
i++;
|
||||||
progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", 80));
|
// Spread app installs across 30→70% (monotonic: BitLocker=75, hardening=90 follow).
|
||||||
|
var pct = 30 + (int)(40.0 * (i - 1) / Math.Max(1, apps.Count));
|
||||||
|
progress.Report(new($"Installing {app.Name} ({i}/{apps.Count})", pct));
|
||||||
var ok = false;
|
var ok = false;
|
||||||
var id = app.Source.Winget;
|
var id = app.Source.Winget;
|
||||||
if (!string.IsNullOrWhiteSpace(id))
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
|||||||
@@ -30,11 +30,24 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="apply-progress-container">
|
<div class="apply-progress-container">
|
||||||
<div class="apply-stage-label">@_stageLabel</div>
|
<div class="apply-stage-row">
|
||||||
|
@if (!_complete)
|
||||||
|
{
|
||||||
|
<span class="apply-spinner" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
<div class="apply-stage-label">@_stageLabel</div>
|
||||||
|
</div>
|
||||||
<div class="apply-progress-track">
|
<div class="apply-progress-track">
|
||||||
<div class="apply-progress-bar" style="width: @(_percent)%"></div>
|
<div class="apply-progress-bar @(_complete ? "" : "working")" style="width: @(_percent)%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="apply-percent-label">@(_percent)%</div>
|
<div class="apply-percent-label">@(_percent)%</div>
|
||||||
|
@if (!_complete)
|
||||||
|
{
|
||||||
|
<p class="apply-hint">
|
||||||
|
Installing your apps and applying security hardening — this can take several
|
||||||
|
minutes. Please leave the device powered on.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_complete)
|
@if (_complete)
|
||||||
|
|||||||
@@ -19,12 +19,9 @@ public class ApplyServiceTests
|
|||||||
new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } };
|
new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Runs_apps_then_bitlocker_when_pin_supplied()
|
public async Task Runs_apps_then_bitlocker_then_hardening_when_pin_supplied()
|
||||||
{
|
{
|
||||||
var order = new List<string>();
|
var order = new List<string>();
|
||||||
var run = new Mock<IProcessRunner>();
|
|
||||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
|
||||||
var bl = new Mock<IBitLockerService>();
|
var bl = new Mock<IBitLockerService>();
|
||||||
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
||||||
@@ -32,25 +29,26 @@ public class ApplyServiceTests
|
|||||||
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
||||||
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
|
.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);
|
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
|
||||||
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
|
var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty<AppCatalogEntry>());
|
||||||
var progress = new List<string>();
|
var progress = new List<string>();
|
||||||
|
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
|
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
|
||||||
|
|
||||||
Assert.Equal(new[] { "apps", "bitlocker" }, order);
|
Assert.Equal(new[] { "apps", "bitlocker", "hardening" }, order);
|
||||||
Assert.Contains("Installing apps", progress);
|
Assert.Contains("Installing apps", progress);
|
||||||
|
Assert.Contains("Applying security hardening", progress);
|
||||||
Assert.Contains("Done", progress);
|
Assert.Contains("Done", progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Empty_pin_skips_bitlocker()
|
public async Task Empty_pin_skips_bitlocker_but_still_hardens()
|
||||||
{
|
{
|
||||||
var order = new List<string>();
|
var order = new List<string>();
|
||||||
var run = new Mock<IProcessRunner>();
|
|
||||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
|
||||||
var bl = new Mock<IBitLockerService>();
|
var bl = new Mock<IBitLockerService>();
|
||||||
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
bl.Setup(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
.Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
|
||||||
@@ -58,27 +56,46 @@ public class ApplyServiceTests
|
|||||||
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
installer.Setup(i => i.InstallAsync(It.IsAny<IReadOnlyList<AppCatalogEntry>>(),
|
||||||
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty<AppInstallResult>());
|
.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);
|
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
|
||||||
var req = new ApplyRequest(Flavour(), "", System.Array.Empty<AppCatalogEntry>());
|
var req = new ApplyRequest(Flavour(), "", System.Array.Empty<AppCatalogEntry>());
|
||||||
|
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||||
|
|
||||||
Assert.Equal(new[] { "apps" }, order);
|
Assert.Equal(new[] { "apps", "hardening" }, order);
|
||||||
bl.Verify(b => b.EnableAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
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]
|
[Fact]
|
||||||
public async Task Installs_the_requested_apps()
|
public async Task Installs_the_requested_apps()
|
||||||
{
|
{
|
||||||
var run = new Mock<IProcessRunner>();
|
|
||||||
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new ProcessResult(0, "", ""));
|
|
||||||
var bl = new Mock<IBitLockerService>();
|
var bl = new Mock<IBitLockerService>();
|
||||||
var installer = NoApps();
|
var installer = NoApps();
|
||||||
|
var hard = new Mock<IHardeningService>();
|
||||||
var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } };
|
var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } };
|
||||||
|
|
||||||
var sut = new ApplyService(bl.Object, installer.Object);
|
var sut = new ApplyService(bl.Object, installer.Object, hard.Object);
|
||||||
var req = new ApplyRequest(Flavour(), "123456", apps);
|
var req = new ApplyRequest(Flavour(), "123456", apps);
|
||||||
|
|
||||||
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
await sut.RunAsync(req, new Progress<ApplyProgress>(_ => { }));
|
||||||
|
|||||||
Reference in New Issue
Block a user