Files
BizTalkPlatformManagementTool/tests/BizTalkPlatformManagementTool.Tests/Program.cs
T

467 lines
29 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using BizTalkPlatformManagementTool.Models;
using BizTalkPlatformManagementTool.Services;
using BizTalkPlatformManagementTool.Setup;
namespace BizTalkPlatformManagementTool.Tests
{
/// <summary>
/// Enthält die portable Regressionstestsuite ohne Abhängigkeit von einem externen Testframework.
/// </summary>
internal static class Program
{
/// <summary>Anzahl der im aktuellen Testlauf fehlgeschlagenen Prüfungen.</summary>
private static int failures;
/// <summary>Führt alle Regressionstests aus und liefert einen CI-tauglichen Exitcode.</summary>
/// <returns>Null, wenn alle Tests bestanden wurden; andernfalls eins.</returns>
private static int Main()
{
Run("JsonRoundTripIsBomTolerantAndAtomic", JsonRoundTripIsBomTolerantAndAtomic);
Run("DiffUsesApplicationAndNameIdentity", DiffUsesApplicationAndNameIdentity);
Run("RestoreRejectsDifferentServer", RestoreRejectsDifferentServer);
Run("RestorePlanUsesSafeOrder", RestorePlanUsesSafeOrder);
Run("CsvNeutralizesFormulaValues", CsvNeutralizesFormulaValues);
Run("PackageManifestRejectsTampering", PackageManifestRejectsTampering);
Run("PackageManifestRejectsUndeclaredAndTraversalFiles", PackageManifestRejectsUndeclaredAndTraversalFiles);
Run("InstallerActivatesValidatedPayload", InstallerActivatesValidatedPayload);
Run("InstallerRetriesTransientActivationMove", InstallerRetriesTransientActivationMove);
Run("InstallerUsesVerifiedCopyFallbackForNewInstall", InstallerUsesVerifiedCopyFallbackForNewInstall);
Run("InstallerStopsAfterBoundedUpdateMoveRetries", InstallerStopsAfterBoundedUpdateMoveRetries);
Run("InstallerDoesNotMutateOnStagingFailure", InstallerDoesNotMutateOnStagingFailure);
Run("InstallerRollsBackFailedActivatedSelfTest", InstallerRollsBackFailedActivatedSelfTest);
Run("InstallerUninstallRemovesProgramDirectory", InstallerUninstallRemovesProgramDirectory);
Run("InstallerDiagnosticLogContainsContextAndExceptionChain", InstallerDiagnosticLogContainsContextAndExceptionChain);
Run("InstallerDiagnosticLogFallsBackToTemp", InstallerDiagnosticLogFallsBackToTemp);
Run("InstallerSurvivesUiReportFailure", InstallerSurvivesUiReportFailure);
Console.WriteLine(failures == 0 ? "ALL TESTS PASSED" : failures + " TEST(S) FAILED");
return failures == 0 ? 0 : 1;
}
/// <summary>Führt einen einzelnen Test isoliert aus und protokolliert sein Ergebnis.</summary>
/// <param name="name">Der stabile Testname für die Konsolenausgabe.</param>
/// <param name="test">Die auszuführende Testfunktion.</param>
private static void Run(string name, Action test)
{
try { test(); Console.WriteLine("PASS " + name); }
catch (Exception ex) { failures++; Console.Error.WriteLine("FAIL " + name + ": " + ex); }
}
/// <summary>Prüft BOM-tolerantes Lesen und rückstandsfreies atomisches JSON-Schreiben.</summary>
private static void JsonRoundTripIsBomTolerantAndAtomic()
{
InTemp(directory =>
{
var path = Path.Combine(directory, "snapshot.json");
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
JsonFileStore.Save(path, snapshot);
JsonFileStore.Save(path, snapshot);
var original = File.ReadAllBytes(path);
var withBom = new byte[original.Length + 3];
withBom[0] = 0xef; withBom[1] = 0xbb; withBom[2] = 0xbf;
Buffer.BlockCopy(original, 0, withBom, 3, original.Length);
File.WriteAllBytes(path, withBom);
Assert(JsonFileStore.Load<BizTalkSnapshot>(path).Applications.Count == 1, "BOM JSON did not load");
Assert(Directory.GetFiles(directory, "*.tmp.*").Length == 0, "temporary files remained");
Assert(Directory.GetFiles(directory, "*.bak.*").Length == 0, "backup files remained");
});
}
/// <summary>Prüft, dass gleichnamige Artefakte verschiedener Anwendungen getrennt verglichen werden.</summary>
private static void DiffUsesApplicationAndNameIdentity()
{
var before = Snapshot("APP-A", "SHARED", ArtifactStates.SendPortStarted);
before.Applications.Add(Snapshot("APP-B", "SHARED", ArtifactStates.SendPortStarted).Applications[0]);
var after = Snapshot("APP-A", "SHARED", ArtifactStates.SendPortStopped);
after.Applications.Add(Snapshot("APP-B", "SHARED", ArtifactStates.SendPortStarted).Applications[0]);
var diff = SnapshotComparer.Compare(before, after);
Assert(diff.ArtifactDifferences.Count == 1, "expected one application-scoped difference");
Assert(diff.ArtifactDifferences[0].Application == "APP-A", "wrong application was compared");
}
/// <summary>Prüft Kurzname/FQDN-Kompatibilität und Ablehnung eines fremden Restore-Zielservers.</summary>
private static void RestoreRejectsDifferentServer()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.Server = "BIZTALK-A.example.local";
SnapshotValidator.EnsureServerMatches(snapshot, "BIZTALK-A");
Expect<InvalidOperationException>(() => SnapshotValidator.EnsureServerMatches(snapshot, "BIZTALK-B"));
}
/// <summary>Prüft die sichere Restore-Reihenfolge und den Schutz gebundener Orchestrierungen.</summary>
private static void RestorePlanUsesSafeOrder()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
snapshot.Applications[0].Orchestrations.Add(new OrchestrationState { Application = "APP", Name = "ORCH", OrchestrationStatus = ArtifactStates.OrchestrationBound });
snapshot.Applications[0].ReceiveLocations.Add(new ReceiveLocationState { Application = "APP", Name = "RL", Enabled = true });
var plan = new BizTalkOperationService(null).CreateRestorePlan(snapshot, snapshot.Server);
Assert(plan.Steps.First().Kind == "HostInstance", "host instance must start first");
Assert(plan.Steps.Last().Kind == "ReceiveLocation", "receive location must be restored last");
var bound = plan.Steps.Single(x => x.Name == "ORCH");
Assert(!bound.Execute && bound.Kind == "Note", "bound orchestration must remain unchanged");
}
/// <summary>Prüft die Neutralisierung formelartiger CSV-Feldwerte.</summary>
private static void CsvNeutralizesFormulaValues()
{
InTemp(directory =>
{
var path = Path.Combine(directory, "diff.csv");
var diff = new SnapshotDiff();
diff.ArtifactDifferences.Add(new ArtifactDiffEntry { Application = "=cmd|' /C calc'!A0", ArtifactType = "SendPort", Name = "PORT", Before = "Started", After = "Stopped" });
CsvWriter.WriteDiff(path, diff);
Assert(File.ReadAllText(path).Contains("'=cmd"), "formula-like CSV field was not neutralized");
});
}
/// <summary>Prüft, dass eine nach Manifestbildung veränderte Payload abgelehnt wird.</summary>
private static void PackageManifestRejectsTampering()
{
InTemp(directory =>
{
var app = Path.Combine(directory, "application"); Directory.CreateDirectory(app);
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), "payload");
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
var manifest = Path.Combine(directory, "application.manifest");
PackageManifest.Write(app, manifest);
PackageManifest.ValidateAndRead(app, manifest);
File.AppendAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), "tampered");
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
});
}
/// <summary>Prüft die Aktivierung einer vollständig validierten Neuinstallation.</summary>
private static void InstallerActivatesValidatedPayload()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var engine = new InstallerEngine(package, install, data, false, path => File.Exists(path));
engine.Install(false, null);
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "new", "new payload not activated");
Assert(File.Exists(Path.Combine(install, "install-state.txt")), "install state missing");
});
}
/// <summary>Prüft die erfolgreiche Aktivierung nach einer kurzzeitigen Rename-Sperre.</summary>
private static void InstallerRetriesTransientActivationMove()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var moveCalls = 0;
var delays = new List<int>();
var engine = new InstallerEngine(
package,
install,
data,
false,
path => true,
(source, target) =>
{
moveCalls++;
if (moveCalls == 1) throw new IOException("simulated transient scanner lock");
Directory.Move(source, target);
},
delays.Add);
engine.Install(false, null);
Assert(moveCalls == 2, "transient activation move was not retried exactly once");
Assert(delays.SequenceEqual(new[] { 250 }), "unexpected retry delay for transient activation move");
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "new", "payload was not activated after retry");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=directory_move_retry role=activate failed_attempt=1"), "transient move retry was not diagnosed");
Assert(log.Contains("event=directory_move_recovered role=activate attempt=2"), "move recovery was not diagnosed");
});
}
/// <summary>Prüft den verifizierten Kopierfallback einer durchgehend gesperrten Neuinstallation.</summary>
private static void InstallerUsesVerifiedCopyFallbackForNewInstall()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var moveCalls = 0;
var delays = new List<int>();
var engine = new InstallerEngine(
package,
install,
data,
false,
path => true,
(source, target) =>
{
moveCalls++;
throw new UnauthorizedAccessException("simulated permanent policy denial");
},
delays.Add);
engine.Install(false, null);
Assert(moveCalls == 8, "permanent move failure did not stop after eight attempts");
Assert(delays.SequenceEqual(new[] { 250, 500, 1000, 2000, 3000, 5000, 8000 }), "bounded retry schedule changed unexpectedly");
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "new", "verified copy fallback did not activate payload");
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging remained after copy fallback");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=directory_move_retry role=activate failed_attempt=7"), "final scheduled retry was not diagnosed");
Assert(log.Contains("exception_type=System.UnauthorizedAccessException"), "ACL failure type missing from retry diagnostics");
Assert(log.Contains("event=activation_fallback_complete method=verified_copy"), "copy fallback completion was not diagnosed");
Assert(log.Contains("activation_method=verified_copy_fallback"), "copy fallback missing from setup summary");
});
}
/// <summary>Prüft, dass ein Update bei dauerhaft gesperrtem Backup atomar und unverändert abbricht.</summary>
private static void InstallerStopsAfterBoundedUpdateMoveRetries()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "old");
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName + ".config"), "old-config");
var data = Path.Combine(directory, "data");
var moveCalls = 0;
var delays = new List<int>();
var engine = new InstallerEngine(
package,
install,
data,
false,
path => true,
(source, target) =>
{
moveCalls++;
throw new UnauthorizedAccessException("simulated permanent update policy denial");
},
delays.Add);
var exception = Capture<InvalidOperationException>(() => engine.Install(false, null));
Assert(moveCalls == 8, "permanent update move failure did not stop after eight attempts");
Assert(delays.SequenceEqual(new[] { 250, 500, 1000, 2000, 3000, 5000, 8000 }), "bounded update retry schedule changed unexpectedly");
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "failed update modified the active installation");
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging remained after permanent update failure");
Assert(exception.Message.Contains("Fehlercode=SETUP-ACTIVATION"), "activation error code missing after update retries");
Assert(exception.Message.Contains("Kein Rollback erforderlich"), "pre-mutation update denial reported a rollback");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=directory_move_retry role=backup failed_attempt=7"), "final update retry was not diagnosed");
Assert(!log.Contains("event=activation_fallback_started"), "update incorrectly used the new-install copy fallback");
});
}
/// <summary>Prüft die Ablehnung nicht deklarierter Dateien und ausbrechender Manifestpfade.</summary>
private static void PackageManifestRejectsUndeclaredAndTraversalFiles()
{
InTemp(directory =>
{
var app = Path.Combine(directory, "application"); Directory.CreateDirectory(app);
var exe = Path.Combine(app, InstallerEngine.ApplicationExeName);
File.WriteAllText(exe, "payload");
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
var manifest = Path.Combine(directory, "application.manifest");
PackageManifest.Write(app, manifest);
File.WriteAllText(Path.Combine(app, "undeclared.dll"), "extra");
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
File.Delete(Path.Combine(app, "undeclared.dll"));
File.WriteAllText(manifest, PackageManifest.Sha256(exe) + "|" + new FileInfo(exe).Length + "|../escape.exe" + Environment.NewLine);
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
});
}
/// <summary>Prüft, dass ein Staging-Fehler die aktive Installation nicht mutiert.</summary>
private static void InstallerDoesNotMutateOnStagingFailure()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install"); Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "old");
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName + ".config"), "old-config");
var data = Path.Combine(directory, "data");
var messages = new List<string>();
var engine = new InstallerEngine(package, install, data, false, path => false);
var exception = Capture<InvalidOperationException>(() => engine.Install(false, messages.Add));
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "staging failure modified the installed payload");
Assert(!Directory.GetDirectories(directory, "install.backup.*").Any(), "backup was created before staging passed");
Assert(exception.Message.Contains("Fehlercode=SETUP-STAGING-VALIDATION"), "staging error code missing");
Assert(exception.Message.Contains("Kein Rollback erforderlich"), "pre-mutation failure reported a rollback");
Assert(messages.Any(x => x.Contains("Kein Rollback erforderlich")), "operator output did not distinguish pre-mutation failure");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=self_test_completed label=staging"), "staging self-test result missing from log");
Assert(log.Contains("event=rollback_summary result=not_required"), "no-rollback evidence missing from log");
});
}
/// <summary>Prüft die Wiederherstellung der Vorversion nach fehlgeschlagenem aktiviertem Self-Test.</summary>
private static void InstallerRollsBackFailedActivatedSelfTest()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install"); Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "old");
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName + ".config"), "old-config");
var calls = 0;
var engine = new InstallerEngine(package, install, Path.Combine(directory, "data"), false, path => ++calls == 1);
var exception = Capture<InvalidOperationException>(() => engine.Install(false, null));
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "previous payload was not restored");
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging directory remained");
Assert(!Directory.GetDirectories(directory, "install.backup.*").Any(), "backup directory remained");
Assert(exception.Message.Contains("Fehlercode=SETUP-ACTIVATED-SELFTEST"), "activated self-test error code missing");
Assert(exception.Message.Contains("Rollback erfolgreich"), "successful rollback not reported");
});
}
/// <summary>Prüft die Deinstallation über ein atomar umbenanntes Quarantäneverzeichnis.</summary>
private static void InstallerUninstallRemovesProgramDirectory()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install"); Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "installed");
var engine = new InstallerEngine(package, install, Path.Combine(directory, "data"), false, path => true);
engine.Uninstall(null);
Assert(!Directory.Exists(install), "program directory still exists after uninstall");
Assert(!Directory.GetDirectories(directory, "install.removed.*").Any(), "uninstall quarantine directory remained");
});
}
/// <summary>Prüft technischen Kontext, Fehlercode, HRESULT und innere Ausnahme im Setup-Log.</summary>
private static void InstallerDiagnosticLogContainsContextAndExceptionChain()
{
InTemp(directory =>
{
var log = SetupOperationLog.Create(directory, "diagnostic-test");
Assert(!string.IsNullOrEmpty(log.FilePath), "diagnostic log was not created");
Exception failure;
try { throw new InvalidOperationException("outer", new IOException("inner")); }
catch (Exception ex) { failure = ex; }
log.WriteException("TEST-CODE", "Test phase", failure);
var content = File.ReadAllText(log.FilePath);
Assert(content.Contains("event=setup_started"), "setup context header missing");
Assert(content.Contains("process_bitness="), "process bitness missing");
Assert(content.Contains("identity="), "identity missing");
Assert(content.Contains("error_code=TEST-CODE"), "error code missing");
Assert(content.Contains("inner[1]"), "inner exception missing");
Assert(content.Contains("hresult=0x"), "HRESULT missing");
});
}
/// <summary>Prüft, dass ein fehlerhafter UI-Callback die Installation nicht beeinflusst.</summary>
private static void InstallerSurvivesUiReportFailure()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var engine = new InstallerEngine(package, install, data, false, path => true);
engine.Install(false, message => { throw new InvalidOperationException("UI unavailable"); });
Assert(File.Exists(Path.Combine(install, InstallerEngine.ApplicationExeName)), "UI report failure interrupted installation");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=ui_report_failed"), "UI report failure was not diagnosed");
});
}
/// <summary>Prüft den Diagnose-Log-Fallback bei einem nicht verwendbaren ProgramData-Pfad.</summary>
private static void InstallerDiagnosticLogFallsBackToTemp()
{
InTemp(directory =>
{
var blockedDataDirectory = Path.Combine(directory, "blocked-data-directory");
File.WriteAllText(blockedDataDirectory, "not a directory");
var log = SetupOperationLog.Create(blockedDataDirectory, "fallback-test");
try
{
Assert(log.IsFallback, "diagnostic log did not use the Temp fallback");
Assert(!string.IsNullOrEmpty(log.CreationError), "primary logging failure was not retained");
Assert(File.Exists(log.FilePath), "fallback diagnostic log was not created");
Assert(File.ReadAllText(log.FilePath).Contains("event=primary_log_creation_failed"), "fallback reason missing from log");
}
finally
{
if (File.Exists(log.FilePath)) File.Delete(log.FilePath);
}
});
}
/// <summary>Erstellt einen minimalen Snapshot für Vergleiche und Planprüfungen.</summary>
/// <param name="application">Der Name der Testanwendung.</param>
/// <param name="port">Der Name des Test-Send-Ports.</param>
/// <param name="state">Der rohe Send-Port-Status.</param>
/// <returns>Ein Snapshot mit einer Anwendung und einem Send Port.</returns>
private static BizTalkSnapshot Snapshot(string application, string port, int state)
{
var result = new BizTalkSnapshot { ToolVersion = "test", CreatedAt = DateTimeOffset.Now.ToString("o"), Server = Environment.MachineName };
var app = new ApplicationSnapshot { Application = application };
app.SendPorts.Add(new SendPortState { Application = application, Name = port, Status = state });
result.Applications.Add(app);
return result;
}
/// <summary>Erzeugt eine minimale, manifestierte Installer-Payload.</summary>
/// <param name="root">Das temporäre Testwurzelverzeichnis.</param>
/// <param name="payload">Der simulierte Inhalt der Anwendungs-EXE.</param>
/// <returns>Das Verzeichnis des erzeugten Testpakets.</returns>
private static string CreatePackage(string root, string payload)
{
var package = Path.Combine(root, "package");
var app = Path.Combine(package, "application"); Directory.CreateDirectory(app);
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), payload);
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
PackageManifest.Write(app, Path.Combine(package, "application.manifest"));
return package;
}
/// <summary>Führt einen Test in einem eindeutigen temporären Verzeichnis mit garantierter Bereinigung aus.</summary>
/// <param name="action">Die Testfunktion, die den temporären Pfad erhält.</param>
private static void InTemp(Action<string> action)
{
var directory = Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool.Tests." + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
try { action(directory); }
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
/// <summary>Bricht den Test ab, wenn eine erwartete Bedingung nicht erfüllt ist.</summary>
/// <param name="condition">Die erwartete Bedingung.</param>
/// <param name="message">Die Fehlermeldung bei nicht erfüllter Bedingung.</param>
private static void Assert(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); }
/// <summary>Prüft, dass eine Aktion eine bestimmte Ausnahme auslöst.</summary>
/// <typeparam name="T">Der erwartete Ausnahmetyp.</typeparam>
/// <param name="action">Die auszuführende Aktion.</param>
private static void Expect<T>(Action action) where T : Exception
{
try { action(); }
catch (T) { return; }
throw new InvalidOperationException("Expected exception " + typeof(T).Name);
}
/// <summary>Führt eine Aktion aus und gibt die erwartete Ausnahme für weitere Prüfungen zurück.</summary>
/// <typeparam name="T">Der erwartete Ausnahmetyp.</typeparam>
/// <param name="action">Die auszuführende Aktion.</param>
/// <returns>Die von der Aktion ausgelöste Ausnahme.</returns>
private static T Capture<T>(Action action) where T : Exception
{
try { action(); }
catch (T ex) { return ex; }
throw new InvalidOperationException("Expected exception " + typeof(T).Name);
}
}
}