From a1c53b63a24a62d4c6b28028af3e6df58aadb8bf Mon Sep 17 00:00:00 2001 From: Kevin Snyder Date: Thu, 1 Jan 2026 12:51:51 -0500 Subject: [PATCH 1/2] Added User Login to access pages /cast and /cast_base --- .../Execution_UI/LOGIN_IMPLEMENTATION.md | 161 ++++ Execution_UI/Execution_UI/Pages/cast.cshtml | 677 +++++++------ .../Execution_UI/Pages/cast.cshtml.cs | 50 +- .../Execution_UI/Pages/cast_base.cshtml | 909 ++++++++++-------- .../Execution_UI/Pages/cast_base.cshtml.cs | 50 +- Execution_UI/Execution_UI/Program.cs | 10 + .../Execution_UI/Properties/user.properties | 6 + .../Services/AuthenticationService.cs | 174 ++++ .../Execution_UI/wwwroot/js/cache-clear.js | 76 ++ 9 files changed, 1418 insertions(+), 695 deletions(-) create mode 100644 Execution_UI/Execution_UI/LOGIN_IMPLEMENTATION.md create mode 100644 Execution_UI/Execution_UI/Properties/user.properties create mode 100644 Execution_UI/Execution_UI/Services/AuthenticationService.cs create mode 100644 Execution_UI/Execution_UI/wwwroot/js/cache-clear.js diff --git a/Execution_UI/Execution_UI/LOGIN_IMPLEMENTATION.md b/Execution_UI/Execution_UI/LOGIN_IMPLEMENTATION.md new file mode 100644 index 00000000..9f43a115 --- /dev/null +++ b/Execution_UI/Execution_UI/LOGIN_IMPLEMENTATION.md @@ -0,0 +1,161 @@ +# CAST Execution UI - Login Implementation Summary + +## Overview +A complete login system with Basic Authentication has been added to the CAST Execution UI application. The login process can be controlled via the `enable_login` boolean parameter in `appsettings.json`. + +## Changes Made + +### 1. **New Service: AuthenticationService** +- **File**: `Services/AuthenticationService.cs` +- **Purpose**: Handles all authentication logic including: + - Loading user credentials from `Properties/user.properties` + - Validating credentials against stored values + - Managing authentication tokens in session + - Clearing authentication data when users logout + +**Key Methods**: +- `ValidateCredentials(username, password)` - Validates user credentials +- `ValidateBasicAuth(request)` - Validates Basic Authentication headers +- `SetAuthenticationToken(token)` - Stores auth token in session +- `IsAuthenticated()` - Checks if user has active session +- `ClearAuthentication()` - Clears all auth data and session + +### 2. **Configuration Update** +- **File**: `appsettings.json` +- **Change**: Added `"enable_login": true` to AppSettings section +- **Purpose**: Toggle login feature on/off without code changes + +### 3. **Credentials File** +- **File**: `Properties/user.properties` +- **Format**: `username=password` (one per line) +- **Sample Credentials**: + - `admin=admin@123` + - `user=user@123` + +### 4. **Program Configuration** +- **File**: `Program.cs` +- **Changes**: + - Added `AddHttpContextAccessor()` for HTTP context access + - Added session support with `AddSession()` + - Registered `AuthenticationService` as scoped dependency + - Added `UseSession()` middleware + +### 5. **Pages/cast.cshtml.cs Updates** +- Added `AuthenticationService` dependency injection +- Added authentication properties: `IsLoginRequired`, `IsAuthenticated`, `AuthenticationError` +- Updated `OnGet()` method to check authentication before displaying content +- Added `OnPostLogin()` handler for login form submission +- Added `OnPostLogout()` handler for logout functionality + +### 6. **Pages/cast_base.cshtml.cs Updates** +- Same changes as cast.cshtml.cs +- Authentication check in `OnGet()` +- Login/logout handlers + +### 7. **UI Updates - cast.cshtml** +- Added conditional login page display when `IsLoginRequired && !IsAuthenticated` +- Login form with username and password fields +- Error message display for invalid credentials +- Logout button in header when login is enabled +- Styled login modal with gradient background + +### 8. **UI Updates - cast_base.cshtml** +- Added CSS styles for login interface +- Added conditional login page display +- Login form with username and password fields +- Logout button in navigation +- Responsive design matching the application theme + +### 9. **Cache Clearing - JavaScript** +- **File**: `wwwroot/js/cache-clear.js` +- **Purpose**: Clears all browser cache when pages are closed +- **Clearing Targets**: + - LocalStorage + - SessionStorage + - Service Workers + - IndexedDB + - Browser cache headers + +**Events Monitored**: +- `beforeunload` - Before page unloads +- `unload` - Page unloading +- `pagehide` - Page hidden (tab closed) +- `visibilitychange` - Tab switching +- `pageshow` - Page shown (back/forward navigation) + +**Included in**: Both `cast.cshtml` and `cast_base.cshtml` + +## Feature Details + +### Login Flow +1. User accesses `/cast` or `/cast_base` +2. If `enable_login` is `true` and user is not authenticated: + - Login page is displayed + - User enters credentials +3. Credentials are validated against `user.properties` +4. On successful login: + - Authentication token is stored in session + - User is redirected to main page +5. User can logout using the logout button +6. On logout or page close: + - All cached credentials are cleared + - Session is cleared + +### Security Features +- Session-based authentication (30-minute timeout) +- Credentials stored in properties file (not in database) +- All cache cleared on logout/page close +- LocalStorage and SessionStorage cleared +- Service workers and IndexedDB cleaned + +## Configuration Options + +### Enable/Disable Login +In `appsettings.json`: +```json +"AppSettings": { + "enable_login": true // Set to false to disable login +} +``` + +### Add New Credentials +Edit `Properties/user.properties`: +``` +username1=password1 +username2=password2 +``` + +## Testing + +### Default Test Credentials +- **Username**: `admin` **Password**: `admin@123` +- **Username**: `user` **Password**: `user@123` + +### Test Scenarios +1. **Enable Login**: Set `enable_login: true` in appsettings.json +2. **Access Pages**: Navigate to `/cast` or `/cast_base` +3. **Invalid Credentials**: Enter wrong username/password - should show error +4. **Valid Login**: Use credentials above - should redirect to main page +5. **Logout**: Click logout button - should clear session and return to login +6. **Cache Clearing**: Close browser/tab - cache should be cleared automatically + +## Files Modified/Created + +### Created: +- `Services/AuthenticationService.cs` +- `Properties/user.properties` +- `wwwroot/js/cache-clear.js` + +### Modified: +- `Program.cs` +- `Pages/cast.cshtml.cs` +- `Pages/cast_base.cshtml.cs` +- `Pages/cast.cshtml` +- `Pages/cast_base.cshtml` +- `appsettings.json` + +## Notes +- Both `cast` and `cast_base` pages use the same authentication system +- Login state is shared across both pages via session +- Cache clearing happens automatically without user intervention +- The implementation is non-invasive and can be disabled via configuration diff --git a/Execution_UI/Execution_UI/Pages/cast.cshtml b/Execution_UI/Execution_UI/Pages/cast.cshtml index 6b641444..387dd572 100644 --- a/Execution_UI/Execution_UI/Pages/cast.cshtml +++ b/Execution_UI/Execution_UI/Pages/cast.cshtml @@ -304,369 +304,426 @@ -
-
- - -
-

CAST

-

Centralized Automation of Software Tools

-
-
- -
- - -
-
- -
-

Service Health Check

-
- - - - @for (int countMYSQLRecords = 0; countMYSQLRecords < Model.serviceName.Count; - countMYSQLRecords++) - { - - } - - - @for (int countMYSQLRecords = 0; countMYSQLRecords < Model.serviceName.Count; - countMYSQLRecords++) - { - @if (@Model.serviceState[countMYSQLRecords].ToUpper().Equals("ONLINE")) - { - - } - else if (@Model.serviceState[countMYSQLRecords].ToUpper().Equals("OFFLINE")) - { - - } - else if (@Model.serviceState[countMYSQLRecords].ToUpper().Equals("UNDER CONSTRUCTION")) - { - - } - else - { - - } - } - - -
@Model.serviceName[countMYSQLRecords]
@Model.serviceState[countMYSQLRecords]@Model.serviceState[countMYSQLRecords]@Model.serviceState[countMYSQLRecords]@Model.serviceState[countMYSQLRecords]
-
- -

Client Frameworks

-
-

Filters

-
-
- - + @if (Model.IsLoginRequired && !Model.IsAuthenticated) + { + + + +
+
CAST Execution UI
+ @if (Model.IsLoginRequired) + { +
+ +
+ } + else + { +
Powered by CAST Framework
+ } +
+
+ } + + - \ No newline at end of file + + +
+ } + + + \ No newline at end of file diff --git a/Execution_UI/Execution_UI/Pages/cast_base.cshtml.cs b/Execution_UI/Execution_UI/Pages/cast_base.cshtml.cs index a623a734..963b8830 100644 --- a/Execution_UI/Execution_UI/Pages/cast_base.cshtml.cs +++ b/Execution_UI/Execution_UI/Pages/cast_base.cshtml.cs @@ -16,17 +16,24 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Execution_UI.Services; namespace Execution_UI.Pages; public class CastBaseModel : PageModel { private readonly ILogger _logger; - public CastBaseModel(ILogger logger) + private readonly AuthenticationService _authService; + + public CastBaseModel(ILogger logger, AuthenticationService authService) { _logger = logger; + _authService = authService; } + public bool IsLoginRequired { get; set; } + public bool IsAuthenticated { get; set; } + public string AuthenticationError { get; set; } = string.Empty; public SelectList? Options { get; set; } [BindProperty] @@ -210,6 +217,17 @@ public CastBaseModel(ILogger logger) /// IActionResult public IActionResult? OnGet(string? param2) { + // Check if login is enabled + var enableLogin = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("AppSettings")["enable_login"]; + IsLoginRequired = enableLogin != null && enableLogin.ToString().ToLower() == "true"; + IsAuthenticated = _authService.IsAuthenticated(); + + // If login is required and user is not authenticated, show login page + if (IsLoginRequired && !IsAuthenticated) + { + return Page(); + } + if (param2 != null) { if (param2.StartsWith("schedule_") && !scheduleList.Contains(param2.Substring(9))) @@ -719,6 +737,36 @@ public CastBaseModel(ILogger logger) return null!; } + /// + /// Handles user login + /// + public IActionResult OnPostLogin(string username, string password) + { + if (_authService.ValidateCredentials(username, password)) + { + var token = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}")); + _authService.SetAuthenticationToken(token); + _authService.SetUsername(username); + return RedirectToPage("./cast_base"); + } + else + { + AuthenticationError = "Invalid username or password"; + IsLoginRequired = true; + IsAuthenticated = false; + return Page(); + } + } + + /// + /// Handles user logout and clears cache + /// + public IActionResult OnPostLogout() + { + _authService.ClearAuthentication(); + return RedirectToPage("./cast_base"); + } + /// /// Deprecated. Was used to simulate test runs /// diff --git a/Execution_UI/Execution_UI/Program.cs b/Execution_UI/Execution_UI/Program.cs index 010e06bc..b8a0d9a6 100644 --- a/Execution_UI/Execution_UI/Program.cs +++ b/Execution_UI/Execution_UI/Program.cs @@ -3,6 +3,14 @@ // Add services to the container. builder.Services.AddRazorPages(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromMinutes(30); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; +}); +builder.Services.AddScoped(); var app = builder.Build(); @@ -13,6 +21,8 @@ } app.UseStaticFiles(); +app.UseSession(); + app.UseRouting(); app.UseAuthorization(); diff --git a/Execution_UI/Execution_UI/Properties/user.properties b/Execution_UI/Execution_UI/Properties/user.properties new file mode 100644 index 00000000..e6cf0d6b --- /dev/null +++ b/Execution_UI/Execution_UI/Properties/user.properties @@ -0,0 +1,6 @@ +# User Credentials for CAST Execution UI +# Format: username=password +# Add one credential pair per line + +admin=admin@abc123 +user=user@123 diff --git a/Execution_UI/Execution_UI/Services/AuthenticationService.cs b/Execution_UI/Execution_UI/Services/AuthenticationService.cs new file mode 100644 index 00000000..8dd16209 --- /dev/null +++ b/Execution_UI/Execution_UI/Services/AuthenticationService.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Execution_UI.Services +{ + public class AuthenticationService + { + private readonly string _userPropertiesPath; + private readonly IHttpContextAccessor _httpContextAccessor; + private Dictionary _credentials = new(); + + public AuthenticationService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + _userPropertiesPath = Path.Combine( + Directory.GetCurrentDirectory(), + "Properties", + "user.properties" + ); + LoadCredentials(); + } + + /// + /// Loads user credentials from user.properties file + /// + private void LoadCredentials() + { + try + { + if (File.Exists(_userPropertiesPath)) + { + var lines = File.ReadAllLines(_userPropertiesPath); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) + continue; + + var parts = line.Split('='); + if (parts.Length == 2) + { + var username = parts[0].Trim(); + var password = parts[1].Trim(); + _credentials[username] = password; + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading credentials: {ex.Message}"); + } + } + + /// + /// Validates user credentials + /// + public bool ValidateCredentials(string username, string password) + { + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + return false; + + if (_credentials.TryGetValue(username, out var storedPassword)) + { + return storedPassword == password; + } + + return false; + } + + /// + /// Validates Basic Authentication header + /// + public bool ValidateBasicAuth(HttpRequest request) + { + var authHeader = request.Headers["Authorization"].ToString(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ")) + return false; + + try + { + var encodedCredentials = authHeader.Substring("Basic ".Length).Trim(); + var decodedString = Encoding.UTF8.GetString(Convert.FromBase64String(encodedCredentials)); + var credentials = decodedString.Split(':'); + + if (credentials.Length == 2) + { + return ValidateCredentials(credentials[0], credentials[1]); + } + } + catch + { + return false; + } + + return false; + } + + /// + /// Stores authentication token in session + /// + public void SetAuthenticationToken(string token) + { + if (_httpContextAccessor.HttpContext != null) + { + _httpContextAccessor.HttpContext.Session.SetString("AuthToken", token); + } + } + + /// + /// Retrieves authentication token from session + /// + public string GetAuthenticationToken() + { + if (_httpContextAccessor.HttpContext != null) + { + return _httpContextAccessor.HttpContext.Session.GetString("AuthToken") ?? string.Empty; + } + return string.Empty; + } + + /// + /// Checks if user is authenticated + /// + public bool IsAuthenticated() + { + if (_httpContextAccessor.HttpContext != null) + { + var token = _httpContextAccessor.HttpContext.Session.GetString("AuthToken"); + return !string.IsNullOrEmpty(token); + } + return false; + } + + /// + /// Clears authentication token and session data + /// + public void ClearAuthentication() + { + if (_httpContextAccessor.HttpContext != null) + { + _httpContextAccessor.HttpContext.Session.Clear(); + _httpContextAccessor.HttpContext.Session.Remove("AuthToken"); + _httpContextAccessor.HttpContext.Session.Remove("Username"); + } + } + + /// + /// Sets username in session + /// + public void SetUsername(string username) + { + if (_httpContextAccessor.HttpContext != null) + { + _httpContextAccessor.HttpContext.Session.SetString("Username", username); + } + } + + /// + /// Gets username from session + /// + public string GetUsername() + { + if (_httpContextAccessor.HttpContext != null) + { + return _httpContextAccessor.HttpContext.Session.GetString("Username") ?? string.Empty; + } + return string.Empty; + } + } +} diff --git a/Execution_UI/Execution_UI/wwwroot/js/cache-clear.js b/Execution_UI/Execution_UI/wwwroot/js/cache-clear.js new file mode 100644 index 00000000..8eebafb6 --- /dev/null +++ b/Execution_UI/Execution_UI/wwwroot/js/cache-clear.js @@ -0,0 +1,76 @@ +/** + * Cache clearing logic for CAST application + * Clears browser cache, localStorage, and sessionStorage when pages are closed + */ + +// Store page load time for tracking +const pageLoadTime = Date.now(); + +// Function to clear all cache +function clearAllCache() { + try { + // Clear localStorage + if (typeof (Storage) !== "undefined") { + localStorage.clear(); + } + + // Clear sessionStorage + if (typeof (sessionStorage) !== "undefined") { + sessionStorage.clear(); + } + + // Clear browser cache headers + if (navigator.serviceWorker) { + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => { + registration.unregister(); + }); + }); + } + + // Clear IndexedDB + if (typeof (indexedDB) !== "undefined") { + indexedDB.databases().then(dbs => { + dbs.forEach(db => { + indexedDB.deleteDatabase(db.name); + }); + }); + } + + console.log('Cache cleared successfully'); + } catch (error) { + console.error('Error clearing cache:', error); + } +} + +// Clear cache when user leaves the page +window.addEventListener('beforeunload', function (e) { + clearAllCache(); +}); + +// Clear cache when page is unloaded +window.addEventListener('unload', function () { + clearAllCache(); +}); + +// Clear cache when tab is closed (pagehide event) +window.addEventListener('pagehide', function (e) { + if (e.persisted === false) { + clearAllCache(); + } +}); + +// Listen for visibility changes (when user switches tabs or closes window) +document.addEventListener('visibilitychange', function () { + if (document.hidden) { + // Page is hidden, clear cache + clearAllCache(); + } +}); + +// Clear cache on browser back/forward navigation +window.addEventListener('pageshow', function (e) { + if (e.persisted) { + clearAllCache(); + } +}); From 536e5478f53e2ce37f409a019d7c3b5caf34ba32 Mon Sep 17 00:00:00 2001 From: Kevin Snyder Date: Thu, 1 Jan 2026 13:00:40 -0500 Subject: [PATCH 2/2] Updated Unit Tests to account for Authentication --- .../Execution_UI.Tests/Pages/CastPageTests.cs | 147 +++++- .../Services/AuthenticationServiceTests.cs | 486 ++++++++++++++++++ 2 files changed, 613 insertions(+), 20 deletions(-) create mode 100644 Execution_UI/Execution_UI.Tests/Services/AuthenticationServiceTests.cs diff --git a/Execution_UI/Execution_UI.Tests/Pages/CastPageTests.cs b/Execution_UI/Execution_UI.Tests/Pages/CastPageTests.cs index 6af320df..04c3467e 100644 --- a/Execution_UI/Execution_UI.Tests/Pages/CastPageTests.cs +++ b/Execution_UI/Execution_UI.Tests/Pages/CastPageTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Logging; using Execution_UI.Pages; +using Execution_UI.Services; namespace Execution_UI.Tests.Pages { @@ -18,16 +19,22 @@ private Mock> CreateMockLogger() return new Mock>(); } + private Mock CreateMockAuthenticationService() + { + return new Mock(null!); + } + #region Constructor Tests [Fact] - public void Constructor_WithValidLogger_InitializesSuccessfully() + public void Constructor_WithValidDependencies_InitializesSuccessfully() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model); @@ -38,9 +45,10 @@ public void Constructor_InitializesOriginatorUUIDsList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.originatorUUIDs); @@ -53,9 +61,10 @@ public void Constructor_InitializesDisplayNamesList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.displayNames); @@ -63,6 +72,28 @@ public void Constructor_InitializesDisplayNamesList() Assert.Empty(model.displayNames); } + [Fact] + public void Constructor_WithNullLogger_AcceptsNull() + { + // Arrange + var mockAuthService = CreateMockAuthenticationService(); + + // Act & Assert - Constructor signature allows null + var model = new CastModel(null!, mockAuthService.Object); + Assert.NotNull(model); + } + + [Fact] + public void Constructor_WithNullAuthenticationService_AcceptsNull() + { + // Arrange + var mockLogger = CreateMockLogger(); + + // Act & Assert - Constructor signature allows null + var model = new CastModel(mockLogger.Object, null!); + Assert.NotNull(model); + } + #endregion #region Inheritance Tests @@ -72,9 +103,10 @@ public void CastModel_InheritsFromPageModel() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.IsAssignableFrom(model); @@ -89,7 +121,8 @@ public void SelectedValue_CanBeSet() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); var testValue = "test-value"; // Act @@ -104,7 +137,8 @@ public void SelectedValue_CanBeSetToNull() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Act model.SelectedValue = null; @@ -118,7 +152,8 @@ public void SelectedValue_CanBeSetMultipleTimes() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); var value1 = "value1"; var value2 = "value2"; @@ -136,7 +171,8 @@ public void Options_CanBeSet() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); var items = new List { "option1", "option2", "option3" }; var selectList = new SelectList(items); @@ -153,7 +189,8 @@ public void Options_CanBeNull() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Act model.Options = null; @@ -162,6 +199,66 @@ public void Options_CanBeNull() Assert.Null(model.Options); } + [Fact] + public void IsLoginRequired_CanBeSet() + { + // Arrange + var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); + + // Act + model.IsLoginRequired = true; + + // Assert + Assert.True(model.IsLoginRequired); + } + + [Fact] + public void IsAuthenticated_CanBeSet() + { + // Arrange + var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); + + // Act + model.IsAuthenticated = true; + + // Assert + Assert.True(model.IsAuthenticated); + } + + [Fact] + public void AuthenticationError_DefaultsToEmptyString() + { + // Arrange + var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); + + // Act + var model = new CastModel(mockLogger.Object, mockAuthService.Object); + + // Assert + Assert.Equal(string.Empty, model.AuthenticationError); + } + + [Fact] + public void AuthenticationError_CanBeSet() + { + // Arrange + var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); + var errorMessage = "Invalid credentials"; + + // Act + model.AuthenticationError = errorMessage; + + // Assert + Assert.Equal(errorMessage, model.AuthenticationError); + } + #endregion #region Root Directory Tests @@ -171,9 +268,10 @@ public void RootDir_IsNotEmpty() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.rootDir); @@ -185,7 +283,8 @@ public void RootDir_ContainsValidPath() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Act & Assert Assert.True(model.rootDir.Contains("..") || model.rootDir.Contains(Path.DirectorySeparatorChar.ToString())); @@ -200,7 +299,8 @@ public void OriginatorUUIDs_CanAddItem() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); var testUuid = "test-uuid-123"; // Act @@ -216,7 +316,8 @@ public void OriginatorUUIDs_CanAddMultipleItems() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); var uuid1 = "uuid-1"; var uuid2 = "uuid-2"; var uuid3 = "uuid-3"; @@ -238,7 +339,8 @@ public void DisplayNames_CanAddItem() { // Arrange var mockLogger = CreateMockLogger(); - var model = new CastModel(mockLogger.Object); + var mockAuthService = CreateMockAuthenticationService(); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); var displayName = "Test Client"; // Act @@ -254,9 +356,10 @@ public void StartRun_InitializesAsEmptyList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.startRun); @@ -269,9 +372,10 @@ public void StopRun_InitializesAsEmptyList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.stopRun); @@ -284,9 +388,10 @@ public void PauseRun_InitializesAsEmptyList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.pauseRun); @@ -299,9 +404,10 @@ public void ResumeRun_InitializesAsEmptyList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.resumeRun); @@ -314,9 +420,10 @@ public void AbortRun_InitializesAsEmptyList() { // Arrange var mockLogger = CreateMockLogger(); + var mockAuthService = CreateMockAuthenticationService(); // Act - var model = new CastModel(mockLogger.Object); + var model = new CastModel(mockLogger.Object, mockAuthService.Object); // Assert Assert.NotNull(model.abortRun); diff --git a/Execution_UI/Execution_UI.Tests/Services/AuthenticationServiceTests.cs b/Execution_UI/Execution_UI.Tests/Services/AuthenticationServiceTests.cs new file mode 100644 index 00000000..504ae982 --- /dev/null +++ b/Execution_UI/Execution_UI.Tests/Services/AuthenticationServiceTests.cs @@ -0,0 +1,486 @@ +using Xunit; +using Moq; +using Microsoft.AspNetCore.Http; +using Execution_UI.Services; +using System.Text; + +namespace Execution_UI.Tests.Services +{ + /// + /// Unit tests for AuthenticationService. + /// Tests credential validation, basic auth handling, and session management. + /// + public class AuthenticationServiceTests + { + private Mock CreateMockHttpContextAccessor() + { + return new Mock(); + } + + #region Constructor Tests + + [Fact] + public void Constructor_WithValidHttpContextAccessor_InitializesSuccessfully() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + + // Act + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void Constructor_CallsLoadCredentials() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + + // Act - Constructor should load credentials without throwing + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Assert - Service should be initialized without exception + Assert.NotNull(service); + } + + #endregion + + #region ValidateCredentials Tests + + [Fact] + public void ValidateCredentials_WithNullUsername_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateCredentials(null!, "password"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateCredentials_WithEmptyUsername_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateCredentials(string.Empty, "password"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateCredentials_WithNullPassword_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateCredentials("username", null!); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateCredentials_WithEmptyPassword_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateCredentials("username", string.Empty); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateCredentials_WithNonexistentUser_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateCredentials("nonexistent", "password"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateCredentials_WithInvalidCredentials_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateCredentials("invaliduser", "invalidpass"); + + // Assert + Assert.IsType(result); + Assert.False(result); + } + + #endregion + + #region ValidateBasicAuth Tests + + [Fact] + public void ValidateBasicAuth_WithNullAuthHeader_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var mockRequest = new Mock(); + mockRequest.Setup(r => r.Headers).Returns(new HeaderDictionary()); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateBasicAuth(mockRequest.Object); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateBasicAuth_WithEmptyAuthHeader_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var mockRequest = new Mock(); + var headers = new HeaderDictionary { { "Authorization", "" } }; + mockRequest.Setup(r => r.Headers).Returns(headers); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateBasicAuth(mockRequest.Object); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateBasicAuth_WithoutBasicPrefix_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var mockRequest = new Mock(); + var headers = new HeaderDictionary { { "Authorization", "Bearer token123" } }; + mockRequest.Setup(r => r.Headers).Returns(headers); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateBasicAuth(mockRequest.Object); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateBasicAuth_WithInvalidBase64_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var mockRequest = new Mock(); + var headers = new HeaderDictionary { { "Authorization", "Basic !!invalid!!" } }; + mockRequest.Setup(r => r.Headers).Returns(headers); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateBasicAuth(mockRequest.Object); + + // Assert + Assert.False(result); + } + + [Fact] + public void ValidateBasicAuth_WithValidBasicAuthHeader_AttemptsValidation() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var mockRequest = new Mock(); + + // "dGVzdHVzZXI6dGVzdHBhc3M=" is base64 for "testuser:testpass" + var headers = new HeaderDictionary { { "Authorization", "Basic dGVzdHVzZXI6dGVzdHBhc3M=" } }; + mockRequest.Setup(r => r.Headers).Returns(headers); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateBasicAuth(mockRequest.Object); + + // Assert - Should return boolean based on credentials validation + Assert.IsType(result); + } + + [Fact] + public void ValidateBasicAuth_WithMissingColon_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + var mockRequest = new Mock(); + + // "dGVzdHVzZXJ0ZXN0cGFzcw==" is base64 for "testusertestpass" (no colon) + var headers = new HeaderDictionary { { "Authorization", "Basic dGVzdHVzZXJ0ZXN0cGFzcw==" } }; + mockRequest.Setup(r => r.Headers).Returns(headers); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.ValidateBasicAuth(mockRequest.Object); + + // Assert + Assert.False(result); + } + + #endregion + + #region SetAuthenticationToken Tests + + [Fact] + public void SetAuthenticationToken_WithValidToken_DoesNotThrow() + { + // Arrange + var mockSession = new Mock(); + var mockHttpContext = new Mock(); + mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object); + + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns(mockHttpContext.Object); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + var token = "test-token-123"; + + // Act & Assert + var exception = Record.Exception(() => service.SetAuthenticationToken(token)); + Assert.Null(exception); + } + + [Fact] + public void SetAuthenticationToken_WithNullHttpContext_DoesNotThrow() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns((HttpContext)null!); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act & Assert - Should not throw + var exception = Record.Exception(() => service.SetAuthenticationToken("token")); + Assert.Null(exception); + } + + #endregion + + #region GetAuthenticationToken Tests + + [Fact] + public void GetAuthenticationToken_WithNullHttpContext_ReturnsEmptyString() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns((HttpContext)null!); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.GetAuthenticationToken(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetAuthenticationToken_ReturnsString() + { + // Arrange + var mockSession = new Mock(); + var mockHttpContext = new Mock(); + mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object); + + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns(mockHttpContext.Object); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.GetAuthenticationToken(); + + // Assert + Assert.IsType(result); + } + + #endregion + + #region IsAuthenticated Tests + + [Fact] + public void IsAuthenticated_WithNullHttpContext_ReturnsFalse() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns((HttpContext)null!); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.IsAuthenticated(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsAuthenticated_ReturnsBoolean() + { + // Arrange + var mockSession = new Mock(); + var mockHttpContext = new Mock(); + mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object); + + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns(mockHttpContext.Object); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.IsAuthenticated(); + + // Assert + Assert.IsType(result); + } + + #endregion + + #region ClearAuthentication Tests + + [Fact] + public void ClearAuthentication_WithValidSession_CallsClear() + { + // Arrange + var mockSession = new Mock(); + var mockHttpContext = new Mock(); + mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object); + + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns(mockHttpContext.Object); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + service.ClearAuthentication(); + + // Assert - Verify that session operations were called + mockSession.Verify(s => s.Clear(), Times.Once); + } + + [Fact] + public void ClearAuthentication_WithNullHttpContext_DoesNotThrow() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns((HttpContext)null!); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act & Assert + var exception = Record.Exception(() => service.ClearAuthentication()); + Assert.Null(exception); + } + + #endregion + + #region SetUsername Tests + + [Fact] + public void SetUsername_WithValidUsername_DoesNotThrow() + { + // Arrange + var mockSession = new Mock(); + var mockHttpContext = new Mock(); + mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object); + + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns(mockHttpContext.Object); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + var username = "testuser"; + + // Act & Assert + var exception = Record.Exception(() => service.SetUsername(username)); + Assert.Null(exception); + } + + [Fact] + public void SetUsername_WithNullHttpContext_DoesNotThrow() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns((HttpContext)null!); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act & Assert + var exception = Record.Exception(() => service.SetUsername("testuser")); + Assert.Null(exception); + } + + #endregion + + #region GetUsername Tests + + [Fact] + public void GetUsername_WithNullHttpContext_ReturnsEmptyString() + { + // Arrange + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns((HttpContext)null!); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.GetUsername(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetUsername_ReturnsString() + { + // Arrange + var mockSession = new Mock(); + var mockHttpContext = new Mock(); + mockHttpContext.Setup(c => c.Session).Returns(mockSession.Object); + + var mockHttpContextAccessor = CreateMockHttpContextAccessor(); + mockHttpContextAccessor.Setup(a => a.HttpContext).Returns(mockHttpContext.Object); + + var service = new AuthenticationService(mockHttpContextAccessor.Object); + + // Act + var result = service.GetUsername(); + + // Assert + Assert.IsType(result); + } + + #endregion + } +}