diff --git a/test/Classes/HomeControllerCasUrlTests.cs b/test/Classes/HomeControllerCasUrlTests.cs
new file mode 100644
index 000000000..bcb823d39
--- /dev/null
+++ b/test/Classes/HomeControllerCasUrlTests.cs
@@ -0,0 +1,148 @@
+using System.Net;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Viper.Classes;
+using Viper.Classes.SQLContext;
+using Viper.Controllers;
+using Web.Authorization;
+
+namespace Viper.test.Classes;
+
+///
+/// CAS service callbacks must be built from the configured canonical origin, never from the
+/// request Host. Login covers the shared BuildRedirectUri helper that CasLogin's ticket
+/// validation also uses.
+///
+public class HomeControllerCasUrlTests
+{
+ private const string CasBaseUrl = "https://ssodev.ucdavis.edu/cas/";
+ private const string PublicBaseUrl = "https://secure-test.vetmed.ucdavis.edu/2";
+ private const string ForgedHost = "attacker.example";
+
+ [Fact]
+ public void Login_BuildsServiceFromConfiguredOrigin_NotHostHeader()
+ {
+ var controller = CreateController(ForgedHost, pathBase: "/2");
+
+ var result = Assert.IsType(controller.Login());
+
+ Assert.DoesNotContain(ForgedHost, result.Url, StringComparison.OrdinalIgnoreCase);
+ Assert.StartsWith($"{PublicBaseUrl}/CasLogin?", ServiceParameter(result.Url), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Login_DefaultReturnUrl_PreservesPathBase()
+ {
+ var controller = CreateController(ForgedHost, pathBase: "/2");
+
+ var result = Assert.IsType(controller.Login());
+
+ // ReturnUrl is encoded inside the service value, which is then encoded again for CAS,
+ // so one decode leaves the inner encoding intact.
+ Assert.Equal($"{PublicBaseUrl}/CasLogin?ReturnUrl={WebUtility.UrlEncode("/2")}", ServiceParameter(result.Url));
+ }
+
+ [Fact]
+ public void Login_NoPathBase_DefaultsToEmptyReturnUrl()
+ {
+ var controller = CreateController("localhost:7157", pathBase: string.Empty);
+
+ var result = Assert.IsType(controller.Login());
+
+ Assert.Equal($"{PublicBaseUrl}/CasLogin?ReturnUrl=", ServiceParameter(result.Url));
+ }
+
+ [Fact]
+ public void Login_ExplicitReturnUrl_IsPreserved()
+ {
+ var controller = CreateController(ForgedHost, pathBase: "/2");
+
+ var result = Assert.IsType(controller.Login("/2/Students/StudentClassYear"));
+
+ Assert.Equal(
+ $"{PublicBaseUrl}/CasLogin?ReturnUrl={WebUtility.UrlEncode("/2/Students/StudentClassYear")}",
+ ServiceParameter(result.Url));
+ }
+
+ [Fact]
+ public void Login_ApiReturnUrlUnderPathBase_ReturnsUnauthorized()
+ {
+ // The SPAs send ReturnUrl already prefixed with the deployed PathBase, so without
+ // stripping it the API guard never fired on TEST/PROD and an API caller got a CAS
+ // HTML redirect instead of a 401.
+ var controller = CreateController("secure-test.vetmed.ucdavis.edu", pathBase: "/2");
+
+ Assert.IsType(controller.Login("/2/api/students/dvm"));
+ }
+
+ [Fact]
+ public void Login_ApiReturnUrlWithoutPathBase_ReturnsUnauthorized()
+ {
+ var controller = CreateController("localhost:7157", pathBase: string.Empty);
+
+ Assert.IsType(controller.Login("/api/students/dvm"));
+ }
+
+ [Fact]
+ public async Task Logout_BuildsServiceFromConfiguredOrigin_NotHostHeader()
+ {
+ var controller = CreateController(ForgedHost, pathBase: "/2");
+
+ var result = Assert.IsType(await controller.Logout());
+
+ Assert.DoesNotContain(ForgedHost, result.Url, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal($"{CasBaseUrl}logout?service={WebUtility.UrlEncode(PublicBaseUrl)}", result.Url);
+ }
+
+ ///
+ /// Pulls the decoded CAS service parameter out of the redirect so assertions read as URLs
+ /// rather than percent-encoded soup.
+ ///
+ private static string ServiceParameter(string redirectUrl)
+ {
+ const string marker = "service=";
+ int start = redirectUrl.IndexOf(marker, StringComparison.Ordinal);
+ Assert.True(start >= 0, $"No service parameter in '{redirectUrl}'.");
+
+ return WebUtility.UrlDecode(redirectUrl[(start + marker.Length)..]);
+ }
+
+ private static HomeController CreateController(string host, string pathBase)
+ {
+ var publicUrl = new PublicUrlService(
+ Options.Create(new PublicUrlOptions { PublicBaseUrl = PublicBaseUrl }),
+ Substitute.For());
+
+ var controller = new HomeController(
+ Substitute.For(),
+ Options.Create(new CasSettings { CasBaseUrl = CasBaseUrl }),
+ publicUrl,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For());
+
+ var httpContext = new DefaultHttpContext
+ {
+ RequestServices = AuthenticationServices()
+ };
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString(host);
+ httpContext.Request.PathBase = new PathString(pathBase);
+ httpContext.Request.Path = new PathString("/Login");
+
+ controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
+ return controller;
+ }
+
+ // Logout signs the cookie out, which resolves IAuthenticationService from the request.
+ private static IServiceProvider AuthenticationServices()
+ {
+ var authentication = Substitute.For();
+ var services = Substitute.For();
+ services.GetService(typeof(IAuthenticationService)).Returns(authentication);
+ return services;
+ }
+}
diff --git a/test/Classes/PublicUrlServiceTests.cs b/test/Classes/PublicUrlServiceTests.cs
new file mode 100644
index 000000000..c964244ac
--- /dev/null
+++ b/test/Classes/PublicUrlServiceTests.cs
@@ -0,0 +1,179 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using NSubstitute.ReturnsExtensions;
+using Viper.Classes;
+
+namespace Viper.test.Classes;
+
+///
+/// The canonical public origin must come from configuration in deployed environments so a
+/// forged Host header cannot influence a CAS callback. Development keeps the request-derived
+/// fallback because the local port is dynamic.
+///
+public class PublicUrlServiceTests
+{
+ private const string TestBaseUrl = "https://secure-test.vetmed.ucdavis.edu/2";
+ private const string ProductionBaseUrl = "https://viper.vetmed.ucdavis.edu/2";
+
+ [Fact]
+ public void BaseUrl_ConfiguredOriginWins_OverForgedHostHeader()
+ {
+ var service = CreateService(TestBaseUrl, host: "attacker.example", pathBase: "/2");
+
+ Assert.Equal(TestBaseUrl, service.BaseUrl);
+ }
+
+ [Fact]
+ public void BuildUrl_ConfiguredOriginWins_OverForgedHostHeader()
+ {
+ var service = CreateService(ProductionBaseUrl, host: "attacker.example", pathBase: "/2");
+
+ Assert.Equal($"{ProductionBaseUrl}/CasLogin", service.BuildUrl("/CasLogin"));
+ Assert.DoesNotContain("attacker.example", service.BuildUrl("/CasLogin"), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Theory]
+ [InlineData("https://viper.vetmed.ucdavis.edu/2/", "https://viper.vetmed.ucdavis.edu/2")]
+ [InlineData(" https://viper.vetmed.ucdavis.edu/2 ", "https://viper.vetmed.ucdavis.edu/2")]
+ [InlineData("https://viper.vetmed.ucdavis.edu/", "https://viper.vetmed.ucdavis.edu")]
+ public void NormalizeBaseUrl_TrimsWhitespaceAndTrailingSlash(string configured, string expected)
+ {
+ Assert.Equal(expected, PublicUrlService.NormalizeBaseUrl(configured));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void NormalizeBaseUrl_BlankIsNull(string? configured)
+ {
+ Assert.Null(PublicUrlService.NormalizeBaseUrl(configured));
+ }
+
+ [Fact]
+ public void BuildUrl_AddsSeparator_WhenPathHasNoLeadingSlash()
+ {
+ var service = CreateService(TestBaseUrl, host: "secure-test.vetmed.ucdavis.edu", pathBase: "/2");
+
+ Assert.Equal($"{TestBaseUrl}/CasLogin", service.BuildUrl("CasLogin"));
+ }
+
+ [Fact]
+ public void BuildUrl_EmptyPath_ReturnsBaseUrl()
+ {
+ var service = CreateService(TestBaseUrl, host: "secure-test.vetmed.ucdavis.edu", pathBase: "/2");
+
+ Assert.Equal(TestBaseUrl, service.BuildUrl(string.Empty));
+ }
+
+ [Fact]
+ public void BaseUrl_Unconfigured_FallsBackToRequestIncludingPathBase()
+ {
+ // Development only: no PublicBaseUrl set, so the origin comes from the request.
+ var service = CreateService(configured: null, host: "localhost:7157", pathBase: "/2");
+
+ Assert.Equal("https://localhost:7157/2", service.BaseUrl);
+ }
+
+ [Fact]
+ public void BaseUrl_Unconfigured_NoPathBase_ReturnsOriginOnly()
+ {
+ var service = CreateService(configured: null, host: "localhost:7157", pathBase: string.Empty);
+
+ Assert.Equal("https://localhost:7157", service.BaseUrl);
+ }
+
+ [Fact]
+ public void BaseUrl_Unconfigured_NoRequest_FallsBackToLocalDevelopmentOrigin()
+ {
+ // Development background work (Hangfire email) has no request to derive from. Deployed
+ // environments never reach this because startup validation requires the configured value.
+ var accessor = Substitute.For();
+ accessor.HttpContext.ReturnsNull();
+ var service = new PublicUrlService(Options.Create(new PublicUrlOptions()), accessor);
+
+ string expectedPort = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT") ?? "7157";
+
+ Assert.Equal($"https://localhost:{expectedPort}", service.BaseUrl);
+ }
+
+ [Fact]
+ public void BaseUrl_Configured_NoRequest_StillUsesTheCanonicalOrigin()
+ {
+ // The email path must not pick up the local development origin in a deployed environment.
+ var accessor = Substitute.For();
+ accessor.HttpContext.ReturnsNull();
+ var service = new PublicUrlService(Options.Create(new PublicUrlOptions { PublicBaseUrl = ProductionBaseUrl }), accessor);
+
+ Assert.Equal(ProductionBaseUrl, service.BaseUrl);
+ }
+
+ #region Startup validation
+
+ [Theory]
+ [InlineData(TestBaseUrl)]
+ [InlineData(ProductionBaseUrl)]
+ [InlineData("https://viper.vetmed.ucdavis.edu")]
+ public void Validate_AcceptsCanonicalDeployedUrls(string configured)
+ {
+ Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl(configured, isDevelopment: false).Succeeded);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void Validate_MissingOutsideDevelopment_FailsStartup(string? configured)
+ {
+ var result = PublicUrlOptionsValidator.ValidateBaseUrl(configured, isDevelopment: false);
+
+ Assert.True(result.Failed);
+ Assert.Contains("Application:PublicBaseUrl", result.FailureMessage, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Validate_MissingInDevelopment_Succeeds()
+ {
+ // Development derives the origin from the request so dynamic local ports keep working.
+ Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl(null, isDevelopment: true).Succeeded);
+ }
+
+ [Fact]
+ public void Validate_HttpOutsideDevelopment_Fails()
+ {
+ Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl("http://viper.vetmed.ucdavis.edu/2", isDevelopment: false).Failed);
+ }
+
+ [Fact]
+ public void Validate_HttpInDevelopment_Succeeds()
+ {
+ Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl("http://localhost:5000", isDevelopment: true).Succeeded);
+ }
+
+ [Theory]
+ [InlineData("/2")]
+ [InlineData("viper.vetmed.ucdavis.edu/2")]
+ [InlineData("https://user:pass@viper.vetmed.ucdavis.edu/2")]
+ [InlineData("https://viper.vetmed.ucdavis.edu/2?next=x")]
+ [InlineData("https://viper.vetmed.ucdavis.edu/2#frag")]
+ public void Validate_RejectsMalformedOrUnsafeValues(string configured)
+ {
+ Assert.True(PublicUrlOptionsValidator.ValidateBaseUrl(configured, isDevelopment: false).Failed);
+ }
+
+ #endregion
+
+ private static PublicUrlService CreateService(string? configured, string host, string pathBase)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Scheme = "https";
+ context.Request.Host = new HostString(host);
+ context.Request.PathBase = new PathString(pathBase);
+ context.Request.Path = new PathString("/CasLogin");
+
+ var accessor = Substitute.For();
+ accessor.HttpContext.Returns(context);
+
+ return new PublicUrlService(Options.Create(new PublicUrlOptions { PublicBaseUrl = configured }), accessor);
+ }
+}
diff --git a/test/ClinicalScheduler/EmailNotificationTest.cs b/test/ClinicalScheduler/EmailNotificationTest.cs
index 7dd97c420..1727ef320 100644
--- a/test/ClinicalScheduler/EmailNotificationTest.cs
+++ b/test/ClinicalScheduler/EmailNotificationTest.cs
@@ -6,6 +6,7 @@
using NSubstitute.ExceptionExtensions;
using Viper.Areas.ClinicalScheduler.EmailTemplates.Models;
using Viper.Areas.ClinicalScheduler.Services;
+using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.EmailTemplates.Services;
using Viper.Models.ClinicalScheduler;
@@ -79,8 +80,9 @@ public EmailNotificationTest()
.Returns(currentYear);
// Setup email settings
- var mockEmailSettingsOptions = Substitute.For>();
- mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" });
+ var mockPublicUrl = Substitute.For();
+ mockPublicUrl.BaseUrl.Returns("https://test.example.com");
+ mockPublicUrl.BuildUrl(Arg.Any()).Returns(ci => "https://test.example.com" + ci.Arg());
// Setup audit service
_mockAuditService.LogInstructorRemovedAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
@@ -92,7 +94,7 @@ public EmailNotificationTest()
_mockLogger,
_mockEmailService,
_mockEmailNotificationOptions,
- mockEmailSettingsOptions,
+ mockPublicUrl,
_mockGradYearService,
_mockPermissionValidator,
_mockEmailTemplateRenderer);
@@ -560,8 +562,9 @@ public async Task RemoveInstructorScheduleAsync_MultipleEmailRecipients_SendsToA
}
};
_mockEmailNotificationOptions.Value.Returns(emailNotificationSettings);
- var mockEmailSettingsOptions = Substitute.For>();
- mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" });
+ var mockPublicUrl = Substitute.For();
+ mockPublicUrl.BaseUrl.Returns("https://test.example.com");
+ mockPublicUrl.BuildUrl(Arg.Any()).Returns(ci => "https://test.example.com" + ci.Arg());
// Create a new service instance with the updated configuration
var serviceWithMultipleRecipients = new TestableScheduleEditService(
@@ -570,7 +573,7 @@ public async Task RemoveInstructorScheduleAsync_MultipleEmailRecipients_SendsToA
_mockLogger,
_mockEmailService,
_mockEmailNotificationOptions,
- mockEmailSettingsOptions,
+ mockPublicUrl,
_mockGradYearService,
_mockPermissionValidator,
_mockEmailTemplateRenderer);
diff --git a/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs b/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs
index 9bb83e89c..d7bf6692d 100644
--- a/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs
+++ b/test/ClinicalScheduler/Integration/ControllerServiceIntegrationTest.cs
@@ -14,6 +14,8 @@
using Viper.Services;
using CS = Viper.Models.ClinicalScheduler;
+using Viper.Classes;
+
namespace Viper.test.ClinicalScheduler.Integration
{
///
@@ -55,8 +57,8 @@ public ControllerServiceIntegrationTest()
var mockEmailService = Substitute.For();
var mockEmailNotificationSettings = Substitute.For>();
mockEmailNotificationSettings.Value.Returns(new EmailNotificationSettings());
- var mockEmailSettings = Substitute.For>();
- mockEmailSettings.Value.Returns(new EmailSettings());
+ var mockPublicUrl = Substitute.For();
+ mockPublicUrl.BaseUrl.Returns("https://test.example.com");
var mockGradYearService = Substitute.For();
var mockPermissionValidator = Substitute.For();
var mockEmailTemplateRenderer = Substitute.For();
@@ -67,7 +69,7 @@ public ControllerServiceIntegrationTest()
scheduleEditLogger,
mockEmailService,
mockEmailNotificationSettings,
- mockEmailSettings,
+ mockPublicUrl,
mockGradYearService,
mockPermissionValidator,
mockEmailTemplateRenderer);
diff --git a/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs b/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs
index 7d08884f6..55ad8daec 100644
--- a/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs
+++ b/test/ClinicalScheduler/ScheduleEditServiceRollbackTest.cs
@@ -5,6 +5,7 @@
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Viper.Areas.ClinicalScheduler.Services;
+using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.EmailTemplates.Services;
using Viper.Services;
@@ -56,8 +57,9 @@ public ScheduleEditServiceRollbackTest()
var emailNotificationOptions = Substitute.For>();
emailNotificationOptions.Value.Returns(new EmailNotificationSettings());
- var emailSettingsOptions = Substitute.For>();
- emailSettingsOptions.Value.Returns(new EmailSettings());
+ var publicUrl = Substitute.For();
+ publicUrl.BaseUrl.Returns("https://test.example.com");
+ publicUrl.BuildUrl(Arg.Any()).Returns(ci => "https://test.example.com" + ci.Arg());
_service = new ScheduleEditService(
_context,
@@ -65,7 +67,7 @@ public ScheduleEditServiceRollbackTest()
Substitute.For>(),
Substitute.For(),
emailNotificationOptions,
- emailSettingsOptions,
+ publicUrl,
gradYearService,
permissionValidator,
Substitute.For());
diff --git a/test/ClinicalScheduler/ScheduleEditServiceTest.cs b/test/ClinicalScheduler/ScheduleEditServiceTest.cs
index a05887886..add6303ee 100644
--- a/test/ClinicalScheduler/ScheduleEditServiceTest.cs
+++ b/test/ClinicalScheduler/ScheduleEditServiceTest.cs
@@ -6,6 +6,7 @@
using NSubstitute.ExceptionExtensions;
using Viper.Areas.ClinicalScheduler.EmailTemplates.Models;
using Viper.Areas.ClinicalScheduler.Services;
+using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.EmailTemplates.Services;
using Viper.Models.ClinicalScheduler;
@@ -96,8 +97,9 @@ public ScheduleEditServiceTest()
SeedTestData();
// Setup email settings
- var mockEmailSettingsOptions = Substitute.For>();
- mockEmailSettingsOptions.Value.Returns(new EmailSettings { BaseUrl = "https://test.example.com" });
+ var mockPublicUrl = Substitute.For();
+ mockPublicUrl.BaseUrl.Returns("https://test.example.com");
+ mockPublicUrl.BuildUrl(Arg.Any()).Returns(ci => "https://test.example.com" + ci.Arg());
_service = new TestableScheduleEditService(
_context,
@@ -105,7 +107,7 @@ public ScheduleEditServiceTest()
_mockLogger,
_mockEmailService,
_mockEmailNotificationOptions,
- mockEmailSettingsOptions,
+ mockPublicUrl,
_mockGradYearService,
_mockPermissionValidator,
_mockEmailTemplateRenderer);
diff --git a/test/ClinicalScheduler/TestableScheduleEditService.cs b/test/ClinicalScheduler/TestableScheduleEditService.cs
index 51a2443fc..b79b65152 100644
--- a/test/ClinicalScheduler/TestableScheduleEditService.cs
+++ b/test/ClinicalScheduler/TestableScheduleEditService.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Viper.Areas.ClinicalScheduler.Services;
+using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.EmailTemplates.Services;
using Viper.Services;
@@ -19,11 +20,11 @@ public TestableScheduleEditService(
ILogger logger,
IEmailService emailService,
IOptions emailNotificationOptions,
- IOptions emailSettingsOptions,
+ IPublicUrlService publicUrl,
IGradYearService gradYearService,
IPermissionValidator permissionValidator,
IEmailTemplateRenderer emailTemplateRenderer)
- : base(context, auditService, logger, emailService, emailNotificationOptions, emailSettingsOptions, gradYearService, permissionValidator, emailTemplateRenderer)
+ : base(context, auditService, logger, emailService, emailNotificationOptions, publicUrl, gradYearService, permissionValidator, emailTemplateRenderer)
{
}
diff --git a/test/Effort/VerificationServiceTests.cs b/test/Effort/VerificationServiceTests.cs
index 3d6273af7..3aa711af5 100644
--- a/test/Effort/VerificationServiceTests.cs
+++ b/test/Effort/VerificationServiceTests.cs
@@ -10,6 +10,7 @@
using Viper.Areas.Effort.Models.DTOs.Responses;
using Viper.Areas.Effort.Models.Entities;
using Viper.Areas.Effort.Services;
+using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.EmailTemplates.Services;
using Viper.Models.VIPER;
@@ -66,11 +67,9 @@ public VerificationServiceTests()
};
var settingsOptions = Options.Create(_settings);
- var emailSettings = new EmailSettings
- {
- BaseUrl = "https://test.example.com"
- };
- var emailSettingsOptions = Options.Create(emailSettings);
+ var publicUrl = Substitute.For();
+ publicUrl.BaseUrl.Returns("https://test.example.com");
+ publicUrl.BuildUrl(Arg.Any()).Returns(ci => "https://test.example.com" + ci.Arg());
_emailTemplateRendererMock = Substitute.For();
_emailTemplateRendererMock
@@ -102,7 +101,7 @@ public VerificationServiceTests()
_classificationServiceMock,
_loggerMock,
settingsOptions,
- emailSettingsOptions,
+ publicUrl,
_emailTemplateRendererMock);
SeedTestData();
@@ -661,50 +660,6 @@ await _auditServiceMock.Received(1).LogPersonChangeAsync(
Arg.Is