diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..a85367c
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+custom: ["https://web.satispay.com/app/match/link/user/S6Y-CON--88923C30-BEC8-487E-9814-68A5449F7D83?amount=500¤cy=EUR"]
diff --git a/SafeExamBrowser.Applications.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml b/SafeExamBrowser.Applications.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml
new file mode 100644
index 0000000..a42d7f0
--- /dev/null
+++ b/SafeExamBrowser.Applications.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/SafeExamBrowser.Browser.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml b/SafeExamBrowser.Browser.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml
new file mode 100644
index 0000000..a42d7f0
--- /dev/null
+++ b/SafeExamBrowser.Browser.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/SafeExamBrowser.Browser/Events/JavaScriptDialogRequestedEventArgs.cs b/SafeExamBrowser.Browser/Events/JavaScriptDialogRequestedEventArgs.cs
new file mode 100644
index 0000000..52b456e
--- /dev/null
+++ b/SafeExamBrowser.Browser/Events/JavaScriptDialogRequestedEventArgs.cs
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+namespace SafeExamBrowser.Browser.Events
+{
+ internal class JavaScriptDialogRequestedEventArgs
+ {
+ internal bool Success { get; set; }
+ internal JavaScriptDialogType Type { get; set; }
+ }
+}
diff --git a/SafeExamBrowser.Browser/Events/JavaScriptDialogRequestedEventHandler.cs b/SafeExamBrowser.Browser/Events/JavaScriptDialogRequestedEventHandler.cs
new file mode 100644
index 0000000..81b4b33
--- /dev/null
+++ b/SafeExamBrowser.Browser/Events/JavaScriptDialogRequestedEventHandler.cs
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+namespace SafeExamBrowser.Browser.Events
+{
+ internal delegate void JavaScriptDialogRequestedEventHandler(JavaScriptDialogRequestedEventArgs args);
+}
diff --git a/SafeExamBrowser.Browser/Events/JavaScriptDialogType.cs b/SafeExamBrowser.Browser/Events/JavaScriptDialogType.cs
new file mode 100644
index 0000000..c1b85d7
--- /dev/null
+++ b/SafeExamBrowser.Browser/Events/JavaScriptDialogType.cs
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+namespace SafeExamBrowser.Browser.Events
+{
+ internal enum JavaScriptDialogType
+ {
+ LeavePage,
+ Reload
+ }
+}
diff --git a/SafeExamBrowser.Browser/Handlers/DragHandler.cs b/SafeExamBrowser.Browser/Handlers/DragHandler.cs
new file mode 100644
index 0000000..65e13c2
--- /dev/null
+++ b/SafeExamBrowser.Browser/Handlers/DragHandler.cs
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System.Collections.Generic;
+using CefSharp;
+using CefSharp.Enums;
+
+namespace SafeExamBrowser.Browser.Handlers
+{
+ internal class DragHandler : IDragHandler
+ {
+ public bool OnDragEnter(IWebBrowser chromiumWebBrowser, IBrowser browser, IDragData dragData, DragOperationsMask mask)
+ {
+ return true;
+ }
+
+ public void OnDraggableRegionsChanged(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IList regions)
+ {
+ }
+ }
+}
diff --git a/SafeExamBrowser.Browser/Handlers/FocusHandler.cs b/SafeExamBrowser.Browser/Handlers/FocusHandler.cs
new file mode 100644
index 0000000..418ec78
--- /dev/null
+++ b/SafeExamBrowser.Browser/Handlers/FocusHandler.cs
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Handlers
+{
+ internal class FocusHandler : IFocusHandler
+ {
+ internal FocusHandler()
+ {
+ }
+
+ public void OnGotFocus(IWebBrowser webBrowser, IBrowser browser)
+ {
+ }
+
+ public bool OnSetFocus(IWebBrowser webBrowser, IBrowser browser, CefFocusSource source)
+ {
+ return false;
+ }
+
+ public void OnTakeFocus(IWebBrowser webBrowser, IBrowser browser, bool next)
+ {
+ }
+ }
+}
diff --git a/SafeExamBrowser.Browser/Handlers/JavaScriptDialogHandler.cs b/SafeExamBrowser.Browser/Handlers/JavaScriptDialogHandler.cs
new file mode 100644
index 0000000..0b902c6
--- /dev/null
+++ b/SafeExamBrowser.Browser/Handlers/JavaScriptDialogHandler.cs
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System.Threading.Tasks;
+using CefSharp;
+using SafeExamBrowser.Browser.Events;
+
+namespace SafeExamBrowser.Browser.Handlers
+{
+ internal class JavaScriptDialogHandler : IJsDialogHandler
+ {
+ internal event JavaScriptDialogRequestedEventHandler DialogRequested;
+
+ public bool OnBeforeUnloadDialog(IWebBrowser webBrowser, IBrowser browser, string message, bool isReload, IJsDialogCallback callback)
+ {
+ var args = new JavaScriptDialogRequestedEventArgs
+ {
+ Type = isReload ? JavaScriptDialogType.Reload : JavaScriptDialogType.LeavePage
+ };
+
+ Task.Run(() =>
+ {
+ DialogRequested?.Invoke(args);
+
+ using (callback)
+ {
+ callback.Continue(args.Success);
+ }
+ });
+
+ return true;
+ }
+
+ public void OnDialogClosed(IWebBrowser webBrowser, IBrowser browser)
+ {
+ }
+
+ public bool OnJSDialog(IWebBrowser webBrowser, IBrowser browser, string originUrl, CefJsDialogType type, string message, string promptText, IJsDialogCallback callback, ref bool suppress)
+ {
+ return false;
+ }
+
+ public void OnResetDialogState(IWebBrowser webBrowser, IBrowser browser)
+ {
+ }
+ }
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/BeforeUnloadDialogEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/BeforeUnloadDialogEventHandler.cs
new file mode 100644
index 0000000..95ae1c7
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/BeforeUnloadDialogEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void BeforeUnloadDialogEventHandler(IWebBrowser webBrowser, IBrowser browser, string message, bool isReload, IJsDialogCallback callback, GenericEventArgs args);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/DialogClosedEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/DialogClosedEventHandler.cs
new file mode 100644
index 0000000..6a9ccc5
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/DialogClosedEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void DialogClosedEventHandler(IWebBrowser webBrowser, IBrowser browser);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/DragEnterEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/DragEnterEventHandler.cs
new file mode 100644
index 0000000..7d83f7b
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/DragEnterEventHandler.cs
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+using CefSharp.Enums;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void DragEnterEventHandler(IWebBrowser webBrowser, IBrowser browser, IDragData dragData, DragOperationsMask mask, GenericEventArgs args);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/DraggableRegionsChangedEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/DraggableRegionsChangedEventHandler.cs
new file mode 100644
index 0000000..021019e
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/DraggableRegionsChangedEventHandler.cs
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System.Collections.Generic;
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void DraggableRegionsChangedEventHandler(IWebBrowser webBrowser, IBrowser browser, IFrame frame, IList regions);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/GotFocusEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/GotFocusEventHandler.cs
new file mode 100644
index 0000000..ea253a5
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/GotFocusEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void GotFocusEventHandler(IWebBrowser webBrowser, IBrowser browser);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/JavaScriptDialogEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/JavaScriptDialogEventHandler.cs
new file mode 100644
index 0000000..db7c729
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/JavaScriptDialogEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void JavaScriptDialogEventHandler(IWebBrowser webBrowser, IBrowser browser, string originUrl, CefJsDialogType type, string message, string promptText, IJsDialogCallback callback, ref bool suppress, GenericEventArgs args);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/ResetDialogStateEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/ResetDialogStateEventHandler.cs
new file mode 100644
index 0000000..7eb4c14
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/ResetDialogStateEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void ResetDialogStateEventHandler(IWebBrowser webBrowser, IBrowser browser);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/SetFocusEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/SetFocusEventHandler.cs
new file mode 100644
index 0000000..2c5c5f2
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/SetFocusEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void SetFocusEventHandler(IWebBrowser webBrowser, IBrowser browser, CefFocusSource source, GenericEventArgs args);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Events/TakeFocusEventHandler.cs b/SafeExamBrowser.Browser/Wrapper/Events/TakeFocusEventHandler.cs
new file mode 100644
index 0000000..2c5b4d8
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Events/TakeFocusEventHandler.cs
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+
+namespace SafeExamBrowser.Browser.Wrapper.Events
+{
+ internal delegate void TakeFocusEventHandler(IWebBrowser chromiumWebBrowser, IBrowser browser, bool next);
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Handlers/DragHandlerSwitch.cs b/SafeExamBrowser.Browser/Wrapper/Handlers/DragHandlerSwitch.cs
new file mode 100644
index 0000000..c9cc65f
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Handlers/DragHandlerSwitch.cs
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System.Collections.Generic;
+using CefSharp;
+using CefSharp.Enums;
+using CefSharp.WinForms;
+using CefSharp.WinForms.Host;
+using SafeExamBrowser.Browser.Wrapper.Events;
+
+namespace SafeExamBrowser.Browser.Wrapper.Handlers
+{
+ internal class DragHandlerSwitch : IDragHandler
+ {
+ public bool OnDragEnter(IWebBrowser webBrowser, IBrowser browser, IDragData dragData, DragOperationsMask mask)
+ {
+ var args = new GenericEventArgs();
+
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnDragEnter(webBrowser, browser, dragData, mask, args);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnDragEnter(webBrowser, browser, dragData, mask, args);
+ }
+
+ return args.Value;
+ }
+
+ public void OnDraggableRegionsChanged(IWebBrowser webBrowser, IBrowser browser, IFrame frame, IList regions)
+ {
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnDraggableRegionsChanged(webBrowser, browser, frame, regions);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnDraggableRegionsChanged(webBrowser, browser, frame, regions);
+ }
+ }
+ }
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Handlers/FocusHandlerSwitch.cs b/SafeExamBrowser.Browser/Wrapper/Handlers/FocusHandlerSwitch.cs
new file mode 100644
index 0000000..7f18ebc
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Handlers/FocusHandlerSwitch.cs
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+using CefSharp.WinForms;
+using CefSharp.WinForms.Host;
+using SafeExamBrowser.Browser.Wrapper.Events;
+
+namespace SafeExamBrowser.Browser.Wrapper.Handlers
+{
+ internal class FocusHandlerSwitch : IFocusHandler
+ {
+ public void OnGotFocus(IWebBrowser webBrowser, IBrowser browser)
+ {
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnGotFocus(webBrowser, browser);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnGotFocus(webBrowser, browser);
+ }
+ }
+
+ public bool OnSetFocus(IWebBrowser webBrowser, IBrowser browser, CefFocusSource source)
+ {
+ var args = new GenericEventArgs { Value = false };
+
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnSetFocus(webBrowser, browser, source, args);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnSetFocus(webBrowser, browser, source, args);
+ }
+
+ return args.Value;
+ }
+
+ public void OnTakeFocus(IWebBrowser webBrowser, IBrowser browser, bool next)
+ {
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnTakeFocus(webBrowser, browser, next);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnTakeFocus(webBrowser, browser, next);
+ }
+ }
+ }
+}
diff --git a/SafeExamBrowser.Browser/Wrapper/Handlers/JavaScriptDialogHandlerSwitch.cs b/SafeExamBrowser.Browser/Wrapper/Handlers/JavaScriptDialogHandlerSwitch.cs
new file mode 100644
index 0000000..ca2e7cc
--- /dev/null
+++ b/SafeExamBrowser.Browser/Wrapper/Handlers/JavaScriptDialogHandlerSwitch.cs
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using CefSharp;
+using CefSharp.WinForms;
+using CefSharp.WinForms.Host;
+using SafeExamBrowser.Browser.Wrapper.Events;
+
+namespace SafeExamBrowser.Browser.Wrapper.Handlers
+{
+ internal class JavaScriptDialogHandlerSwitch : IJsDialogHandler
+ {
+ public bool OnBeforeUnloadDialog(IWebBrowser webBrowser, IBrowser browser, string message, bool isReload, IJsDialogCallback callback)
+ {
+ var args = new GenericEventArgs { Value = false };
+
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnBeforeUnloadDialog(webBrowser, browser, message, isReload, callback, args);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnBeforeUnloadDialog(webBrowser, browser, message, isReload, callback, args);
+ }
+
+ return args.Value;
+ }
+
+ public void OnDialogClosed(IWebBrowser webBrowser, IBrowser browser)
+ {
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnDialogClosed(webBrowser, browser);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnDialogClosed(webBrowser, browser);
+ }
+ }
+
+ public bool OnJSDialog(IWebBrowser webBrowser, IBrowser browser, string originUrl, CefJsDialogType type, string message, string promptText, IJsDialogCallback callback, ref bool suppress)
+ {
+ var args = new GenericEventArgs { Value = false };
+
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnJavaScriptDialog(webBrowser, browser, originUrl, type, message, promptText, callback, ref suppress, args);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnJavaScriptDialog(webBrowser, browser, originUrl, type, message, promptText, callback, ref suppress, args);
+ }
+
+ return args.Value;
+ }
+
+ public void OnResetDialogState(IWebBrowser webBrowser, IBrowser browser)
+ {
+ if (browser.IsPopup)
+ {
+ var control = ChromiumHostControl.FromBrowser(browser) as CefSharpPopupControl;
+
+ control?.OnResetDialogState(webBrowser, browser);
+ }
+ else
+ {
+ var control = ChromiumWebBrowser.FromBrowser(browser) as CefSharpBrowserControl;
+
+ control?.OnResetDialogState(webBrowser, browser);
+ }
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml b/SafeExamBrowser.Client.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml
new file mode 100644
index 0000000..a42d7f0
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/ILLink/ILLink.Descriptors.LibraryBuild.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/ApplicationResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/ApplicationResponsibilityTests.cs
new file mode 100644
index 0000000..e839c87
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/ApplicationResponsibilityTests.cs
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Applications.Contracts;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Logging.Contracts;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class ApplicationResponsibilityTests
+ {
+ private ClientContext context;
+ private ApplicationsResponsibility sut;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var logger = new Mock();
+
+ context = new ClientContext();
+ sut = new ApplicationsResponsibility(context, logger.Object);
+ }
+
+ [TestMethod]
+ public void MustAutoStartApplications()
+ {
+ var application1 = new Mock>();
+ var application2 = new Mock>();
+ var application3 = new Mock>();
+
+ application1.SetupGet(a => a.AutoStart).Returns(true);
+ application2.SetupGet(a => a.AutoStart).Returns(false);
+ application3.SetupGet(a => a.AutoStart).Returns(true);
+ context.Applications.Add(application1.Object);
+ context.Applications.Add(application2.Object);
+ context.Applications.Add(application3.Object);
+
+ sut.Assume(ClientTask.AutoStartApplications);
+
+ application1.Verify(a => a.Start(), Times.Once);
+ application2.Verify(a => a.Start(), Times.Never);
+ application3.Verify(a => a.Start(), Times.Once);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/BrowserResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/BrowserResponsibilityTests.cs
new file mode 100644
index 0000000..9f4f2ef
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/BrowserResponsibilityTests.cs
@@ -0,0 +1,350 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Browser.Contracts;
+using SafeExamBrowser.Browser.Contracts.Events;
+using SafeExamBrowser.Client.Contracts;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Communication.Contracts.Proxies;
+using SafeExamBrowser.Configuration.Contracts;
+using SafeExamBrowser.Core.Contracts.ResponsibilityModel;
+using SafeExamBrowser.I18n.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.Server.Contracts;
+using SafeExamBrowser.Server.Contracts.Data;
+using SafeExamBrowser.Settings;
+using SafeExamBrowser.UserInterface.Contracts.MessageBox;
+using SafeExamBrowser.UserInterface.Contracts.Shell;
+using SafeExamBrowser.UserInterface.Contracts.Windows;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class BrowserResponsibilityTests
+ {
+ private AppConfig appConfig;
+ private Mock browser;
+ private ClientContext context;
+ private Mock coordinator;
+ private Mock messageBox;
+ private Mock runtime;
+ private Mock server;
+ private AppSettings settings;
+ private Mock splashScreen;
+ private Mock taskbar;
+
+ private BrowserResponsibility sut;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var logger = new Mock();
+ var responsibilities = new Mock>();
+
+ appConfig = new AppConfig();
+ browser = new Mock();
+ context = new ClientContext();
+ coordinator = new Mock();
+ messageBox = new Mock();
+ runtime = new Mock();
+ server = new Mock();
+ settings = new AppSettings();
+ splashScreen = new Mock();
+ taskbar = new Mock();
+
+ context.AppConfig = appConfig;
+ context.Browser = browser.Object;
+ context.Responsibilities = responsibilities.Object;
+ context.Runtime = runtime.Object;
+ context.Server = server.Object;
+ context.Settings = settings;
+
+ sut = new BrowserResponsibility(
+ context,
+ coordinator.Object,
+ logger.Object,
+ messageBox.Object,
+ runtime.Object,
+ splashScreen.Object,
+ taskbar.Object);
+
+ sut.Assume(ClientTask.RegisterEvents);
+ }
+
+ [TestMethod]
+ public void MustAutoStartBrowser()
+ {
+ settings.Browser.EnableBrowser = true;
+ browser.SetupGet(b => b.AutoStart).Returns(true);
+
+ sut.Assume(ClientTask.AutoStartApplications);
+
+ browser.Verify(b => b.Start(), Times.Once);
+ browser.Reset();
+ browser.SetupGet(b => b.AutoStart).Returns(false);
+
+ sut.Assume(ClientTask.AutoStartApplications);
+
+ browser.Verify(b => b.Start(), Times.Never);
+ }
+
+ [TestMethod]
+ public void MustNotAutoStartBrowserIfNotEnabled()
+ {
+ settings.Browser.EnableBrowser = false;
+ browser.SetupGet(b => b.AutoStart).Returns(true);
+
+ sut.Assume(ClientTask.AutoStartApplications);
+
+ browser.Verify(b => b.Start(), Times.Never);
+ }
+
+ [TestMethod]
+ public void Browser_MustHandleUserIdentifierDetection()
+ {
+ var counter = 0;
+ var identifier = "abc123";
+
+ settings.SessionMode = SessionMode.Server;
+ server.Setup(s => s.SendUserIdentifier(It.IsAny())).Returns(() => new ServerResponse(++counter == 3));
+
+ browser.Raise(b => b.UserIdentifierDetected += null, identifier);
+
+ server.Verify(s => s.SendUserIdentifier(It.Is(id => id == identifier)), Times.Exactly(3));
+ }
+
+ [TestMethod]
+ public void Browser_MustTerminateIfRequested()
+ {
+ runtime.Setup(p => p.RequestShutdown()).Returns(new CommunicationResult(true));
+
+ browser.Raise(b => b.TerminationRequested += null);
+
+ runtime.Verify(p => p.RequestShutdown(), Times.Once);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustAllowIfNoQuitPasswordSet()
+ {
+ var args = new DownloadEventArgs();
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ coordinator.Setup(c => c.RequestReconfigurationLock()).Returns(true);
+ runtime.Setup(r => r.RequestReconfiguration(It.IsAny(), It.IsAny())).Returns(new CommunicationResult(true));
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, "filepath.seb", args);
+ args.Callback(true, string.Empty);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Once);
+
+ Assert.IsTrue(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustNotAllowWithQuitPasswordAndNoUrl()
+ {
+ var args = new DownloadEventArgs();
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ settings.Security.AllowReconfiguration = true;
+ settings.Security.QuitPasswordHash = "abc123";
+ runtime.Setup(r => r.RequestReconfiguration(It.IsAny(), It.IsAny())).Returns(new CommunicationResult(true));
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, "filepath.seb", args);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Never);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Never);
+
+ Assert.IsFalse(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustNotAllowConcurrentExecution()
+ {
+ var args = new DownloadEventArgs();
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ coordinator.Setup(c => c.RequestReconfigurationLock()).Returns(false);
+ runtime.Setup(r => r.RequestReconfiguration(It.IsAny(), It.IsAny())).Returns(new CommunicationResult(true));
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, "filepath.seb", args);
+ args.Callback?.Invoke(true, string.Empty);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Never);
+
+ Assert.IsFalse(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustAllowIfUrlMatches()
+ {
+ var args = new DownloadEventArgs { Url = "sebs://www.somehost.org/some/path/some_configuration.seb?query=123" };
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ coordinator.Setup(c => c.RequestReconfigurationLock()).Returns(true);
+ settings.Security.AllowReconfiguration = true;
+ settings.Security.QuitPasswordHash = "abc123";
+ settings.Security.ReconfigurationUrl = "sebs://www.somehost.org/some/path/*.seb?query=123";
+ runtime.Setup(r => r.RequestReconfiguration(It.IsAny(), It.IsAny())).Returns(new CommunicationResult(true));
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, "filepath.seb", args);
+ args.Callback(true, string.Empty);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Once);
+
+ Assert.IsTrue(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustDenyIfNotAllowed()
+ {
+ var args = new DownloadEventArgs();
+
+ settings.Security.AllowReconfiguration = false;
+ settings.Security.QuitPasswordHash = "abc123";
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, "filepath.seb", args);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Never);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Never);
+
+ Assert.IsFalse(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustDenyIfUrlDoesNotMatch()
+ {
+ var args = new DownloadEventArgs { Url = "sebs://www.somehost.org/some/path/some_configuration.seb?query=123" };
+
+ settings.Security.AllowReconfiguration = false;
+ settings.Security.QuitPasswordHash = "abc123";
+ settings.Security.ReconfigurationUrl = "sebs://www.somehost.org/some/path/other_configuration.seb?query=123";
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, "filepath.seb", args);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Never);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Never);
+
+ Assert.IsFalse(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustCorrectlyHandleDownload()
+ {
+ var downloadPath = @"C:\Folder\Does\Not\Exist\filepath.seb";
+ var downloadUrl = @"https://www.host.abc/someresource.seb";
+ var filename = "filepath.seb";
+ var args = new DownloadEventArgs();
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ coordinator.Setup(c => c.RequestReconfigurationLock()).Returns(true);
+ messageBox.Setup(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())).Returns(MessageBoxResult.Yes);
+ runtime.Setup(r => r.RequestReconfiguration(
+ It.Is(p => p == downloadPath),
+ It.Is(u => u == downloadUrl))).Returns(new CommunicationResult(true));
+ settings.Security.AllowReconfiguration = true;
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, filename, args);
+ args.Callback(true, downloadUrl, downloadPath);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Never);
+ runtime.Verify(r => r.RequestReconfiguration(It.Is(p => p == downloadPath), It.Is(u => u == downloadUrl)), Times.Once);
+
+ Assert.AreEqual(downloadPath, args.DownloadPath);
+ Assert.IsTrue(args.AllowDownload);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustCorrectlyHandleFailedDownload()
+ {
+ var downloadPath = @"C:\Folder\Does\Not\Exist\filepath.seb";
+ var downloadUrl = @"https://www.host.abc/someresource.seb";
+ var filename = "filepath.seb";
+ var args = new DownloadEventArgs();
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ coordinator.Setup(c => c.RequestReconfigurationLock()).Returns(true);
+ messageBox.Setup(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())).Returns(MessageBoxResult.Yes);
+ runtime.Setup(r => r.RequestReconfiguration(
+ It.Is(p => p == downloadPath),
+ It.Is(u => u == downloadUrl))).Returns(new CommunicationResult(true));
+ settings.Security.AllowReconfiguration = true;
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, filename, args);
+ args.Callback(false, downloadPath);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Once);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public void Reconfiguration_MustCorrectlyHandleFailedRequest()
+ {
+ var downloadPath = @"C:\Folder\Does\Not\Exist\filepath.seb";
+ var downloadUrl = @"https://www.host.abc/someresource.seb";
+ var filename = "filepath.seb";
+ var args = new DownloadEventArgs();
+
+ appConfig.TemporaryDirectory = @"C:\Folder\Does\Not\Exist";
+ coordinator.Setup(c => c.RequestReconfigurationLock()).Returns(true);
+ messageBox.Setup(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())).Returns(MessageBoxResult.Yes);
+ runtime.Setup(r => r.RequestReconfiguration(
+ It.Is(p => p == downloadPath),
+ It.Is(u => u == downloadUrl))).Returns(new CommunicationResult(false));
+ settings.Security.AllowReconfiguration = true;
+
+ browser.Raise(b => b.ConfigurationDownloadRequested += null, filename, args);
+ args.Callback(true, downloadUrl, downloadPath);
+
+ coordinator.Verify(c => c.RequestReconfigurationLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseReconfigurationLock(), Times.Once);
+ messageBox.Verify(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.Is(i => i == MessageBoxIcon.Error),
+ It.IsAny()), Times.Once);
+ runtime.Verify(r => r.RequestReconfiguration(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [TestMethod]
+ public void MustNotFailIfDependencyIsNull()
+ {
+ context.Browser = null;
+ sut.Assume(ClientTask.DeregisterEvents);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/CommunicationResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/CommunicationResponsibilityTests.cs
new file mode 100644
index 0000000..e39c92c
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/CommunicationResponsibilityTests.cs
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System;
+using System.Collections.Generic;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Client.Contracts;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Communication.Contracts.Data;
+using SafeExamBrowser.Communication.Contracts.Events;
+using SafeExamBrowser.Communication.Contracts.Hosts;
+using SafeExamBrowser.Communication.Contracts.Proxies;
+using SafeExamBrowser.I18n.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.Server.Contracts.Data;
+using SafeExamBrowser.UserInterface.Contracts;
+using SafeExamBrowser.UserInterface.Contracts.MessageBox;
+using SafeExamBrowser.UserInterface.Contracts.Windows;
+using SafeExamBrowser.UserInterface.Contracts.Windows.Data;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class CommunicationResponsibilityTests
+ {
+ private Mock clientHost;
+ private ClientContext context;
+ private Mock coordinator;
+ private Mock messageBox;
+ private Mock runtimeProxy;
+ private Mock shutdown;
+ private Mock splashScreen;
+ private Mock text;
+ private Mock uiFactory;
+
+ private CommunicationResponsibility sut;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var logger = new Mock();
+
+ clientHost = new Mock();
+ context = new ClientContext();
+ coordinator = new Mock();
+ messageBox = new Mock();
+ runtimeProxy = new Mock();
+ shutdown = new Mock();
+ splashScreen = new Mock();
+ text = new Mock();
+ uiFactory = new Mock();
+
+ context.ClientHost = clientHost.Object;
+
+ sut = new CommunicationResponsibility(
+ context,
+ coordinator.Object,
+ logger.Object,
+ messageBox.Object,
+ runtimeProxy.Object,
+ shutdown.Object,
+ splashScreen.Object,
+ text.Object,
+ uiFactory.Object);
+
+ sut.Assume(ClientTask.RegisterEvents);
+ }
+
+ [TestMethod]
+ public void Communication_MustCorrectlyHandleExamSelection()
+ {
+ var args = new ExamSelectionRequestEventArgs
+ {
+ Exams = new List<(string id, string lms, string name, string url)> { ("", "", "", "") },
+ RequestId = Guid.NewGuid()
+ };
+ var dialog = new Mock();
+
+ dialog.Setup(d => d.Show(It.IsAny())).Returns(new ExamSelectionDialogResult { Success = true });
+ uiFactory.Setup(f => f.CreateExamSelectionDialog(It.IsAny>())).Returns(dialog.Object);
+
+ clientHost.Raise(c => c.ExamSelectionRequested += null, args);
+
+ runtimeProxy.Verify(p => p.SubmitExamSelectionResult(It.Is(g => g == args.RequestId), true, null), Times.Once);
+ uiFactory.Verify(f => f.CreateExamSelectionDialog(It.IsAny>()), Times.Once);
+ }
+
+ [TestMethod]
+ public void Communication_MustCorrectlyHandleMessageBoxRequest()
+ {
+ var args = new MessageBoxRequestEventArgs
+ {
+ Action = (int) MessageBoxAction.YesNo,
+ Icon = (int) MessageBoxIcon.Question,
+ Message = "Some question to be answered",
+ RequestId = Guid.NewGuid(),
+ Title = "A Title"
+ };
+
+ messageBox.Setup(m => m.Show(
+ It.Is(s => s == args.Message),
+ It.Is(s => s == args.Title),
+ It.Is(a => a == (MessageBoxAction) args.Action),
+ It.Is(i => i == (MessageBoxIcon) args.Icon),
+ It.IsAny())).Returns(MessageBoxResult.No);
+
+ clientHost.Raise(c => c.MessageBoxRequested += null, args);
+
+ runtimeProxy.Verify(p => p.SubmitMessageBoxResult(
+ It.Is(g => g == args.RequestId),
+ It.Is(r => r == (int) MessageBoxResult.No)), Times.Once);
+ }
+
+ [TestMethod]
+ public void Communication_MustCorrectlyHandlePasswordRequest()
+ {
+ var args = new PasswordRequestEventArgs
+ {
+ Purpose = PasswordRequestPurpose.LocalSettings,
+ RequestId = Guid.NewGuid()
+ };
+ var dialog = new Mock();
+ var result = new PasswordDialogResult { Password = "blubb", Success = true };
+
+ dialog.Setup(d => d.Show(It.IsAny())).Returns(result);
+ uiFactory.Setup(f => f.CreatePasswordDialog(It.IsAny(), It.IsAny())).Returns(dialog.Object);
+
+ clientHost.Raise(c => c.PasswordRequested += null, args);
+
+ runtimeProxy.Verify(p => p.SubmitPassword(
+ It.Is(g => g == args.RequestId),
+ It.Is(b => b == result.Success),
+ It.Is(s => s == result.Password)), Times.Once);
+ }
+
+ [TestMethod]
+ public void Communication_MustCorrectlyHandleAbortedReconfiguration()
+ {
+ clientHost.Raise(c => c.ReconfigurationAborted += null);
+
+ splashScreen.Verify(s => s.Hide(), Times.AtLeastOnce);
+ }
+
+ [TestMethod]
+ public void Communication_MustInformUserAboutDeniedReconfiguration()
+ {
+ var args = new ReconfigurationEventArgs
+ {
+ ConfigurationPath = @"C:\Some\File\Path.seb"
+ };
+
+ clientHost.Raise(c => c.ReconfigurationDenied += null, args);
+
+ messageBox.Verify(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Once);
+ }
+
+ [TestMethod]
+ public void Communication_MustCorrectlyHandleServerCommunicationFailure()
+ {
+ var args = new ServerFailureActionRequestEventArgs { RequestId = Guid.NewGuid() };
+ var dialog = new Mock();
+
+ dialog.Setup(d => d.Show(It.IsAny())).Returns(new ServerFailureDialogResult());
+ uiFactory.Setup(f => f.CreateServerFailureDialog(It.IsAny(), It.IsAny())).Returns(dialog.Object);
+
+ clientHost.Raise(c => c.ServerFailureActionRequested += null, args);
+
+ runtimeProxy.Verify(r => r.SubmitServerFailureActionResult(It.Is(g => g == args.RequestId), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ uiFactory.Verify(f => f.CreateServerFailureDialog(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [TestMethod]
+ public void Communication_MustCorrectlyInitiateShutdown()
+ {
+ clientHost.Raise(c => c.Shutdown += null);
+
+ shutdown.Verify(s => s(), Times.Once);
+ }
+
+ [TestMethod]
+ public void Communication_MustShutdownOnLostConnection()
+ {
+ runtimeProxy.Raise(p => p.ConnectionLost += null);
+
+ messageBox.Verify(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Once);
+ shutdown.Verify(s => s(), Times.Once);
+ }
+
+ [TestMethod]
+ public void MustNotFailIfDependencyIsNull()
+ {
+ context.ClientHost = null;
+ sut.Assume(ClientTask.DeregisterEvents);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/IntegrityResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/IntegrityResponsibilityTests.cs
new file mode 100644
index 0000000..35f03d0
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/IntegrityResponsibilityTests.cs
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Configuration.Contracts.Integrity;
+using SafeExamBrowser.I18n.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class IntegrityResponsibilityTests
+ {
+ private Mock text;
+ private Mock integrityModule;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var context = new ClientContext();
+ var logger = new Mock();
+ var valid = true;
+
+ text = new Mock();
+ integrityModule = new Mock();
+
+ integrityModule.Setup(m => m.TryVerifySessionIntegrity(It.IsAny(), It.IsAny(), out valid)).Returns(true);
+
+ var sut = new IntegrityResponsibility(context, logger.Object, text.Object);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/MonitoringResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/MonitoringResponsibilityTests.cs
new file mode 100644
index 0000000..3b6e5d5
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/MonitoringResponsibilityTests.cs
@@ -0,0 +1,414 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Client.Contracts;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Communication.Contracts.Proxies;
+using SafeExamBrowser.Configuration.Contracts.Cryptography;
+using SafeExamBrowser.Core.Contracts.ResponsibilityModel;
+using SafeExamBrowser.I18n.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.Monitoring.Contracts.Applications;
+using SafeExamBrowser.Monitoring.Contracts.Display;
+using SafeExamBrowser.Monitoring.Contracts.System;
+using SafeExamBrowser.Settings;
+using SafeExamBrowser.Settings.Monitoring;
+using SafeExamBrowser.Settings.UserInterface;
+using SafeExamBrowser.UserInterface.Contracts;
+using SafeExamBrowser.UserInterface.Contracts.MessageBox;
+using SafeExamBrowser.UserInterface.Contracts.Shell;
+using SafeExamBrowser.UserInterface.Contracts.Windows;
+using SafeExamBrowser.UserInterface.Contracts.Windows.Data;
+using SafeExamBrowser.WindowsApi.Contracts;
+using IWindow = SafeExamBrowser.UserInterface.Contracts.Windows.IWindow;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class MonitoringResponsibilityTests
+ {
+ private Mock actionCenter;
+ private Mock applicationMonitor;
+ private ClientContext context;
+ private Mock coordinator;
+ private Mock displayMonitor;
+ private Mock explorerShell;
+ private Mock hashAlgorithm;
+ private Mock messageBox;
+ private Mock runtime;
+ private Mock sentinel;
+ private AppSettings settings;
+ private Mock taskbar;
+ private Mock text;
+ private Mock uiFactory;
+
+ private MonitoringResponsibility sut;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var logger = new Mock();
+ var responsibilities = new Mock>();
+
+ actionCenter = new Mock();
+ applicationMonitor = new Mock();
+ context = new ClientContext();
+ coordinator = new Mock();
+ displayMonitor = new Mock();
+ explorerShell = new Mock();
+ hashAlgorithm = new Mock();
+ messageBox = new Mock();
+ runtime = new Mock();
+ sentinel = new Mock();
+ settings = new AppSettings();
+ taskbar = new Mock();
+ text = new Mock();
+ uiFactory = new Mock();
+
+ context.HashAlgorithm = hashAlgorithm.Object;
+ context.MessageBox = messageBox.Object;
+ context.Responsibilities = responsibilities.Object;
+ context.Runtime = runtime.Object;
+ context.Settings = settings;
+ context.UserInterfaceFactory = uiFactory.Object;
+
+ sut = new MonitoringResponsibility(
+ actionCenter.Object,
+ applicationMonitor.Object,
+ context,
+ coordinator.Object,
+ displayMonitor.Object,
+ explorerShell.Object,
+ logger.Object,
+ sentinel.Object,
+ taskbar.Object,
+ text.Object);
+
+ sut.Assume(ClientTask.RegisterEvents);
+ sut.Assume(ClientTask.StartMonitoring);
+ }
+
+ [TestMethod]
+ public void ApplicationMonitor_MustCorrectlyHandleExplorerStartWithTaskbar()
+ {
+ var boundsActionCenter = 0;
+ var boundsTaskbar = 0;
+ var height = 30;
+ var order = 0;
+ var shell = 0;
+ var workingArea = 0;
+
+ settings.UserInterface.Taskbar.EnableTaskbar = true;
+
+ actionCenter.Setup(a => a.InitializeBounds()).Callback(() => boundsActionCenter = ++order);
+ explorerShell.Setup(e => e.Terminate()).Callback(() => shell = ++order);
+ displayMonitor.Setup(w => w.InitializePrimaryDisplay(It.Is(h => h == height))).Callback(() => workingArea = ++order);
+ taskbar.Setup(t => t.GetAbsoluteHeight()).Returns(height);
+ taskbar.Setup(t => t.InitializeBounds()).Callback(() => boundsTaskbar = ++order);
+
+ applicationMonitor.Raise(a => a.ExplorerStarted += null);
+
+ actionCenter.Verify(a => a.InitializeBounds(), Times.Once);
+ explorerShell.Verify(e => e.Terminate(), Times.Once);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == 0)), Times.Never);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == height)), Times.Once);
+ taskbar.Verify(t => t.InitializeBounds(), Times.Once);
+ taskbar.Verify(t => t.GetAbsoluteHeight(), Times.Once);
+
+ Assert.IsTrue(shell == 1);
+ Assert.IsTrue(workingArea == 2);
+ Assert.IsTrue(boundsActionCenter == 3);
+ Assert.IsTrue(boundsTaskbar == 4);
+ }
+
+ [TestMethod]
+ public void ApplicationMonitor_MustCorrectlyHandleExplorerStartWithoutTaskbar()
+ {
+ var boundsActionCenter = 0;
+ var boundsTaskbar = 0;
+ var height = 30;
+ var order = 0;
+ var shell = 0;
+ var workingArea = 0;
+
+ settings.UserInterface.Taskbar.EnableTaskbar = false;
+
+ actionCenter.Setup(a => a.InitializeBounds()).Callback(() => boundsActionCenter = ++order);
+ explorerShell.Setup(e => e.Terminate()).Callback(() => shell = ++order);
+ displayMonitor.Setup(w => w.InitializePrimaryDisplay(It.Is(h => h == 0))).Callback(() => workingArea = ++order);
+ taskbar.Setup(t => t.GetAbsoluteHeight()).Returns(height);
+ taskbar.Setup(t => t.InitializeBounds()).Callback(() => boundsTaskbar = ++order);
+
+ applicationMonitor.Raise(a => a.ExplorerStarted += null);
+
+ actionCenter.Verify(a => a.InitializeBounds(), Times.Once);
+ explorerShell.Verify(e => e.Terminate(), Times.Once);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == 0)), Times.Once);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == height)), Times.Never);
+ taskbar.Verify(t => t.InitializeBounds(), Times.Once);
+ taskbar.Verify(t => t.GetAbsoluteHeight(), Times.Never);
+
+ Assert.IsTrue(shell == 1);
+ Assert.IsTrue(workingArea == 2);
+ Assert.IsTrue(boundsActionCenter == 3);
+ Assert.IsTrue(boundsTaskbar == 4);
+ }
+
+ [TestMethod]
+ public void ApplicationMonitor_MustPermitApplicationIfChosenByUserAfterFailedTermination()
+ {
+ var lockScreen = new Mock();
+ var result = new LockScreenResult();
+
+ lockScreen.Setup(l => l.WaitForResult()).Returns(result);
+ runtime.Setup(p => p.RequestShutdown()).Returns(new CommunicationResult(true));
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object)
+ .Callback, LockScreenSettings>((m, t, o, s) => result.OptionId = o.First().Id);
+
+ applicationMonitor.Raise(m => m.TerminationFailed += null, new List());
+
+ runtime.Verify(p => p.RequestShutdown(), Times.Never);
+ }
+
+ [TestMethod]
+ public void ApplicationMonitor_MustRequestShutdownIfChosenByUserAfterFailedTermination()
+ {
+ var lockScreen = new Mock();
+ var result = new LockScreenResult();
+
+ lockScreen.Setup(l => l.WaitForResult()).Returns(result);
+ runtime.Setup(p => p.RequestShutdown()).Returns(new CommunicationResult(true));
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object)
+ .Callback, LockScreenSettings>((m, t, o, s) => result.OptionId = o.Last().Id);
+
+ applicationMonitor.Raise(m => m.TerminationFailed += null, new List());
+
+ runtime.Verify(p => p.RequestShutdown(), Times.Once);
+ }
+
+ [TestMethod]
+ public void ApplicationMonitor_MustShowLockScreenIfTerminationFailed()
+ {
+ var activator1 = new Mock();
+ var activator2 = new Mock();
+ var activator3 = new Mock();
+ var lockScreen = new Mock();
+ var result = new LockScreenResult();
+ var order = 0;
+ var pause = 0;
+ var show = 0;
+ var wait = 0;
+ var close = 0;
+ var resume = 0;
+
+ activator1.Setup(a => a.Pause()).Callback(() => pause = ++order);
+ activator1.Setup(a => a.Resume()).Callback(() => resume = ++order);
+ context.Activators.Add(activator1.Object);
+ context.Activators.Add(activator2.Object);
+ context.Activators.Add(activator3.Object);
+ lockScreen.Setup(l => l.Show()).Callback(() => show = ++order);
+ lockScreen.Setup(l => l.WaitForResult()).Callback(() => wait = ++order).Returns(result);
+ lockScreen.Setup(l => l.Close()).Callback(() => close = ++order);
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object);
+
+ applicationMonitor.Raise(m => m.TerminationFailed += null, new List());
+
+ activator1.Verify(a => a.Pause(), Times.Once);
+ activator1.Verify(a => a.Resume(), Times.Once);
+ activator2.Verify(a => a.Pause(), Times.Once);
+ activator2.Verify(a => a.Resume(), Times.Once);
+ activator3.Verify(a => a.Pause(), Times.Once);
+ activator3.Verify(a => a.Resume(), Times.Once);
+ lockScreen.Verify(l => l.Show(), Times.Once);
+ lockScreen.Verify(l => l.WaitForResult(), Times.Once);
+ lockScreen.Verify(l => l.Close(), Times.Once);
+
+ Assert.IsTrue(pause == 1);
+ Assert.IsTrue(show == 2);
+ Assert.IsTrue(wait == 3);
+ Assert.IsTrue(close == 4);
+ Assert.IsTrue(resume == 5);
+ }
+
+ [TestMethod]
+ public void ApplicationMonitor_MustValidateQuitPasswordIfTerminationFailed()
+ {
+ var hash = "12345";
+ var lockScreen = new Mock();
+ var result = new LockScreenResult { Password = "test" };
+ var attempt = 0;
+ var correct = new Random().Next(1, 50);
+ var lockScreenResult = new Func(() => ++attempt == correct ? result : new LockScreenResult());
+
+ context.Settings.Security.QuitPasswordHash = hash;
+ hashAlgorithm.Setup(a => a.GenerateHashFor(It.Is(p => p == result.Password))).Returns(hash);
+ lockScreen.Setup(l => l.WaitForResult()).Returns(lockScreenResult);
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object);
+
+ applicationMonitor.Raise(m => m.TerminationFailed += null, new List());
+
+ hashAlgorithm.Verify(a => a.GenerateHashFor(It.Is(p => p == result.Password)), Times.Once);
+ hashAlgorithm.Verify(a => a.GenerateHashFor(It.Is(p => p != result.Password)), Times.Exactly(attempt - 1));
+ messageBox.Verify(m => m.Show(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.Is(w => w == lockScreen.Object)), Times.Exactly(attempt - 1));
+ }
+
+ [TestMethod]
+ public void DisplayMonitor_MustCorrectlyHandleDisplayChangeWithTaskbar()
+ {
+ var boundsActionCenter = 0;
+ var boundsTaskbar = 0;
+ var height = 25;
+ var order = 0;
+ var workingArea = 0;
+
+ settings.UserInterface.Taskbar.EnableTaskbar = true;
+
+ actionCenter.Setup(t => t.InitializeBounds()).Callback(() => boundsActionCenter = ++order);
+ displayMonitor.Setup(m => m.InitializePrimaryDisplay(It.Is(h => h == height))).Callback(() => workingArea = ++order);
+ displayMonitor.Setup(m => m.ValidateConfiguration(It.IsAny())).Returns(new ValidationResult { IsAllowed = true });
+ taskbar.Setup(t => t.GetAbsoluteHeight()).Returns(height);
+ taskbar.Setup(t => t.InitializeBounds()).Callback(() => boundsTaskbar = ++order);
+
+ displayMonitor.Raise(d => d.DisplayChanged += null);
+
+ actionCenter.Verify(a => a.InitializeBounds(), Times.Once);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == 0)), Times.Never);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == height)), Times.Once);
+ taskbar.Verify(t => t.GetAbsoluteHeight(), Times.Once);
+ taskbar.Verify(t => t.InitializeBounds(), Times.Once);
+
+ Assert.IsTrue(workingArea == 1);
+ Assert.IsTrue(boundsActionCenter == 2);
+ Assert.IsTrue(boundsTaskbar == 3);
+ }
+
+ [TestMethod]
+ public void DisplayMonitor_MustCorrectlyHandleDisplayChangeWithoutTaskbar()
+ {
+ var boundsActionCenter = 0;
+ var boundsTaskbar = 0;
+ var height = 25;
+ var order = 0;
+ var workingArea = 0;
+
+ settings.UserInterface.Taskbar.EnableTaskbar = false;
+
+ actionCenter.Setup(t => t.InitializeBounds()).Callback(() => boundsActionCenter = ++order);
+ displayMonitor.Setup(w => w.InitializePrimaryDisplay(It.Is(h => h == 0))).Callback(() => workingArea = ++order);
+ displayMonitor.Setup(m => m.ValidateConfiguration(It.IsAny())).Returns(new ValidationResult { IsAllowed = true });
+ taskbar.Setup(t => t.GetAbsoluteHeight()).Returns(height);
+ taskbar.Setup(t => t.InitializeBounds()).Callback(() => boundsTaskbar = ++order);
+
+ displayMonitor.Raise(d => d.DisplayChanged += null);
+
+ actionCenter.Verify(a => a.InitializeBounds(), Times.Once);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == 0)), Times.Once);
+ displayMonitor.Verify(d => d.InitializePrimaryDisplay(It.Is(h => h == height)), Times.Never);
+ taskbar.Verify(t => t.GetAbsoluteHeight(), Times.Never);
+ taskbar.Verify(t => t.InitializeBounds(), Times.Once);
+
+ Assert.IsTrue(workingArea == 1);
+ Assert.IsTrue(boundsActionCenter == 2);
+ Assert.IsTrue(boundsTaskbar == 3);
+ }
+
+ [TestMethod]
+ public void DisplayMonitor_MustShowLockScreenOnDisplayChange()
+ {
+ var lockScreen = new Mock();
+
+ displayMonitor.Setup(m => m.ValidateConfiguration(It.IsAny())).Returns(new ValidationResult { IsAllowed = false });
+ lockScreen.Setup(l => l.WaitForResult()).Returns(new LockScreenResult());
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object);
+
+ displayMonitor.Raise(d => d.DisplayChanged += null);
+
+ lockScreen.Verify(l => l.Show(), Times.Once);
+ }
+
+ [TestMethod]
+ public void SystemMonitor_MustShowLockScreenOnSessionSwitch()
+ {
+ var lockScreen = new Mock();
+
+ coordinator.Setup(c => c.RequestSessionLock()).Returns(true);
+ lockScreen.Setup(l => l.WaitForResult()).Returns(new LockScreenResult());
+ settings.Service.IgnoreService = true;
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object);
+
+ sentinel.Raise(s => s.SessionChanged += null);
+
+ coordinator.Verify(c => c.RequestSessionLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseSessionLock(), Times.Once);
+ lockScreen.Verify(l => l.Show(), Times.Once);
+ }
+
+ [TestMethod]
+ public void SystemMonitor_MustTerminateIfRequestedByUser()
+ {
+ var lockScreen = new Mock();
+ var result = new LockScreenResult();
+
+ coordinator.Setup(c => c.RequestSessionLock()).Returns(true);
+ lockScreen.Setup(l => l.WaitForResult()).Returns(result);
+ runtime.Setup(r => r.RequestShutdown()).Returns(new CommunicationResult(true));
+ settings.Service.IgnoreService = true;
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Callback(new Action, LockScreenSettings>((message, title, options, settings) => result.OptionId = options.Last().Id))
+ .Returns(lockScreen.Object);
+
+ sentinel.Raise(s => s.SessionChanged += null);
+
+ coordinator.Verify(c => c.RequestSessionLock(), Times.Once);
+ coordinator.Verify(c => c.ReleaseSessionLock(), Times.Once);
+ lockScreen.Verify(l => l.Show(), Times.Once);
+ runtime.Verify(p => p.RequestShutdown(), Times.Once);
+ }
+
+ [TestMethod]
+ public void SystemMonitor_MustDoNothingIfSessionSwitchAllowed()
+ {
+ var lockScreen = new Mock();
+
+ settings.Service.IgnoreService = false;
+ settings.Service.DisableUserLock = false;
+ settings.Service.DisableUserSwitch = false;
+ lockScreen.Setup(l => l.WaitForResult()).Returns(new LockScreenResult());
+ uiFactory
+ .Setup(f => f.CreateLockScreen(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .Returns(lockScreen.Object);
+
+ sentinel.Raise(s => s.SessionChanged += null);
+
+ lockScreen.Verify(l => l.Show(), Times.Never);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/NetworkResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/NetworkResponsibilityTests.cs
new file mode 100644
index 0000000..bcea339
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/NetworkResponsibilityTests.cs
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.I18n.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.SystemComponents.Contracts.Network;
+using SafeExamBrowser.UserInterface.Contracts;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class NetworkResponsibilityTests
+ {
+ private Mock networkAdapter;
+ private Mock text;
+ private Mock uiFactory;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var context = new ClientContext();
+ var logger = new Mock();
+
+ networkAdapter = new Mock();
+ text = new Mock();
+ uiFactory = new Mock();
+
+ var sut = new NetworkResponsibility(context, logger.Object, networkAdapter.Object, text.Object, uiFactory.Object);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/ProctoringResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/ProctoringResponsibilityTests.cs
new file mode 100644
index 0000000..7d2935c
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/ProctoringResponsibilityTests.cs
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.UserInterface.Contracts;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class ProctoringResponsibilityTests
+ {
+ private ClientContext context;
+ private Mock uiFactory;
+
+ private ProctoringResponsibility sut;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ var logger = new Mock();
+
+ context = new ClientContext();
+ logger = new Mock();
+ uiFactory = new Mock();
+
+ sut = new ProctoringResponsibility(context, logger.Object, uiFactory.Object);
+ }
+
+ [TestMethod]
+ public void MustNotFailIfDependencyIsNull()
+ {
+ context.Proctoring = null;
+ sut.Assume(ClientTask.PrepareShutdown_Wave1);
+ }
+ }
+}
diff --git a/SafeExamBrowser.Client.UnitTests/Responsibilities/ServerResponsibilityTests.cs b/SafeExamBrowser.Client.UnitTests/Responsibilities/ServerResponsibilityTests.cs
new file mode 100644
index 0000000..b5cc594
--- /dev/null
+++ b/SafeExamBrowser.Client.UnitTests/Responsibilities/ServerResponsibilityTests.cs
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2025 ETH Zürich, IT Services
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using SafeExamBrowser.Client.Contracts;
+using SafeExamBrowser.Client.Responsibilities;
+using SafeExamBrowser.Communication.Contracts.Proxies;
+using SafeExamBrowser.Core.Contracts.ResponsibilityModel;
+using SafeExamBrowser.I18n.Contracts;
+using SafeExamBrowser.Logging.Contracts;
+using SafeExamBrowser.Server.Contracts;
+
+namespace SafeExamBrowser.Client.UnitTests.Responsibilities
+{
+ [TestClass]
+ public class ServerResponsibilityTests
+ {
+ private ClientContext context;
+ private Mock coordinator;
+ private Mock runtime;
+ private Mock server;
+ private Mock