diff --git a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs index 322c36f..34113bf 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs +++ b/windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs @@ -32,22 +32,16 @@ public static class MauiProgram builder.Logging.AddDebug(); #endif - var hardeningDir = @"C:\Windows\Setup\Scripts\hardening"; builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); var appsDir = Path.Combine(AppContext.BaseDirectory, "apps"); builder.Services.AddSingleton(sp => new AppInstaller(sp.GetRequiredService(), appsDir)); builder.Services.AddSingleton(sp => new ApplyService( sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - hardeningDir)); + sp.GetRequiredService())); builder.Services.AddScoped(); return builder.Build(); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/AccountService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/AccountService.cs deleted file mode 100644 index 31659b9..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/AccountService.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public sealed class AccountService(IProcessRunner runner) : IAccountService -{ - public async Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default) - { - // Daily account = Standard User (Users group only — NOT Administrators). - await Ps($"$p=ConvertTo-SecureString '{Esc(password)}' -AsPlainText -Force; " + - $"New-LocalUser -Name '{Esc(user)}' -Password $p -FullName '{Esc(user)}' -AccountNeverExpires; " + - $"Add-LocalGroupMember -Group 'Users' -Member '{Esc(user)}'", "Daily account creation", ct); - // Separate elevation account. - await Ps($"$a=ConvertTo-SecureString '{Esc(adminPassword)}' -AsPlainText -Force; " + - $"New-LocalUser -Name 'SilverOS Admin' -Password $a -AccountNeverExpires; " + - $"Add-LocalGroupMember -Group 'Administrators' -Member 'SilverOS Admin'", "Admin account creation", ct); - } - - // $ErrorActionPreference='Stop' turns the (otherwise non-terminating) cmdlet errors into a - // non-zero exit so EnsureSuccess can surface them instead of silently continuing. - private async Task Ps(string script, string operation, CancellationToken ct) - { - var r = await runner.RunAsync("powershell.exe", - $"-NoProfile -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; {script}\"", ct); - r.EnsureSuccess(operation); - } - private static string Esc(string s) => s.Replace("'", "''"); -} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs index a1c8179..5dadff1 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs @@ -1,6 +1,6 @@ -using SilverOS.Welcome.Core.Apps; using SilverOS.Welcome.Core.Flavours; +using SilverOS.Welcome.Core.Apps; namespace SilverOS.Welcome.Core.Apply; -public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password, - string AdminPassword, string BitLockerPin, string BootstrapUser, - IReadOnlyList Apps); +// Toolbox model: the account is created by Windows Setup (WinPE collector), and hardening +// runs from SetupComplete. Apply only installs apps + enrols BitLocker. +public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList Apps); diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs index 17727d2..c51f88c 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -1,45 +1,21 @@ -using System.Text.Json; using SilverOS.Welcome.Core.Apps; -using SilverOS.Welcome.Core.Flavours; namespace SilverOS.Welcome.Core.Apply; -public sealed class ApplyService(IProcessRunner runner, IAccountService accounts, - IBitLockerService bitlocker, IBootstrapService bootstrap, IAppInstaller installer, - string hardeningDir) : IApplyService +// Toolbox Apply pipeline: apps -> bitlocker -> done. +// Account creation moved to Windows Setup (WinPE collector); OS hardening runs from +// SetupComplete; sm-bootstrap teardown is owned by Setup, not the toolbox. +public sealed class ApplyService(IProcessRunner runner, IBitLockerService bitlocker, IAppInstaller installer) : IApplyService { public async Task RunAsync(ApplyRequest req, IProgress progress, CancellationToken ct = default) { - progress.Report(new("Applying hardening", 10)); - // Pass modules as a single bare CSV token (e.g. 00,03,05). - // powershell.exe -File receives single-quoted tokens as one literal string, not an array, - // so Invoke-Hardening.ps1 accepts [string]$Modules and splits on ',' internally. - var mods = string.Join(",", req.Flavour.Hardening.Modules); - var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\""); - var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1"); - var res = await runner.RunAsync("powershell.exe", - $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct); - if (res.ExitCode != 0) + progress.Report(new("Installing apps", 30)); + await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws + + if (!string.IsNullOrWhiteSpace(req.BitLockerPin)) { - // Only expose exit code + first non-empty stderr line (capped) — never raw full stderr. - var firstLine = res.StdErr - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault()?.Trim() ?? string.Empty; - if (firstLine.Length > 200) firstLine = firstLine[..200]; - throw new InvalidOperationException($"Hardening failed (exit {res.ExitCode}): {firstLine}"); + progress.Report(new("Encrypting the disk", 75)); + await bitlocker.EnableAsync(req.BitLockerPin, ct); } - - progress.Report(new("Creating your account", 55)); - await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct); - - progress.Report(new("Installing apps", 70)); - await installer.InstallAsync(req.Apps, progress, ct); // continue-on-failure; never throws - - progress.Report(new("Encrypting the disk", 75)); - await bitlocker.EnableAsync(req.BitLockerPin, ct); - - progress.Report(new("Finishing up", 95)); - await bootstrap.RevertKioskAsync(ct); // revert kiosk before account deletion (SID must still resolve) - await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success progress.Report(new("Done", 100)); } } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs deleted file mode 100644 index 67de81d..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService -{ - // Lockdown revert is BEST-EFFORT (like TearDownAsync): -EA SilentlyContinue throughout. - // Don't fail the apply over a missing WMI class / key. Must run BEFORE TearDownAsync. - public async Task RevertKioskAsync(CancellationToken ct = default) - { - // Disable the Keyboard Filter rules so the real end-user's Win key / task-switch / - // Alt+F4 etc. work again (Explorer is already the shell — nothing to undo there). - await Ps( - "$c='root\\\\standardcimv2\\\\embedded';" + - "foreach($k in @('Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R','Alt+Tab','Ctrl+Shift+Esc','Alt+F4')){" + - "$p=Get-CimInstance -Namespace $c -ClassName WEKF_PredefinedKey -Filter \"Id='$k'\" -EA SilentlyContinue;" + - "if($p){$p.Enabled=$false; Set-CimInstance -InputObject $p -EA SilentlyContinue}" + - "}", - ct); - // Revert escape policies set by Configure-Kiosk.ps1. - await Ps( - "$s='HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System';" + - "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -EA SilentlyContinue;" + - // Restore SECURE UAC for the real end-user (the kiosk auto-approved unsigned elevation). - "Set-ItemProperty $s -Name ConsentPromptBehaviorAdmin -Value 2 -Type DWord -EA SilentlyContinue;" + - "Set-ItemProperty $s -Name PromptOnSecureDesktop -Value 1 -Type DWord -EA SilentlyContinue", - ct); - } - - // Teardown is BEST-EFFORT (unlike Account/BitLocker which are strict): the answer file's - // AutoLogon LogonCount=1 already neutralises auto-logon after the first logon (Windows clears - // AutoAdminLogon itself), so these Winlogon cleanups must not fail the whole apply. The op that - // matters — removing the sm-bootstrap account — runs regardless and is tolerant too. - public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default) - { - const string w = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; - await Ps($"Set-ItemProperty -Path {w} -Name AutoAdminLogon -Value '0' -EA SilentlyContinue; " + - $"Remove-ItemProperty -Path {w} -Name DefaultPassword -EA SilentlyContinue; " + - $"Remove-ItemProperty -Path {w} -Name DefaultUserName -EA SilentlyContinue; " + - $"Remove-ItemProperty -Path {w} -Name DefaultDomainName -EA SilentlyContinue", ct); - var u = Esc(bootstrapUser); - // Best-effort in-session removal (usually no-ops — you can't delete the account - // you're logged in as), THEN defer the real removal to a SYSTEM startup task that - // runs on next boot, when sm-bootstrap is no longer logged on. It removes the - // account + profile, then unregisters itself. - // Disable immediately (in-session, takes effect at once so the account is unusable - // and shows as disabled), then best-effort delete; the deferred task does the real - // delete on next boot when it isn't logged on. - await Ps($"Disable-LocalUser -Name '{u}' -EA SilentlyContinue; Remove-LocalUser -Name '{u}' -EA SilentlyContinue", ct); - var cleanup = - $"Remove-LocalUser -Name '{u}' -ErrorAction SilentlyContinue; " + - $"Get-CimInstance Win32_UserProfile | Where-Object {{ $_.LocalPath -like '*\\{u}' }} | Remove-CimInstance -ErrorAction SilentlyContinue; " + - "Unregister-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Confirm:$false -ErrorAction SilentlyContinue"; - var b64 = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(cleanup)); - // Register-ScheduledTask (not schtasks.exe) — schtasks /tr caps at 261 chars and - // silently failed with the encoded payload, so the task was never created. - await Ps("$a=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument " + - $"'-NoProfile -ExecutionPolicy Bypass -EncodedCommand {b64}'; " + - "$t=New-ScheduledTaskTrigger -AtStartup; " + - "$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest; " + - "Register-ScheduledTask -TaskName 'SilverMetalBootstrapCleanup' -Action $a -Trigger $t -Principal $p -Force | Out-Null", ct); - } - private static string Esc(string s) => s.Replace("'", "''"); - private Task Ps(string s, CancellationToken ct) => - runner.RunAsync("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -Command \"{s}\"", ct); -} diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs deleted file mode 100644 index 43715f9..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IAccountService.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public interface IAccountService { Task CreateAccountsAsync(string user, string password, string adminPassword, CancellationToken ct = default); } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs deleted file mode 100644 index 5e48d5b..0000000 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SilverOS.Welcome.Core.Apply; -public interface IBootstrapService -{ - Task RevertKioskAsync(CancellationToken ct = default); - Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); -} diff --git a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor index 797a1e2..d1d173e 100644 --- a/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor @@ -86,13 +86,12 @@ var apps = AppCatalog.Load(Path.Combine(AppContext.BaseDirectory, "apps")) .All.Where(a => State.SelectedApps.Contains(a.Id)).ToList(); + // D1: Apply is now apps+bitlocker only (account via Setup, hardening via SetupComplete). + // D2 owns the full UI rewire (run-mode / preseed); this passes the 3-arg request from + // existing State fields so the app keeps compiling. var req = new ApplyRequest( Flavour: State.Flavour!, - Username: State.Username, - Password: State.Password, - AdminPassword: State.AdminPassword, BitLockerPin: State.BitLockerPin, - BootstrapUser: "sm-bootstrap", Apps: apps); var progress = new Progress(p => diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/AccountStepTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/AccountStepTests.cs deleted file mode 100644 index 02dddb0..0000000 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/AccountStepTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Bunit; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.DependencyInjection; -using SilverOS.Welcome.App.Components; -using SilverOS.Welcome.App.Components.Steps; -using Xunit; - -public class AccountStepTests : TestContext -{ - // Helper: register WizardState and render AccountStep with an OnValidityChanged capture. - private (IRenderedComponent cut, Func lastValidity) RenderStep(WizardState? state = null) - { - var wizardState = state ?? new WizardState(); - Services.AddSingleton(wizardState); - - bool? captured = null; - var cut = RenderComponent(p => - p.Add(s => s.OnValidityChanged, - EventCallback.Factory.Create(this, v => captured = v))); - - return (cut, () => captured); - } - - [Fact] - public void OnValidityChanged_fires_false_on_initial_mount_with_empty_fields() - { - var (_, lastValidity) = RenderStep(); - - Assert.NotNull(lastValidity()); - Assert.False(lastValidity(), "Step should be invalid on first mount (empty fields)."); - } - - [Fact] - public void OnValidityChanged_fires_true_after_all_valid_inputs_are_entered() - { - var (cut, lastValidity) = RenderStep(); - - // Simulate user filling in all four fields. - cut.Find("#username").Input("alice"); - cut.Find("#password").Input("Secret1!"); - cut.Find("#adminpassword").Input("Admin1!"); - cut.Find("#bitlockerpin").Input("123456"); - - Assert.True(lastValidity(), "Step should be valid after all fields are correctly filled."); - } - - [Fact] - public void OnValidityChanged_fires_false_when_a_field_is_cleared_after_being_valid() - { - var (cut, lastValidity) = RenderStep(); - - cut.Find("#username").Input("alice"); - cut.Find("#password").Input("Secret1!"); - cut.Find("#adminpassword").Input("Admin1!"); - cut.Find("#bitlockerpin").Input("123456"); - - Assert.True(lastValidity()); // sanity - - // Clear a required field — must revert to invalid. - cut.Find("#username").Input(""); - - Assert.False(lastValidity(), "Step should become invalid again when a required field is cleared."); - } - - [Fact] - public void OnValidityChanged_fires_false_when_pin_is_non_numeric_or_too_short() - { - var (cut, lastValidity) = RenderStep(); - - cut.Find("#username").Input("alice"); - cut.Find("#password").Input("Secret1!"); - cut.Find("#adminpassword").Input("Admin1!"); - - // Too short — 5 digits. - cut.Find("#bitlockerpin").Input("12345"); - Assert.False(lastValidity(), "PIN with only 5 digits must be invalid."); - - // Non-numeric. - cut.Find("#bitlockerpin").Input("abc123"); - Assert.False(lastValidity(), "Non-numeric PIN must be invalid."); - - // Exactly 6 digits — valid. - cut.Find("#bitlockerpin").Input("123456"); - Assert.True(lastValidity(), "Exactly 6 numeric digits is valid."); - } - - [Fact] - public void OnValidityChanged_fires_true_on_mount_when_wizard_state_already_populated() - { - var prefilledState = new WizardState - { - Username = "alice", - Password = "Secret1!", - AdminPassword = "Admin1!", - BitLockerPin = "123456" - }; - - var (_, lastValidity) = RenderStep(prefilledState); - - Assert.True(lastValidity(), - "Step should fire valid=true on mount when WizardState already has valid values (Back→Forward re-mount)."); - } -} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceHardeningIntegrationTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceHardeningIntegrationTests.cs deleted file mode 100644 index f8a5652..0000000 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceHardeningIntegrationTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Moq; -using SilverOS.Welcome.Core.Apply; -using SilverOS.Welcome.Core.Apps; -using SilverOS.Welcome.Core.Flavours; -using Xunit; - -/// -/// Real integration test: proves that ApplyService passes -Modules with the correct -/// encoding so that Invoke-Hardening.ps1's subset filter actually works through the -/// real ProcessStartInfo / PowerShell boundary. -/// -/// SAFETY: only harmless dummy .ps1 files are executed — never the real 0*.ps1 hardening -/// modules. Invoke-Hardening.ps1 is copied into a temp dir and run against dummy stubs. -/// -public class ApplyServiceHardeningIntegrationTests -{ - /// Walk up from the test binary to find the repo root (same as ShippedFlavoursTests). - private static string HardeningDir() - { - var d = AppContext.BaseDirectory; - while (d is not null && !Directory.Exists(Path.Combine(d, "windows", "hardening"))) - d = Directory.GetParent(d)?.FullName; - return Path.Combine(d!, "windows", "hardening"); - } - - [Fact] - public async Task Subset_filter_runs_only_requested_modules_via_real_powershell() - { - // ---- Arrange: set up a temp sandbox ---- - var tmp = Path.Combine(Path.GetTempPath(), $"sm_integ_{Guid.NewGuid():N}"); - Directory.CreateDirectory(tmp); - try - { - // Copy the REAL Invoke-Hardening.ps1 (the one we just patched) into the temp dir. - var realInvoke = Path.Combine(HardeningDir(), "Invoke-Hardening.ps1"); - File.Copy(realInvoke, Path.Combine(tmp, "Invoke-Hardening.ps1")); - - // Create harmless dummy module stubs. Each just appends its prefix to ran.txt. - var ranFile = Path.Combine(tmp, "ran.txt").Replace("\\", "\\\\"); - foreach (var (prefix, name) in new[] { - ("00", "00-a.ps1"), - ("03", "03-b.ps1"), - ("05", "05-c.ps1"), - ("07", "07-d.ps1"), - }) - { - // Single quotes around prefix so the string itself is written, not executed. - await File.WriteAllTextAsync( - Path.Combine(tmp, name), - $"'RAN {prefix}' | Out-File -Append \"{ranFile.Replace("\\\\", "\\\\")}\""); - } - - // Dummy Verify script — no-op so Invoke-Hardening.ps1's Verify step succeeds. - await File.WriteAllTextAsync( - Path.Combine(tmp, "Verify-SilverMetalWindows.ps1"), - "# no-op verify"); - - // ---- Arrange: mocked services so apply completes without touching real OS ---- - var acct = new Mock(); - acct.Setup(a => a.CreateAccountsAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - - var bl = new Mock(); - bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - var boot = new Mock(); - boot.Setup(b => b.TearDownAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - var installer = new Mock(); - installer.Setup(i => i.InstallAsync( - It.IsAny>(), - It.IsAny>(), It.IsAny())) - .ReturnsAsync(Array.Empty()); - - var sut = new ApplyService( - runner: new ProcessRunner(), - accounts: acct.Object, - bitlocker: bl.Object, - bootstrap: boot.Object, - installer: installer.Object, - hardeningDir: tmp); - - // Flavour requests modules 00 and 05 only — 03 and 07 must be skipped. - var flavour = new FlavourManifest - { - Id = "test", - Hardening = new HardeningSpec { Modules = new[] { "00", "05" } } - }; - var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", Array.Empty()); - - // ---- Act ---- - await sut.RunAsync(req, new Progress(_ => { })); - - // ---- Assert: ran.txt should contain only 00 and 05 markers ---- - Assert.True(File.Exists(Path.Combine(tmp, "ran.txt")), - "ran.txt was not created — no module ran at all (subset filter matched nothing)"); - - var ran = await File.ReadAllTextAsync(Path.Combine(tmp, "ran.txt")); - - Assert.Contains("RAN 00", ran, StringComparison.Ordinal); - Assert.Contains("RAN 05", ran, StringComparison.Ordinal); - Assert.DoesNotContain("RAN 03", ran, StringComparison.Ordinal); - Assert.DoesNotContain("RAN 07", ran, StringComparison.Ordinal); - - // ---- Assert: the rest of the apply pipeline also completed ---- - acct.Verify(a => a.CreateAccountsAsync( - "alice", "pw", "adminpw", It.IsAny()), Times.Once); - bl.Verify(b => b.EnableAsync("123456", It.IsAny()), Times.Once); - boot.Verify(b => b.TearDownAsync("sm-bootstrap", It.IsAny()), Times.Once); - } - finally - { - // Clean up — ignore errors (locked files etc.) to avoid masking test failure. - try { Directory.Delete(tmp, recursive: true); } catch { /* ignore */ } - } - } -} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs index 5c81adf..3b8262b 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServiceTests.cs @@ -15,39 +15,76 @@ public class ApplyServiceTests return installer; } + private static FlavourManifest Flavour() => + new() { Id = "daily-driver", Hardening = new HardeningSpec { Modules = new[] { "00" } } }; + [Fact] - public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last() + public async Task Runs_apps_then_bitlocker_when_pin_supplied() { var order = new List(); var run = new Mock(); run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); }) .ReturnsAsync(new ProcessResult(0, "", "")); - var acct = new Mock(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask); - var bl = new Mock(); bl.Setup(b => b.EnableAsync(It.IsAny(),It.IsAny())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); - var boot = new Mock(); boot.Setup(b => b.TearDownAsync(It.IsAny(),It.IsAny())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask); - var installer = NoApps(); installer.Setup(i => i.InstallAsync(It.IsAny>(),It.IsAny>(),It.IsAny())).Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty()); + var bl = new Mock(); + bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) + .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); + var installer = NoApps(); + installer.Setup(i => i.InstallAsync(It.IsAny>(), + It.IsAny>(), It.IsAny())) + .Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty()); - var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard"); - var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } }; - var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty()); + var sut = new ApplyService(run.Object, bl.Object, installer.Object); + var req = new ApplyRequest(Flavour(), "123456", System.Array.Empty()); var progress = new List(); await sut.RunAsync(req, new Progress(p => progress.Add(p.Stage))); - Assert.Equal(new[]{"modules","accounts","apps","bitlocker","bootstrap"}, order); - Assert.Contains("Applying hardening", progress); + Assert.Equal(new[] { "apps", "bitlocker" }, order); + Assert.Contains("Installing apps", progress); + Assert.Contains("Done", progress); } [Fact] - public async Task Does_not_tear_down_bootstrap_if_account_creation_fails() + public async Task Empty_pin_skips_bitlocker() { - var run = new Mock(); run.Setup(r => r.RunAsync(It.IsAny(),It.IsAny(),It.IsAny())).ReturnsAsync(new ProcessResult(0,"","")); - var acct = new Mock(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); - var bl = new Mock(); var boot = new Mock(); - var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, NoApps().Object, "C:\\hard"); - var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap", System.Array.Empty()); - await Assert.ThrowsAsync(() => sut.RunAsync(req, new Progress(_ => {}))); - boot.Verify(b => b.TearDownAsync(It.IsAny(), It.IsAny()), Times.Never); + var order = new List(); + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "", "")); + var bl = new Mock(); + bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) + .Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask); + var installer = NoApps(); + installer.Setup(i => i.InstallAsync(It.IsAny>(), + It.IsAny>(), It.IsAny())) + .Callback(() => order.Add("apps")).ReturnsAsync(Array.Empty()); + + var sut = new ApplyService(run.Object, bl.Object, installer.Object); + var req = new ApplyRequest(Flavour(), "", System.Array.Empty()); + + await sut.RunAsync(req, new Progress(_ => { })); + + Assert.Equal(new[] { "apps" }, order); + bl.Verify(b => b.EnableAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Installs_the_requested_apps() + { + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "", "")); + var bl = new Mock(); + var installer = NoApps(); + var apps = new[] { new AppCatalogEntry { Id = "firefox", Name = "Firefox" } }; + + var sut = new ApplyService(run.Object, bl.Object, installer.Object); + var req = new ApplyRequest(Flavour(), "123456", apps); + + await sut.RunAsync(req, new Progress(_ => { })); + + installer.Verify(i => i.InstallAsync(apps, It.IsAny>(), + It.IsAny()), Times.Once); + bl.Verify(b => b.EnableAsync("123456", It.IsAny()), Times.Once); } } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs index c600977..943c498 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyServicesTests.cs @@ -19,13 +19,6 @@ public class ApplyServicesTests return m; } - [Fact] - public async Task AccountService_throws_on_nonzero_exit() - { - await Assert.ThrowsAsync(() => - new AccountService(Fail().Object).CreateAccountsAsync("alice", "pw", "apw")); - } - [Fact] public async Task BitLockerService_throws_on_nonzero_exit() { @@ -33,26 +26,6 @@ public class ApplyServicesTests new BitLockerService(Fail().Object).EnableAsync("123456")); } - // Note: BootstrapService is intentionally best-effort (teardown cleanups must not fail the - // apply — auto-logon is already neutralised by the answer file's LogonCount=1), so it does - // NOT throw on a non-zero exit. - - [Fact] - public async Task AccountService_creates_standard_daily_and_admin() - { - var run = Ok(); - await new AccountService(run.Object).CreateAccountsAsync("alice", "pw1", "adminpw"); - // daily user is a Standard user (added to Users, NOT Administrators) - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("New-LocalUser") && s.Contains("alice")), It.IsAny())); - // negative: the daily-user New-LocalUser call must never mention Administrators - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("New-LocalUser") && s.Contains("alice") && !s.Contains("Administrators")), - It.IsAny()), Times.Once); - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("'SilverOS Admin'") && s.Contains("Administrators")), It.IsAny())); - } - [Fact] public async Task BitLockerService_enables_tpm_and_pin() { @@ -82,15 +55,4 @@ public class ApplyServicesTests run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => s.Contains("Shell.Application") && s.Contains("Eject")), It.IsAny())); } - - [Fact] - public async Task BootstrapService_removes_autologon_and_account() - { - var run = Ok(); - await new BootstrapService(run.Object).TearDownAsync("sm-bootstrap"); - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("AutoAdminLogon") && s.Contains("0")), It.IsAny())); - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("Remove-LocalUser") && s.Contains("sm-bootstrap")), It.IsAny())); - } } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs index 45429d9..620fa9d 100644 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs @@ -35,7 +35,7 @@ public class ApplyStepTests : TestContext var cut = RenderComponent(); await cut.InvokeAsync(() => cut.Instance.StartAsync()); apply.Verify(a => a.RunAsync( - It.Is(r => r.Username == "alice" && r.Flavour.Id == "daily-driver"), + It.Is(r => r.BitLockerPin == "123456" && r.Flavour.Id == "daily-driver"), It.IsAny>(), It.IsAny()), Times.Once); } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs deleted file mode 100644 index e96b24b..0000000 --- a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Moq; -using SilverOS.Welcome.Core.Apply; -using SilverOS.Welcome.Core.Apps; - -public class BootstrapServiceRevertKioskTests -{ - private static Mock Ok() - { - var m = new Mock(); - m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProcessResult(0, "", "")); - return m; - } - - private static Mock Fail() - { - var m = new Mock(); - m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProcessResult(1, "", "the operation failed")); - return m; - } - - [Fact] - public async Task RevertKioskAsync_is_best_effort_and_does_not_throw_on_nonzero_exit() - { - // Kiosk revert is best-effort (like TearDownAsync): a non-zero exit must NOT - // fail the apply — the real user still gets Explorer regardless of WESL state. - var ex = await Record.ExceptionAsync(() => - new BootstrapService(Fail().Object).RevertKioskAsync()); - Assert.Null(ex); - } - - [Fact] - public async Task RevertKioskAsync_disables_keyboard_filter_rules() - { - var run = Ok(); - await new BootstrapService(run.Object).RevertKioskAsync(); - // First call: disable the Keyboard Filter predefined-key blocks for the real user. - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("WEKF_PredefinedKey") && - s.Contains("Enabled=$false")), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task RevertKioskAsync_reverts_escape_policies() - { - var run = Ok(); - await new BootstrapService(run.Object).RevertKioskAsync(); - // Second call: policy revert — must remove the three escape policy values. - run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => - s.Contains("Remove-ItemProperty") && - s.Contains("DisableTaskMgr") && - s.Contains("DisableLockWorkstation") && - s.Contains("HideFastUserSwitching")), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ApplyService_calls_revert_kiosk_before_teardown() - { - var order = new List(); - var run = new Mock(); - run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, a, _) => - { - if (a.Contains("Invoke-Hardening")) order.Add("modules"); - }) - .ReturnsAsync(new ProcessResult(0, "", "")); - - var acct = new Mock(); - acct.Setup(a => a.CreateAccountsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback(() => order.Add("accounts")) - .Returns(Task.CompletedTask); - - var bl = new Mock(); - bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) - .Callback(() => order.Add("bitlocker")) - .Returns(Task.CompletedTask); - - var boot = new Mock(); - boot.Setup(b => b.RevertKioskAsync(It.IsAny())) - .Callback(() => order.Add("revert-kiosk")) - .Returns(Task.CompletedTask); - boot.Setup(b => b.TearDownAsync(It.IsAny(), It.IsAny())) - .Callback(() => order.Add("teardown")) - .Returns(Task.CompletedTask); - - var installer = new Mock(); - installer.Setup(i => i.InstallAsync(It.IsAny>(), - It.IsAny>(), It.IsAny())) - .ReturnsAsync(System.Array.Empty()); - - var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, installer.Object, "C:\\hard"); - var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest - { - Id = "daily-driver", - Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } } - }; - var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap", System.Array.Empty()); - - await sut.RunAsync(req, new Progress(_ => { })); - - // revert-kiosk must precede teardown so the sm-bootstrap SID still resolves. - Assert.Equal(new[] { "modules", "accounts", "bitlocker", "revert-kiosk", "teardown" }, order); - } -}