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 */ } } } }