tests/OfficeScrubC2R.Core.Tests/CoreBehaviorTests.cs
|
using System.ComponentModel;
using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using Microsoft.Win32; using OfficeScrubC2R; using Xunit; namespace OfficeScrubC2R.Core.Tests; public sealed class CoreBehaviorTests { [Fact] public void OperationResult_FromExceptionPreservesDiagnosticMetadata() { var exception = new Win32Exception(5, "Access is denied."); var result = OperationResult.FromException( step: "Registry", action: "DeleteKey", targetKind: "RegistryKey", target: @"HKLM\Software\Microsoft\Office", exception: exception, hive: RegistryHive.LocalMachine, view: RegistryView.Registry64); Assert.Equal(OperationStatus.Failed, result.Status); Assert.Equal("Registry", result.Step); Assert.Equal("DeleteKey", result.Action); Assert.Equal("RegistryKey", result.TargetKind); Assert.Equal(@"HKLM\Software\Microsoft\Office", result.Target); Assert.Equal(RegistryHive.LocalMachine, result.RegistryHive); Assert.Equal(RegistryView.Registry64, result.RegistryView); Assert.Equal(nameof(Win32Exception), result.ExceptionType); Assert.Equal(exception.HResult, result.HResult); Assert.Equal(5, result.Win32Error); Assert.False(result.RebootScheduled); } [Fact] public void RegistryAccess_UsesExplicitViewsForSixtyFourBitOperatingSystems() { var access = new RegistryAccess(is64BitOperatingSystem: true); var views = access.GetCandidateViews().ToArray(); Assert.Equal(new[] { RegistryView.Registry64, RegistryView.Registry32 }, views); } [Fact] public void RegistryAccess_UsesThirtyTwoBitViewForThirtyTwoBitOperatingSystems() { var access = new RegistryAccess(is64BitOperatingSystem: false); var view = Assert.Single(access.GetCandidateViews()); Assert.Equal(RegistryView.Registry32, view); } [Fact] public void RegistryAccess_RecordsDiagnosticsForRegistryFailures() { var access = new RegistryAccess(is64BitOperatingSystem: true); var exists = access.KeyExists((RegistryHive)12345, "Software", RegistryView.Registry64); Assert.False(exists); var diagnostic = Assert.Single(access.Diagnostics); Assert.Equal(OperationStatus.Failed, diagnostic.Status); Assert.Equal("Registry", diagnostic.Step); Assert.Equal("KeyExists", diagnostic.Action); Assert.Equal(RegistryView.Registry64, diagnostic.RegistryView); } [Fact] public void OfficeScope_DetectsKnownC2RPathsAndProductCodes() { Assert.True(OfficeScope.IsC2RPath(@"C:\Program Files\Microsoft Office\Root\Office16\WINWORD.EXE")); Assert.True(OfficeScope.IsInScope("{90160000-008F-0000-1000-0000000FF1CE}")); Assert.False(OfficeScope.IsInScope("{90140000-008F-0000-1000-0000000FF1CE}")); } [Fact] public void GuidHelper_RoundTripsCompressedProductCodes() { const string expanded = "{90160000-008F-0000-1000-0000000FF1CE}"; var compressed = GuidHelper.GetCompressedGuid(expanded); var roundTripped = GuidHelper.GetExpandedGuid(compressed); Assert.Equal(expanded, roundTripped); } [Fact] public void ScrubPlanner_ReturnsPlanOnlyOperations() { var state = new OfficeC2RState { IsElevated = true, IsSystem = false, Is64BitOperatingSystem = true }; state.InstalledProducts.Add(new OfficeProductInfo { ProductId = "O365ProPlusRetail", DisplayName = "Microsoft 365 Apps", Source = "Test" }); var plan = ScrubPlanner.CreatePlan(state, keepLicense: true, planOnly: true); Assert.True(plan.PlanOnly); Assert.True(plan.KeepLicense); Assert.Contains(plan.PlannedOperations, item => item.Status == OperationStatus.WouldRun); Assert.Contains(plan.PlannedOperations, item => item.Step == "CompanionApps" && item.Action == "RemoveTeamsAndCopilot" && item.Status == OperationStatus.WouldRun); } [Fact] public void ScrubPlanner_HonorsCompanionAppKeepFlags() { var state = new OfficeC2RState { IsElevated = true, Is64BitOperatingSystem = true }; var plan = ScrubPlanner.CreatePlan( state, keepLicense: false, planOnly: true, keepTeams: true, keepCopilot: true); Assert.True(plan.KeepTeams); Assert.True(plan.KeepCopilot); Assert.Contains(plan.PlannedOperations, item => item.Step == "CompanionApps" && item.Action == "RemoveTeamsAndCopilot" && item.Status == OperationStatus.Skipped); } [Fact] public void CleanupExecutor_DeletesRequestedDirectoryAndReportsResult() { var root = Path.Combine(Path.GetTempPath(), "OfficeScrubC2R.Tests", Guid.NewGuid().ToString("N")); var child = Path.Combine(root, "child"); Directory.CreateDirectory(child); File.WriteAllText(Path.Combine(child, "sample.txt"), "delete me"); var request = new ScrubExecutionRequest { State = new OfficeC2RState { IsElevated = true, Is64BitOperatingSystem = true }, SkipBuiltInTargets = true, SkipCompanionAppTargets = true }; request.ExtraFileSystemTargets.Add(root); var result = new CleanupExecutor().Execute(request); Assert.False(Directory.Exists(root)); Assert.Contains(result.ExecutedOperations, item => item.Step == "Files" && item.Action == "DeleteDirectory" && item.Target == root && item.Status == OperationStatus.Completed); } [Fact] public void CleanupExecutor_DeletesRequestedRegistryKeyAndReportsExplicitView() { var subKey = @"Software\OfficeScrubC2R\Tests\" + Guid.NewGuid().ToString("N"); using (var key = Registry.CurrentUser.CreateSubKey(subKey)) { key!.SetValue("Value", "delete me"); } var request = new ScrubExecutionRequest { State = new OfficeC2RState { IsElevated = true, Is64BitOperatingSystem = true }, SkipBuiltInTargets = true, SkipCompanionAppTargets = true }; request.ExtraRegistryTargets.Add(new RegistryTarget(RegistryHive.CurrentUser, subKey)); var result = new CleanupExecutor().Execute(request); using var remaining = Registry.CurrentUser.OpenSubKey(subKey); Assert.Null(remaining); Assert.Contains(result.ExecutedOperations, item => item.Step == "Registry" && item.Action == "DeleteKey" && item.Target.EndsWith(subKey) && item.Status == OperationStatus.Completed && item.RegistryView.HasValue); } [Fact] public void CleanupExecutor_BlocksWhenNotElevated() { var request = new ScrubExecutionRequest { State = new OfficeC2RState { IsElevated = false, Is64BitOperatingSystem = true }, SkipBuiltInTargets = true, SkipCompanionAppTargets = true }; var result = new CleanupExecutor().Execute(request); Assert.Equal("Blocked", result.ExecutionStatus); Assert.Contains(result.ExecutedOperations, item => item.Status == OperationStatus.Blocked && item.ErrorId == "OfficeScrubC2R.AdminRequired"); } [Fact] public void CleanupExecutor_RemovesTeamsAndCopilotCompanionAppsThroughCommandRunner() { var runner = new RecordingCommandRunner(); var request = new ScrubExecutionRequest { State = new OfficeC2RState { IsElevated = true, Is64BitOperatingSystem = true }, SkipBuiltInTargets = true, SkipCompanionProfileTargets = true }; var result = new CleanupExecutor(runner).Execute(request); Assert.Contains(result.ExecutedOperations, item => item.Step == "CompanionApps" && item.Action == "RemoveTeamsAppxPackage" && item.Status == OperationStatus.Completed); Assert.Contains(result.ExecutedOperations, item => item.Step == "CompanionApps" && item.Action == "RemoveCopilotAppxPackage" && item.Status == OperationStatus.Completed); Assert.Contains(runner.PowerShellScripts, script => script.Contains("MSTeams")); Assert.Contains(runner.PowerShellScripts, script => script.Contains("Microsoft.Copilot")); Assert.Contains(runner.Invocations, invocation => invocation.FileName == "msiexec.exe" && invocation.Arguments.Contains("{731F6BAA-A986-45A4-8936-7C3AAAAA760B}")); } [Fact] public void CleanupExecutor_SkipsCompanionAppsWhenKeepFlagsAreSet() { var runner = new RecordingCommandRunner(); var request = new ScrubExecutionRequest { State = new OfficeC2RState { IsElevated = true, Is64BitOperatingSystem = true }, KeepTeams = true, KeepCopilot = true, SkipBuiltInTargets = true, SkipCompanionProfileTargets = true }; var result = new CleanupExecutor(runner).Execute(request); Assert.Contains(result.ExecutedOperations, item => item.Step == "CompanionApps" && item.Action == "RemoveTeams" && item.Status == OperationStatus.Skipped); Assert.Contains(result.ExecutedOperations, item => item.Step == "CompanionApps" && item.Action == "RemoveCopilot" && item.Status == OperationStatus.Skipped); Assert.Empty(runner.Invocations); } [Fact] public void CleanupExecutor_DoesNotUseFragileAllDirectoriesEnumeration() { var sourcePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "OfficeScrubC2R.Core", "CleanupExecutor.cs")); var source = File.ReadAllText(sourcePath); Assert.DoesNotContain("SearchOption.AllDirectories", source); } [Fact] public void ProcessCommandRunner_CapturesStdoutAndStderrWithoutSequentialReadDeadlock() { var script = "$stdout = 'o' * 100000; $stderr = 'e' * 100000; [Console]::Out.Write($stdout); [Console]::Error.Write($stderr)"; var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); var result = new ProcessCommandRunner().Run( "powershell.exe", "-NoProfile -ExecutionPolicy Bypass -EncodedCommand " + encoded, 30000); Assert.Equal(0, result.ExitCode); Assert.Contains(new string('o', 100), result.Output); Assert.Contains(new string('e', 100), result.Output); } private sealed class RecordingCommandRunner : ICommandRunner { public List<CommandInvocation> Invocations { get; } = new List<CommandInvocation>(); public IEnumerable<string> PowerShellScripts => Invocations .Where(invocation => invocation.FileName == "powershell.exe") .Select(invocation => DecodePowerShellScript(invocation.Arguments)); public CommandRunResult Run(string fileName, string arguments, int timeoutMilliseconds) { Invocations.Add(new CommandInvocation(fileName, arguments, timeoutMilliseconds)); return new CommandRunResult(0, "removed"); } private static string DecodePowerShellScript(string arguments) { const string marker = "-EncodedCommand "; var markerIndex = arguments.IndexOf(marker, StringComparison.OrdinalIgnoreCase); Assert.True(markerIndex >= 0, "PowerShell command should use -EncodedCommand."); var encoded = arguments.Substring(markerIndex + marker.Length).Trim().Trim('"'); return Encoding.Unicode.GetString(Convert.FromBase64String(encoded)); } } private sealed class CommandInvocation { public CommandInvocation(string fileName, string arguments, int timeoutMilliseconds) { FileName = fileName; Arguments = arguments; TimeoutMilliseconds = timeoutMilliseconds; } public string FileName { get; } public string Arguments { get; } public int TimeoutMilliseconds { get; } } } |