diff --git a/docs/specs/scmu-corrupt-instance-configuration-manual-test-plan.md b/docs/specs/scmu-corrupt-instance-configuration-manual-test-plan.md new file mode 100644 index 0000000000..ecbb50d6a6 --- /dev/null +++ b/docs/specs/scmu-corrupt-instance-configuration-manual-test-plan.md @@ -0,0 +1,278 @@ +# Manual Test Plan: SCMU resilience to corrupt instance configuration files + +> [!WARNING] +> **This document is a working artifact for the manual validation of this fix and +> is meant to be DELETED before merging the PR.** Record the test results here (or in +> a copy) while the PR is open. + +**Spec:** [scmu-corrupt-instance-configuration.md](scmu-corrupt-instance-configuration.md) +**Bug:** [#2759 — SCMU: Fails to start when one of the instances has a corrupt configuration file](https://github.com/Particular/ServiceControl/issues/2759) + +This plan validates, end-to-end on a real machine, the rules implemented for SCMU's +handling of corrupt instance configuration files. The automated executable +specification covers the view models over real config files; this plan covers what +those tests cannot: + +- **Instance enumeration against real Windows services** (`InstanceFinder` is static + and machine-dependent — a mutation check confirmed the automated suite cannot + detect a reintroduced silent skip there). +- **The XAML banner wiring** (per-instance and list-level banners — the same mutation + check confirmed removing the list banner from the view goes undetected). +- **The actual SCMU startup path** — the original bug is a crash at startup, which no + unit-level test exercises. + +Scenarios are independent of each other and can be executed together (mob-style), +split among testers, or run async. Each scenario states its own preconditions, so any +tester can bring a machine into the required state without running previous scenarios. + +--- + +## Shared machine setup (once per test machine) + +All scenarios need this baseline. If you split scenarios among testers on different +machines, each machine needs this setup. + +1. **Windows machine or VM** with local administrator rights (SCMU requires elevation + to manage Windows services). +2. **Build and launch the SCMU under test** from this branch: + - Build `ServiceControl.Config` (or use the packaged installer produced from the branch). + - Run it elevated. +3. **At least one error (ServiceControl) instance installed.** Scenarios state when + they additionally need an audit and/or monitoring instance. Any supported transport + works; suggested low-friction options: + - RabbitMQ in a local Docker container, or + - SQL Server / PostgreSQL transport against a local instance. +4. **Snapshot/cleanup strategy:** corruption is introduced by editing config files, so + always take the backup described below before corrupting, and restore it when the + scenario is done. For instance add/remove needs, remove instances via SCMU + (instance → Remove) or restore a VM snapshot of the clean baseline. + +### How to corrupt a configuration file (used by every scenario) + +1. Find the instance's install path (visible on the instance card in SCMU), e.g. + `C:\Program Files (x86)\Particular Software\\`. +2. The config file per instance type: + - error instance: `ServiceControl.exe.config` + - audit instance: `ServiceControl.Audit.exe.config` + - monitoring instance: `ServiceControl.Monitoring.exe.config` +3. **Back it up first**: copy it next to itself as `.bak`. +4. Corrupt it by making the XML invalid — e.g. delete the closing + `` tag, or paste `<<<` in the middle of the file. +5. To repair: copy the `.bak` back over the corrupted file. + +The Windows service itself does not need to be stopped; SCMU reads the file directly. + +### Terminology + +- **Error instance** = a "ServiceControl" instance; **audit** and **monitoring** refer + to "ServiceControl Audit" and "ServiceControl Monitoring" instances. +- **DEPLOYED INSTANCES screen** = SCMU's main instance list. +- **Per-instance banner** = the error banner on the instance card; **summary banner** + = the banner at the top of the DEPLOYED INSTANCES screen. + +--- + +## Scenario matrix + +| # | Rule | Scenario | Instances required | +|---|------|----------|--------------------| +| 1 | Rule 1 | SCMU starts with a corrupt error instance config and lists every instance (the original crash) | 2 error | +| 2 | Rule 1 | Corrupt audit and monitoring configs are handled the same way | 1 audit, 1 monitoring | +| 3 | Rule 2 | Status reads CONFIGURATION ERROR and the banner names the file | 1 error | +| 4 | Rule 3 | Actions requiring a valid configuration are blocked | 1 error | +| 5 | Rule 5 | Summary banner above the list names the corrupt instance(s) | 2 error | +| 6 | Rule 4 | Fix on disk + refresh recovers the instance without restarting SCMU | 1 error | +| 7 | Rule 4 | Corruption introduced while SCMU is running is flagged on refresh | 1 error | + +Suggested split for three testers: A = 1, 2 (startup/enumeration); B = 3, 4 +(per-instance UI state); C = 5, 6, 7 (summary banner and the refresh loop). Scenarios +1 and 5 share the same precondition (two error instances), so grouping them on one +machine saves setup time. + +--- + +## Scenario 1 — Rule 1: SCMU starts with a corrupt config and lists every instance + +**Rule:** Must load every installed instance even when its configuration file is corrupt. +This is the original bug: before the fix, SCMU crashed at startup. + +**Preconditions** +- **Two** error instances installed (e.g. `Particular.ServiceControl` and + `Particular.ServiceControl.2`). +- SCMU **not** running. + +**Steps** +1. Corrupt the config of **one** of the two error instances (back it up first). +2. Start SCMU. +3. Observe the DEPLOYED INSTANCES screen. + +**Pass criteria** +- [ ] SCMU starts — no crash, no fatal error dialog. +- [ ] **Both** instances appear in the list — the corrupt one is flagged, not missing + (this is the silent-skip check automation cannot perform). +- [ ] The corrupt instance's display name falls back to its Windows service name. +- [ ] The healthy instance shows its normal service status and is unaffected. +- [ ] Restore the backup, refresh, and confirm both instances show as healthy. + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Scenario 2 — Rule 1: audit and monitoring instances are protected the same way + +**Rule:** All three instance types load in the error state when their config is corrupt. + +**Preconditions** +- One audit instance and one monitoring instance installed (an error instance may + also be present). +- SCMU **not** running. + +**Steps** +1. Corrupt the audit instance's `ServiceControl.Audit.exe.config` (back it up first). +2. Corrupt the monitoring instance's `ServiceControl.Monitoring.exe.config` (back it up first). +3. Start SCMU and observe the list. + +**Pass criteria** +- [ ] SCMU starts; both the audit and the monitoring instance appear in the list, + each flagged with a configuration error. +- [ ] Any other (healthy) instances are unaffected. +- [ ] Restore both backups, refresh, and confirm both instances return to normal. + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Scenario 3 — Rule 2: the error is shown in place of the service status and names the file + +**Rule:** Must show the configuration error in place of the service status. + +**Preconditions** +- One error instance installed; SCMU may already be running. + +**Steps** +1. Corrupt the error instance's config (back it up first). +2. Refresh SCMU (or start it) and locate the instance card. + +**Pass criteria** +- [ ] The status line reads `CONFIGURATION ERROR` instead of the Windows service status. +- [ ] Neither the running nor the stopped indicator is shown. +- [ ] The per-instance banner says the configuration failed to load **and names the + full path of the config file** to fix (the exact file edited in step 1). +- [ ] Restore the backup, refresh: the normal service status returns and the banner + disappears. + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Scenario 4 — Rule 3: actions requiring a valid configuration are blocked + +**Rule:** Must block actions that require a valid configuration while the error persists. + +**Preconditions** +- Same as Scenario 3 (one error instance, corrupt config). Can be run in the same + session as Scenario 3. + +**Steps** +1. With the instance in the CONFIGURATION ERROR state, inspect its card. +2. Attempt every action the card offers. + +**Pass criteria** +- [ ] Start and Stop are not available. +- [ ] Edit and Advanced Options are hidden (or not invocable). +- [ ] No transport or persister is reported on the card. +- [ ] Remove is still possible? Note the observed behavior — removal of a broken + instance is the operator's last resort; record what happens in Notes. +- [ ] Restore the backup, refresh: Edit/Advanced Options/Start/Stop become available + again and transport/persister are reported. + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Scenario 5 — Rule 5: summary banner above the instance list + +**Rule:** Must summarize configuration errors above the instance list. +This is the XAML wiring the automated suite cannot see (confirmed surviving mutant). + +**Preconditions** +- **Two** error instances installed. Can be run together with Scenario 1. + +**Steps** +1. Corrupt the config of **one** instance (back it up first); refresh SCMU. +2. Observe the top of the DEPLOYED INSTANCES screen. +3. Corrupt the **second** instance's config too (back it up first); refresh. +4. Restore **both** backups; refresh. + +**Pass criteria** +- [ ] Step 2: a summary banner is visible **above** the instance list and names the + corrupt instance exactly. +- [ ] Step 3: the banner lists **both** corrupt instances. +- [ ] Step 4: the banner disappears entirely once all configurations are valid. +- [ ] The banner is readable and visually distinct (warning styling, not lost in the + layout) — attach a screenshot to the notes. + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Scenario 6 — Rule 4: fix on disk + refresh recovers the instance without restarting SCMU + +**Rule:** Should recover the instance once the configuration file is fixed and the +list is refreshed. + +**Preconditions** +- One error instance installed, config corrupted (back it up first), SCMU running and + showing the CONFIGURATION ERROR state. + +**Steps** +1. Repair the config file on disk (copy the `.bak` back). +2. Do **not** restart SCMU — trigger the DEPLOYED INSTANCES refresh. +3. Observe the instance card and the top of the list. + +**Pass criteria** +- [ ] The instance returns to normal: real service status shown, Edit allowed, + transport/persister reported. +- [ ] Both the per-instance banner and the summary banner disappear. +- [ ] SCMU was **not** restarted at any point in the fix loop. + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Scenario 7 — Rule 4: corruption introduced while SCMU is running is flagged on refresh + +**Rule:** Refresh re-reads the configuration from disk — in both directions. + +**Preconditions** +- One error instance installed with a **valid** config, SCMU running and showing the + instance as healthy. + +**Steps** +1. With SCMU still running, corrupt the instance's config (back it up first). +2. Trigger the DEPLOYED INSTANCES refresh. +3. Observe the instance card and the top of the list. + +**Pass criteria** +- [ ] The instance flips to CONFIGURATION ERROR; the banner names the config file. +- [ ] The summary banner appears above the list. +- [ ] Restore the backup, refresh again: everything returns to normal (full loop). + +**Result:** ☐ Pass ☐ Fail — Tester: ______ Date: ______ Notes: ______ + +--- + +## Wrap-up + +- Collect results per scenario (pass/fail, tester, notes) in this file or a copy. +- Any failure: capture a screenshot of the DEPLOYED INSTANCES screen, the SCMU log, + and the corrupted config file, and link them in the notes. +- Known out-of-scope behavior (do **not** log as failures): + - Corruption other than invalid XML (e.g. valid XML with missing/invalid setting + values) may surface lazily at first read rather than at load; the spec covers + both paths, but only invalid XML is exercised by this plan. + - The Windows service itself may keep running with a corrupt config on disk (it + read its settings at startup) — SCMU flagging the file while the service runs is + correct behavior, not a contradiction. +- The PowerShell module fallback mentioned in the bug report is unaffected by this + fix and is not covered by this plan. diff --git a/docs/specs/scmu-corrupt-instance-configuration.md b/docs/specs/scmu-corrupt-instance-configuration.md new file mode 100644 index 0000000000..11d6707c92 --- /dev/null +++ b/docs/specs/scmu-corrupt-instance-configuration.md @@ -0,0 +1,158 @@ +# Feature: SCMU resilience to corrupt instance configuration files + +> [!WARNING] +> **This document is a working artifact for the development of this fix and is +> meant to be DELETED before merging the PR.** Do not reference it from code or docs +> that will outlive the PR. + +**As an operator managing instances through ServiceControl Management Utility (SCMU), +I want SCMU to start and show every installed instance even when one of them has a +corrupt configuration file, so that I can see which instance is broken and fix it +instead of being locked out of the tool entirely.** + +> Bug: [#2759 — SCMU: Fails to start when one of the instances has a corrupt configuration file](https://github.com/Particular/ServiceControl/issues/2759). +> When any instance's `.exe.config` contains invalid XML, loading it throws +> `ConfigurationErrorsException` during instance enumeration and SCMU crashes at +> startup. The only workaround is fixing the file blind or falling back to the +> PowerShell module. + +## Vocabulary + +- **SCMU** = ServiceControl Management Utility (`ServiceControl.Config`), the Windows + desktop tool used to install and manage instances. +- **Instance** = an installed ServiceControl Windows service; three types exist: + **error instance** ("ServiceControl"), **audit instance** ("ServiceControl Audit"), + and **monitoring instance** ("ServiceControl Monitoring"). +- **Configuration file** = the instance's `.exe.config` in its install path + (e.g. `ServiceControl.exe.config`), read by SCMU to display and edit settings. +- **Corrupt configuration** = a configuration file that cannot be loaded — typically + invalid XML; loading it throws instead of returning settings. +- **Configuration error state** = how a corrupt instance is presented: loaded and + listed, but flagged with a `CONFIGURATION ERROR` status, its settings unavailable + and config-dependent actions blocked. +- **DEPLOYED INSTANCES screen** = SCMU's main screen listing all installed instances. +- **Per-instance banner** = the error banner on an instance's card; **summary + banner** = the banner above the instance list naming all corrupt instances. +- **Refresh** = the DEPLOYED INSTANCES action that re-reads every instance's + configuration from disk and updates each instance card in place. + +## Rules and Examples + +### Rule 1: Must load every installed instance even when its configuration file is corrupt + +Enumeration must never let one broken instance take down the whole list. A corrupt +instance still appears, carrying a configuration error instead of its settings; its +display name falls back to the Windows service name (the config it would normally +be read from is unreadable). + +- **Example:** The one where the error instance's config XML is corrupt and the + instance still loads, flagged with a configuration error. +- **Example:** The one where the audit instance's config XML is corrupt and the + instance still loads, flagged with a configuration error. +- **Example:** The one where the monitoring instance's config XML is corrupt and the + instance still loads, flagged with a configuration error. +- **Counter-example:** The one where the configuration is valid and no configuration + error is flagged. + +--- + +### Rule 2: Must show the configuration error in place of the service status + +The affected instance must be visibly broken in the UI: the status line reads +`CONFIGURATION ERROR` instead of the Windows service status, neither the running nor +the stopped indicator is shown, and the error banner explains what failed and names +the file the operator has to fix. + +- **Example:** The one where the status reads CONFIGURATION ERROR and neither the + running nor the stopped indicator is shown. +- **Example:** The one where the error banner explains that the configuration failed + to load and names the config file. +- **Counter-example:** The one where the configuration is valid and the Windows + service status is shown as usual. + +--- + +### Rule 3: Must block actions that require a valid configuration while the error persists + +Starting, stopping, editing, or viewing advanced options of an instance whose +configuration cannot be read would operate on unknown state. Everything derived from +configuration (transport, persister) is also unavailable. + +- **Example:** The one where Start and Stop are not allowed. +- **Example:** The one where Edit and Advanced Options are hidden. +- **Example:** The one where no transport or persister is reported. +- **Counter-example:** The one where the configuration is valid and the instance can + be edited and started as usual. + +--- + +### Rule 4: Should recover the instance once the configuration file is fixed and the list is refreshed + +The operator's fix loop is: see the error → repair the file on disk → refresh SCMU. +Refresh must re-read the configuration from disk and clear the error state without +restarting SCMU. Refresh updates each instance in place — it never swaps state +between instances. + +- **Example:** The one where the config file is fixed on disk, refresh runs, and the + instance returns to normal (service status shown, edit allowed). +- **Example:** The one where the config file becomes corrupt after loading and the + next refresh flags the error. +- **Example:** The one where the fix is picked up through the DEPLOYED INSTANCES + refresh flow (the same path the UI triggers), not just by updating a single + instance directly. +- **Counter-example:** The one where a refresh tries to apply data from a + differently-named instance and is rejected. +- **Counter-example:** The one where a refresh tries to apply data from an instance + of a different type (same name) and is rejected. + +--- + +### Rule 5: Must summarize configuration errors above the instance list + +The per-instance banner can be far below the fold in a long list. A summary banner at +the top of the DEPLOYED INSTANCES screen names the corrupt instance(s) so the operator +sees at a glance that something needs fixing, and disappears when everything is +healthy. + +- **Example:** The one where a single instance is corrupt and the banner names it. +- **Example:** The one where multiple instances are corrupt and the banner lists all + of them. +- **Counter-example:** The one where all configurations are valid and no banner is + shown. + +## Resolved decisions (for implementation) + +- **Acceptance tier:** `InstanceDetailsViewModel` observed over real + `ServiceControlInstance` / `ServiceControlAuditInstance` objects loaded from real + (corrupt or valid) config files in a temp folder, with the Windows service + substituted through the existing `IWindowsServiceController` seam. A full SCMU + end-to-end test (real Windows services) is not automatable in this repository's + test suites. +- **Healthy instances are real, not simulated:** a minimal valid config with + `ServiceControl/TransportType = LearningTransport` fully loads through + `Reload()`, so counter-examples exercise the genuine success path. +- **Error detection point:** `AppConfigWrapper` captures the open failure; + instance constructors catch any `Reload()` failure, set + `ConfigurationLoadError`, fall back to the service name, and record the error on + the `ReportCard`. Both paths are covered by Rule 1 regardless of whether + `ConfigurationManager` throws eagerly (at open) or lazily (at first read). +- **All three instance types are protected the same way** (Rule 1): + `MonitoringInstance` takes the `IWindowsServiceController` seam like the other two + and creates its `AppConfig` inside the same try/catch, so a corrupt monitoring + config is caught and testable identically. +- **No silent skip** (Rule 1): the enumeration in `InstanceFinder` does not wrap + instance construction in a try/catch that omits failing instances (and does not + write ad-hoc logs to `%TEMP%`). The constructors themselves never throw for config + errors — they load the instance in the error state instead. An instance that fails + to load must be visible, not missing. +- **Error message names the file** (Rule 2): `ConfigurationLoadError` is formatted as + `Failed to load configuration file '': `, so both banners tell the + operator exactly which file to fix. +- **List-level banner** (Rule 5): `ListInstancesView` binds a summary banner to + `ListInstancesViewModel.HasConfigurationErrors` / `ConfigurationErrorMessage`. + The view model takes a `getAllInstances` seam (internal constructor overload, + defaulting to `InstanceFinder.AllInstances`) so the banner logic is testable + without enumerating real Windows services. +- **Refresh never swaps state between instances** (Rule 4): `UpdateServiceInstance` + rejects an update whose name *or* type differs from the instance the view model + wraps, instead of silently ignoring it. diff --git a/src/ServiceControl.Config.Tests/InstanceDetails/CorruptInstanceConfiguration.cs b/src/ServiceControl.Config.Tests/InstanceDetails/CorruptInstanceConfiguration.cs new file mode 100644 index 0000000000..2147fbb1b0 --- /dev/null +++ b/src/ServiceControl.Config.Tests/InstanceDetails/CorruptInstanceConfiguration.cs @@ -0,0 +1,434 @@ +namespace ServiceControl.Config.Tests.InstanceDetails +{ + using System; + using System.IO; + using System.ServiceProcess; + using System.Threading; + using System.Threading.Tasks; + using Caliburn.Micro; + using NUnit.Framework; + using ServiceControl.Config.Events; + using ServiceControl.Config.UI.InstanceDetails; + using ServiceControl.Config.UI.ListInstances; + using ServiceControlInstaller.Engine.Instances; + using ServiceControlInstaller.Engine.Services; + + /// + /// Executable specification for SCMU resilience to corrupt instance configuration files + /// (bug https://github.com/Particular/ServiceControl/issues/2759). + /// + /// Organized as feature > rule > examples: + /// - this outer class is the feature, + /// - each nested fixture is one rule, + /// - each test is one example, named with "The one where ..." language. + /// + /// The tests load real ServiceControl / ServiceControl.Audit instances from real + /// (corrupt or valid) configuration files written to a temp install folder, substituting + /// the Windows service through the existing IWindowsServiceController seam, and observe + /// the same InstanceDetailsViewModel state the UI binds to. + /// + public class CorruptInstanceConfiguration + { + [TestFixture] + public class Rule_1_Must_load_every_instance_even_when_its_configuration_file_is_corrupt : CorruptInstanceConfigurationFixture + { + [Test] + public void The_one_where_the_error_instance_config_xml_is_corrupt_and_the_instance_still_loads_flagged_with_the_error() + { + WriteErrorInstanceConfig(CorruptXml); + + var instance = LoadErrorInstance(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(instance.ConfigurationLoadError, Is.Not.Null.And.Not.Empty, + "A corrupt config must be reported as a configuration load error"); + Assert.That(instance.InstanceName, Is.EqualTo(ServiceName), + "The instance name must fall back to the Windows service name when the config cannot be read"); + Assert.That(instance.ReportCard.Errors, Is.Not.Empty); + } + } + + [Test] + public void The_one_where_the_audit_instance_config_xml_is_corrupt_and_the_instance_still_loads_flagged_with_the_error() + { + WriteAuditInstanceConfig(CorruptXml); + + var instance = LoadAuditInstance(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(instance.ConfigurationLoadError, Is.Not.Null.And.Not.Empty, + "A corrupt config must be reported as a configuration load error"); + Assert.That(instance.InstanceName, Is.EqualTo(ServiceName), + "The instance name must fall back to the Windows service name when the config cannot be read"); + Assert.That(instance.ReportCard.Errors, Is.Not.Empty); + } + } + + [Test] + public void The_one_where_the_monitoring_instance_config_xml_is_corrupt_and_the_instance_still_loads_flagged_with_the_error() + { + WriteMonitoringInstanceConfig(CorruptXml); + + var instance = LoadMonitoringInstance(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(instance.ConfigurationLoadError, Is.Not.Null.And.Not.Empty, + "A corrupt config must be reported as a configuration load error"); + Assert.That(instance.InstanceName, Is.EqualTo(ServiceName), + "The instance name must fall back to the Windows service name when the config cannot be read"); + Assert.That(instance.ReportCard.Errors, Is.Not.Empty); + } + } + + [Test] + public void The_one_where_the_configuration_is_valid_and_no_error_is_flagged() + { + WriteErrorInstanceConfig(ValidErrorInstanceXml); + + var instance = LoadErrorInstance(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(instance.ConfigurationLoadError, Is.Null.Or.Empty); + Assert.That(instance.TransportPackage, Is.Not.Null, + "A valid config must load fully, including the transport"); + } + } + } + + [TestFixture] + public class Rule_2_Must_show_the_configuration_error_in_place_of_the_service_status : CorruptInstanceConfigurationFixture + { + [Test] + public void The_one_where_the_status_reads_configuration_error_and_no_running_or_stopped_indicator_is_shown() + { + WriteErrorInstanceConfig(CorruptXml); + + var viewModel = DetailsFor(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.HasConfigurationError, Is.True); + Assert.That(viewModel.Status, Is.EqualTo("CONFIGURATION ERROR")); + Assert.That(viewModel.IsRunning, Is.False, "No running indicator for a corrupt instance"); + Assert.That(viewModel.IsStopped, Is.False, "No stopped indicator for a corrupt instance"); + } + } + + [Test] + public void The_one_where_the_error_banner_explains_that_the_configuration_failed_to_load_and_names_the_file() + { + WriteErrorInstanceConfig(CorruptXml); + + var instance = LoadErrorInstance(); + var viewModel = DetailsFor(instance); + + Assert.That(viewModel.ConfigurationErrorMessage, + Does.Contain("Failed to load configuration file").And.Contain(instance.ConfigurationFilePath), + "The banner must point the operator at the file that needs fixing"); + } + + [Test] + public void The_one_where_the_configuration_is_valid_and_the_windows_service_status_is_shown_as_usual() + { + WriteErrorInstanceConfig(ValidErrorInstanceXml); + + var viewModel = DetailsFor(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.HasConfigurationError, Is.False); + Assert.That(viewModel.Status, Is.EqualTo("STOPPED")); + Assert.That(viewModel.IsStopped, Is.True); + } + } + } + + [TestFixture] + public class Rule_3_Must_block_actions_that_require_a_valid_configuration_while_the_error_persists : CorruptInstanceConfigurationFixture + { + [Test] + public void The_one_where_start_and_stop_are_not_allowed() + { + WriteErrorInstanceConfig(CorruptXml); + + var viewModel = DetailsFor(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.AllowStart, Is.False); + Assert.That(viewModel.AllowStop, Is.False); + } + } + + [Test] + public void The_one_where_edit_and_advanced_options_are_hidden() + { + WriteErrorInstanceConfig(CorruptXml); + + var viewModel = DetailsFor(LoadErrorInstance()); + + Assert.That(viewModel.AllowEdit, Is.False); + } + + [Test] + public void The_one_where_no_transport_or_persister_is_reported() + { + WriteErrorInstanceConfig(CorruptXml); + + var viewModel = DetailsFor(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.Transport, Is.Null); + Assert.That(viewModel.Persister, Is.Empty); + } + } + + [Test] + public void The_one_where_the_configuration_is_valid_and_the_instance_can_be_edited_and_started_as_usual() + { + WriteErrorInstanceConfig(ValidErrorInstanceXml); + + var viewModel = DetailsFor(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.AllowEdit, Is.True); + Assert.That(viewModel.AllowStart, Is.True, "A stopped, healthy instance can be started"); + } + } + } + + [TestFixture] + public class Rule_4_Should_recover_the_instance_once_the_configuration_file_is_fixed_and_the_list_is_refreshed : CorruptInstanceConfigurationFixture + { + [Test] + public void The_one_where_the_config_file_is_fixed_on_disk_and_refresh_returns_the_instance_to_normal() + { + WriteErrorInstanceConfig(CorruptXml); + var viewModel = DetailsFor(LoadErrorInstance()); + Assert.That(viewModel.HasConfigurationError, Is.True, "Precondition: the instance starts out corrupt"); + + // The operator fixes the file, then SCMU refreshes and re-reads instances from disk + WriteErrorInstanceConfig(ValidErrorInstanceXml); + viewModel.UpdateServiceInstance(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.HasConfigurationError, Is.False); + Assert.That(viewModel.Status, Is.EqualTo("STOPPED")); + Assert.That(viewModel.AllowEdit, Is.True); + } + } + + [Test] + public async Task The_one_where_the_config_file_becomes_corrupt_after_loading_and_the_next_refresh_flags_the_error() + { + WriteErrorInstanceConfig(ValidErrorInstanceXml); + var instance = LoadErrorInstance(); + var viewModel = DetailsFor(instance); + Assert.That(viewModel.HasConfigurationError, Is.False, "Precondition: the instance starts out healthy"); + + // The file is corrupted while SCMU is running, then a refresh reloads it + WriteErrorInstanceConfig(CorruptXml); + await viewModel.HandleAsync(new PostRefreshInstances(), CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(viewModel.HasConfigurationError, Is.True); + Assert.That(viewModel.Status, Is.EqualTo("CONFIGURATION ERROR")); + Assert.That(viewModel.ConfigurationErrorMessage, Does.Contain(instance.ConfigurationFilePath)); + } + } + + [Test] + public async Task The_one_where_the_fix_is_picked_up_through_the_deployed_instances_refresh_flow() + { + WriteErrorInstanceConfig(CorruptXml); + var list = new ListInstancesViewModel(DetailsFor, () => [LoadErrorInstance()]) + { + EventAggregator = new EventAggregator() + }; + Assert.That(list.HasConfigurationErrors, Is.True, "Precondition: the list starts out with a corrupt instance"); + + // The operator fixes the file, then triggers the refresh the UI uses + WriteErrorInstanceConfig(ValidErrorInstanceXml); + await list.HandleAsync(new RefreshInstances(), CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(list.HasConfigurationErrors, Is.False); + Assert.That(list.ConfigurationErrorMessage, Is.Null); + } + } + + [Test] + public void The_one_where_a_refresh_tries_to_apply_data_from_a_differently_named_instance_and_is_rejected() + { + WriteErrorInstanceConfig(CorruptXml); + var viewModel = DetailsFor(LoadErrorInstance()); + + var differentInstance = LoadErrorInstance(serviceName: "Particular.ServiceControl.Other"); + + Assert.That(() => viewModel.UpdateServiceInstance(differentInstance), Throws.ArgumentException); + } + + [Test] + public void The_one_where_a_refresh_tries_to_apply_data_from_an_instance_of_a_different_type_and_is_rejected() + { + WriteErrorInstanceConfig(CorruptXml); + var viewModel = DetailsFor(LoadErrorInstance()); + + WriteAuditInstanceConfig(CorruptXml); + var differentTypeInstance = LoadAuditInstance(); // same service name, different instance type + + Assert.That(() => viewModel.UpdateServiceInstance(differentTypeInstance), Throws.ArgumentException); + } + } + + [TestFixture] + public class Rule_5_Must_summarize_configuration_errors_above_the_instance_list : CorruptInstanceConfigurationFixture + { + [Test] + public void The_one_where_a_single_instance_is_corrupt_and_the_banner_names_it() + { + WriteErrorInstanceConfig(CorruptXml); + WriteAuditInstanceConfig(ValidAuditInstanceXml); + + var list = ListFor(LoadErrorInstance(), LoadAuditInstance("Particular.ServiceControl.Audit")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(list.HasConfigurationErrors, Is.True); + Assert.That(list.ConfigurationErrorMessage, + Is.EqualTo("Particular.ServiceControl instance cannot be loaded due to XML configuration error.")); + } + } + + [Test] + public void The_one_where_multiple_instances_are_corrupt_and_the_banner_lists_all_of_them() + { + WriteErrorInstanceConfig(CorruptXml); + WriteAuditInstanceConfig(CorruptXml); + + var list = ListFor(LoadErrorInstance(), LoadAuditInstance("Particular.ServiceControl.Audit")); + + Assert.That(list.ConfigurationErrorMessage, + Is.EqualTo("Multiple instances (Particular.ServiceControl, Particular.ServiceControl.Audit) cannot be loaded due to XML configuration errors.")); + } + + [Test] + public void The_one_where_all_configurations_are_valid_and_no_banner_is_shown() + { + WriteErrorInstanceConfig(ValidErrorInstanceXml); + + var list = ListFor(LoadErrorInstance()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(list.HasConfigurationErrors, Is.False); + Assert.That(list.ConfigurationErrorMessage, Is.Null); + } + } + } + } + + public abstract class CorruptInstanceConfigurationFixture + { + protected const string ServiceName = "Particular.ServiceControl"; + + // A realistically corrupted file: truncated mid-element, as left behind by a failed edit + protected const string CorruptXml = """ + + + + + + + + + + """; + + protected const string ValidAuditInstanceXml = """ + + + + + + + """; + + protected string InstallPath { get; private set; } + + [SetUp] + public void CreateInstallPath() + { + InstallPath = Path.Combine(Path.GetTempPath(), "SCMUCorruptConfigSpec", Path.GetRandomFileName()); + Directory.CreateDirectory(InstallPath); + } + + [TearDown] + public void DeleteInstallPath() => Directory.Delete(InstallPath, recursive: true); + + protected void WriteErrorInstanceConfig(string contents) => + File.WriteAllText(Path.Combine(InstallPath, $"{Constants.ServiceControlExe}.config"), contents); + + protected void WriteAuditInstanceConfig(string contents) => + File.WriteAllText(Path.Combine(InstallPath, $"{Constants.ServiceControlAuditExe}.config"), contents); + + protected void WriteMonitoringInstanceConfig(string contents) => + File.WriteAllText(Path.Combine(InstallPath, $"{Constants.MonitoringExe}.config"), contents); + + protected ServiceControlInstance LoadErrorInstance(string serviceName = ServiceName) => + new(new FakeWindowsServiceController(Path.Combine(InstallPath, Constants.ServiceControlExe), serviceName)); + + protected ServiceControlAuditInstance LoadAuditInstance(string serviceName = ServiceName) => + new(new FakeWindowsServiceController(Path.Combine(InstallPath, Constants.ServiceControlAuditExe), serviceName)); + + protected MonitoringInstance LoadMonitoringInstance(string serviceName = ServiceName) => + new(new FakeWindowsServiceController(Path.Combine(InstallPath, Constants.MonitoringExe), serviceName)); + + internal static InstanceDetailsViewModel DetailsFor(BaseService instance) => + new(instance, null, null, null, null, null, null, null, null); + + internal static ListInstancesViewModel ListFor(params BaseService[] instances) => + new(DetailsFor, () => instances); + + class FakeWindowsServiceController(string exePath, string serviceName) : IWindowsServiceController + { + public string ServiceName => serviceName; + + public string ExePath => exePath; + + public string Description { get; set; } + + public ServiceControllerStatus Status => ServiceControllerStatus.Stopped; + + public string Account => "LocalSystem"; + + public string DisplayName => serviceName; + + public bool Exists() => true; + + public void Refresh() + { + } + + public void WaitForStatus(ServiceControllerStatus stopped, TimeSpan timeSpan) => throw new NotSupportedException(); + public void Start() => throw new NotSupportedException(); + public void Stop() => throw new NotSupportedException(); + public void SetStartupMode(string v) => throw new NotSupportedException(); + public void Delete() => throw new NotSupportedException(); + public void ChangeAccountDetails(string accountName, string serviceAccountPwd) => throw new NotSupportedException(); + } + } +} diff --git a/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml b/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml index dcddb94c1a..1b80eaaf6f 100644 --- a/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml +++ b/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml @@ -58,8 +58,38 @@ Text="{Binding InstanceTypeDisplayName}" /> + + + + + + + + + + + + + + + + + + Text="{Binding Status}"> + + + + + Style="{StaticResource ConfigurationButton}" + Visibility="{Binding AllowEdit, Converter={StaticResource boolToVis}}" />