From 5cbc1856388fcd4847aa546c1b81f5190cc485fd Mon Sep 17 00:00:00 2001 From: nikhila-aot Date: Tue, 13 Oct 2026 12:44:39 -0700 Subject: [PATCH 01/45] [AB#34424] Show inline field validation in Compose & Send Email review step Mark the selected application as validated once a field is committed (change/blur) so the To/CC/BCC/From/Subject/Body inputs show their own error state, not only the recipient card. Co-Authored-By: Claude Opus 5.5 --- .../ComposeAndSendEmailModal.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js index 773e69249c..29652845e1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js @@ -950,9 +950,13 @@ $('#EmailTo, #EmailCC, #EmailBCC, #EmailFrom, #EmailSubject') .off('input.compose change.compose') - .on('input.compose change.compose', function () { + .on('input.compose change.compose', function (event) { syncCurrentState(); if (currentStep === 2 && selectedApplicationId) { + // A committed edit (change fires on blur) surfaces inline field errors for this application. + if (event.type === 'change') { + rightPanelValidatedApplications.add(selectedApplicationId); + } const errors = updateRowValidation(selectedApplicationId); if (rightPanelValidatedApplications.has(selectedApplicationId)) { showStepTwoErrors(errors); @@ -1107,9 +1111,12 @@ }; } }); - tinyEditor.on('input change undo redo', function () { + tinyEditor.on('input change undo redo', function (event) { syncCurrentState(); if (currentStep === 2 && selectedApplicationId) { + if (event.type === 'change') { + rightPanelValidatedApplications.add(selectedApplicationId); + } const errors = updateRowValidation(selectedApplicationId); if (rightPanelValidatedApplications.has(selectedApplicationId)) { showStepTwoErrors(errors); From b16bcd45e3d68aef5c40de0940e355b61ac7dda9 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 08:45:05 -0700 Subject: [PATCH 02/45] feature/AB#34582-TightenApi --- .../ScheduledNotificationHelperTests.cs | 29 +++ .../FinancialNotificationSummaryWorker.cs | 5 +- .../ReconciliationProducer.cs | 5 +- .../CasPaymentRequestCoordinator.cs | 148 ++++++++++---- .../CasPaymentRequestCoordinatorTests.cs | 187 ++++++++++++++++++ .../Unity.SharedKernel.csproj | 1 + ...BackgroundExecutionAuthorizationHandler.cs | 27 +++ .../Utils/BackgroundJobContext.cs | 12 +- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 30 +++ .../Themes/UX2/Layouts/Account.cshtml | 7 + .../Themes/UX2/Layouts/Application.cshtml | 7 + .../Themes/UX2/Layouts/Empty.cshtml | 7 + .../Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj | 2 + .../ApplicantTenantMapReconciliationWorker.cs | 5 +- .../FiscalYearEndRolloverWorker.cs | 5 +- .../DateBasedScheduledNotificationJob.cs | 5 +- .../GrantManagerApplicationModule.cs | 6 +- .../GrantsPortalMessageCleanupWorker.cs | 6 +- .../ApplicantUpdateEmailRecoveryWorker.cs | 6 +- .../DataHealthCheckWorker.cs | 5 +- .../History/AuditLogCleanupWorker.cs | 6 +- .../History/HistoryAppService.cs | 3 + .../BackgroundWorkers/IntakeSyncWorker.cs | 5 +- .../Integrations/Css/CssApiService.cs | 12 +- .../Endpoints/EndpointManagementAppService.cs | 2 + .../Messaging/InboxWorkerBase.cs | 6 +- .../Messaging/OutboxWorkerBase.cs | 6 +- .../AutomatedNotificationAppService.cs | 2 + ...120000_AddQuartzMisfireOriginalFireTime.cs | 29 +++ .../FormNotificationsApiController.cs | 108 +++++----- .../GrantManagerWebModule.cs | 2 +- .../Pages/_ViewStart.cshtml | 6 + .../ApplicantAddressesController.cs | 3 + .../ApplicantContactsController.cs | 3 + .../ApplicantOrganizationInfoController.cs | 3 + 35 files changed, 551 insertions(+), 150 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs index 8a98d4bb5b..35c35d916d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs @@ -63,6 +63,35 @@ public void BuildTokenValues_ForApplicationTemplate_PreservesApplicationValues() values["unity_application_id"].ShouldBe("UNITY-456"); } + [Fact] + public void BuildTokenValues_ForDeclinedApplication_MapsDisplayDeclineRationale() + { + var application = new Application + { + Applicant = new Applicant { ApplicantName = "VelangTest2", OrgName = "DEC DESIGN" }, + ApplicationStatus = new ApplicationStatus { InternalStatus = "Declined" }, + ApplicationForm = new ApplicationForm { Category = "Community" }, + ReferenceNo = "SUB-123", + DeclineRational = "INELIGIBLE_PROJECT", + ProjectName = "Test project", + RequestedAmount = 1000m, + UnityApplicationId = "UNITY-123" + }; + var applicantAgent = new ApplicantAgent { Name = "Test Contact", Title = "Director" }; + + var values = ScheduledNotificationHelper.BuildTokenValues(application, applicantAgent); + + values["applicant_name"].ShouldBe("VelangTest2"); + values["organization_name"].ShouldBe("DEC DESIGN"); + values["decline_rationale"].ShouldBe("Ineligible Project"); + values["status"].ShouldBe("Declined"); + values["contact_full_name"].ShouldBe("Test Contact"); + values["contact_title"].ShouldBe("Director"); + values["category"].ShouldBe("Community"); + values["submission_number"].ShouldBe("SUB-123"); + values["unity_application_id"].ShouldBe("UNITY-123"); + } + [Fact] public void BuildTokenValues_ForApplicantTemplate_RendersMissingOptionalValuesAsEmpty() { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs index 84e5e516d0..e412444396 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs @@ -2,7 +2,6 @@ using System; using System.Threading.Tasks; using Unity.Payments.PaymentRequests.Notifications; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.SettingManagement; using Microsoft.Extensions.Logging; using Unity.Payments.PaymentRequests.BackgroundJobWorkers; @@ -13,7 +12,7 @@ namespace Unity.Payments.PaymentRequests; [DisallowConcurrentExecution] -public class FinancialNotificationSummaryWorker : QuartzBackgroundWorkerBase +public class FinancialNotificationSummaryWorker : UnityQuartzBackgroundWorkerBase { private readonly FinancialSummaryNotifier _financialSummaryNotifier; private readonly IEnumerable _strategies; @@ -45,7 +44,7 @@ public FinancialNotificationSummaryWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("FinancialNotificationSummary Execute"); await _financialSummaryNotifier.NotifyFailedPayments(_strategies); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs index 73c187eb55..923839903d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Quartz; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.SettingManagement; using Unity.Modules.Shared.Utils; using Unity.Payments.Settings; @@ -9,7 +8,7 @@ namespace Unity.Payments.PaymentRequests; [DisallowConcurrentExecution] -public class ReconciliationProducer : QuartzBackgroundWorkerBase +public class ReconciliationProducer : UnityQuartzBackgroundWorkerBase { private readonly CasPaymentRequestCoordinator _casPaymentRequestCoordinator; @@ -42,7 +41,7 @@ CasPaymentRequestCoordinator casPaymentRequestCoordinator } } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { await _casPaymentRequestCoordinator.AddPaymentRequestsToReconciliationQueue(); await Task.CompletedTask; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index 74f126b99a..a16fc69f34 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -13,6 +13,7 @@ using Unity.Payments.Events; using Unity.Payments.RabbitMQ.QueueMessages; using Unity.Notifications.Integrations.RabbitMQ; +using Volo.Abp.Data; using Volo.Abp.EventBus.Local; namespace Unity.Payments.PaymentRequests @@ -129,55 +130,134 @@ public async Task AddPaymentRequestsToReconciliationQueue() return null; } - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + const int maxConcurrencyAttempts = 3; + CasPaymentValues? originalCasValues = null; - var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); - var previousPaymentStatus = paymentRequest.PaymentStatus; - var previousStatus = paymentRequest.Status; + for (var attempt = 1; ; attempt++) + { + try + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); - UpdatePaymentRequestFromCasResult(paymentRequest, result); + var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + var previousPaymentStatus = paymentRequest.PaymentStatus; + var previousStatus = paymentRequest.Status; + originalCasValues ??= GetCurrentCasValues(paymentRequest); - await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); + var casValues = GetCasValues(paymentRequest, result); + ApplyCasValues(paymentRequest, casValues); - // CompleteAsync commits the transaction and calls SaveChangesAsync, - // which triggers AbpDbContext to collect entity changes into the active audit log. - // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns. - await uow.CompleteAsync(); + await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); - if (previousPaymentStatus != paymentRequest.PaymentStatus || previousStatus != paymentRequest.Status) - { - await localEventBus.PublishAsync(new PaymentStatusChangedEvent + // CompleteAsync commits the transaction and calls SaveChangesAsync, + // which triggers AbpDbContext to collect entity changes into the active audit log. + // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns. + await uow.CompleteAsync(); + + if (previousPaymentStatus != paymentRequest.PaymentStatus || previousStatus != paymentRequest.Status) + { + await localEventBus.PublishAsync(new PaymentStatusChangedEvent + { + PaymentRequestId = paymentRequest.Id, + ApplicationId = paymentRequest.CorrelationId, + Status = paymentRequest.Status, + CasPaymentStatus = paymentRequest.PaymentStatus, + TenantId = TenantId + }); + } + + return paymentRequest; + } + catch (AbpDbConcurrencyException ex) when (attempt < maxConcurrencyAttempts) { - PaymentRequestId = paymentRequest.Id, - ApplicationId = paymentRequest.CorrelationId, - Status = paymentRequest.Status, - CasPaymentStatus = paymentRequest.PaymentStatus, - TenantId = TenantId - }); - } + using var inspectionUow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var currentPaymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + var currentCasValues = GetCurrentCasValues(currentPaymentRequest); + var desiredCasValues = GetCasValues(currentPaymentRequest, result); + + if (currentCasValues == desiredCasValues) + { + return currentPaymentRequest; + } - return paymentRequest; + if (currentCasValues != originalCasValues) + { + Logger.LogWarning( + ex, + "Rejecting stale CAS result for payment request {PaymentRequestId}; the payment has a different CAS state.", + PaymentRequestId); + return currentPaymentRequest; + } + + Logger.LogWarning( + ex, + "Concurrency conflict updating payment request {PaymentRequestId} from CAS; retrying attempt {Attempt} of {MaxAttempts}.", + PaymentRequestId, + attempt + 1, + maxConcurrencyAttempts); + } + catch (AbpDbConcurrencyException) + { + throw; + } + } } - private static void UpdatePaymentRequestFromCasResult(PaymentRequest paymentRequest, CasPaymentSearchResult result) + private static CasPaymentValues GetCasValues(PaymentRequest paymentRequest, CasPaymentSearchResult result) { - // Handle duplicate NotFound status by appending "2" - if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && - result.InvoiceStatus == CasPaymentRequestStatus.NotFound) + var invoiceStatus = result.InvoiceStatus ?? string.Empty; + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound + "2" && + invoiceStatus == CasPaymentRequestStatus.NotFound) + { + invoiceStatus = paymentRequest.InvoiceStatus; + } + + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && + invoiceStatus == CasPaymentRequestStatus.NotFound) { - result.InvoiceStatus = CasPaymentRequestStatus.NotFound + "2"; + invoiceStatus = CasPaymentRequestStatus.NotFound + "2"; } - paymentRequest.SetInvoiceStatus(result.InvoiceStatus ?? ""); - paymentRequest.SetPaymentStatus(result.PaymentStatus ?? ""); - paymentRequest.SetPaymentDate(result.PaymentDate ?? ""); - paymentRequest.SetPaymentNumber(result.PaymentNumber ?? ""); - - if (result.InvoiceStatus != null) + return new CasPaymentValues( + invoiceStatus, + result.PaymentStatus ?? string.Empty, + result.PaymentNumber ?? string.Empty, + result.PaymentDate ?? string.Empty, + result.InvoiceStatus != null ? (int)System.Net.HttpStatusCode.OK : null, + result.InvoiceStatus != null ? "SUCCEEDED" : null); + } + + private static CasPaymentValues GetCurrentCasValues(PaymentRequest paymentRequest) + { + return new CasPaymentValues( + paymentRequest.InvoiceStatus ?? string.Empty, + paymentRequest.PaymentStatus ?? string.Empty, + paymentRequest.PaymentNumber ?? string.Empty, + paymentRequest.PaymentDate ?? string.Empty, + paymentRequest.CasHttpStatusCode, + paymentRequest.CasResponse); + } + + private static void ApplyCasValues(PaymentRequest paymentRequest, CasPaymentValues casValues) + { + paymentRequest.SetInvoiceStatus(casValues.InvoiceStatus); + paymentRequest.SetPaymentStatus(casValues.PaymentStatus); + paymentRequest.SetPaymentDate(casValues.PaymentDate); + paymentRequest.SetPaymentNumber(casValues.PaymentNumber); + + if (casValues.CasHttpStatusCode.HasValue) { - paymentRequest.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); - paymentRequest.SetCasResponse("SUCCEEDED"); + paymentRequest.SetCasHttpStatusCode(casValues.CasHttpStatusCode.Value); + paymentRequest.SetCasResponse(casValues.CasResponse ?? string.Empty); } } + + private readonly record struct CasPaymentValues( + string InvoiceStatus, + string PaymentStatus, + string PaymentNumber, + string PaymentDate, + int? CasHttpStatusCode, + string? CasResponse); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs new file mode 100644 index 0000000000..f155acee09 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Microsoft.Extensions.Logging.Abstractions; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Integrations.Cas; +using Unity.Notifications.Integrations.RabbitMQ; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Application.Services; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus.Local; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; +using Volo.Abp.Uow; +using Xunit; + +namespace Unity.Payments.PaymentRequests; + +public class CasPaymentRequestCoordinatorTests +{ + private static readonly Guid TenantId = Guid.NewGuid(); + private static readonly Guid PaymentRequestId = Guid.NewGuid(); + + [Fact] + public async Task UpdatePaymentRequestStatus_RetriesAfterConcurrencyConflict() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns(CreatePaymentRequest(), CreatePaymentRequest(), CreatePaymentRequest()); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync() + .Returns(Task.FromException(new AbpDbConcurrencyException()), Task.CompletedTask); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result.ShouldNotBeNull(); + result!.PaymentStatus.ShouldBe("Paid"); + _ = repository.Received(2).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(2).CompleteAsync(); + } + + [Fact] + public async Task UpdatePaymentRequestStatus_ReturnsCurrentRequestWhenResultIsAlreadyApplied() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns(CreatePaymentRequest(), CreatePaymentRequest(paymentStatus: "Paid")); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result!.PaymentStatus.ShouldBe("Paid"); + _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(1).CompleteAsync(); + } + + [Fact] + public async Task UpdatePaymentRequestStatus_RejectsStaleCasResult() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns(CreatePaymentRequest(), CreatePaymentRequest(invoiceStatus: "Paid")); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result!.InvoiceStatus.ShouldBe("Paid"); + _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(1).CompleteAsync(); + } + + [Fact] + public async Task UpdatePaymentRequestStatus_ThrowsAfterMaximumConcurrencyAttempts() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns( + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest()); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns( + Task.FromException(new AbpDbConcurrencyException()), + Task.FromException(new AbpDbConcurrencyException()), + Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + await Should.ThrowAsync(() => + coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult())); + + _ = unitOfWork.Received(3).CompleteAsync(); + } + + private static CasPaymentRequestCoordinator CreateCoordinator( + IPaymentRequestRepository repository, + IUnitOfWork unitOfWork) + { + var unitOfWorkManager = Substitute.For(); + unitOfWorkManager.Begin(Arg.Any(), Arg.Any()).Returns(unitOfWork); + + var coordinator = new TestableCasPaymentRequestCoordinator( + paymentQueueService: null!, + paymentRequestsRepository: repository, + unitOfWorkManager: unitOfWorkManager, + tenantRepository: Substitute.For(), + currentTenant: Substitute.For(), + localEventBus: Substitute.For()); + + var lazyServiceProvider = Substitute.For(); + lazyServiceProvider.LazyGetService(Arg.Any>()) + .Returns(NullLogger.Instance); + typeof(ApplicationService) + .GetProperty("LazyServiceProvider", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.SetValue(coordinator, lazyServiceProvider); + return coordinator; + } + + private sealed class TestableCasPaymentRequestCoordinator( + PaymentQueueService paymentQueueService, + IPaymentRequestRepository paymentRequestsRepository, + IUnitOfWorkManager unitOfWorkManager, + ITenantRepository tenantRepository, + ICurrentTenant currentTenant, + ILocalEventBus localEventBus) + : CasPaymentRequestCoordinator( + paymentQueueService, + paymentRequestsRepository, + unitOfWorkManager, + tenantRepository, + currentTenant, + localEventBus) + { } + + private static PaymentRequest CreatePaymentRequest( + string? invoiceStatus = null, + string? paymentStatus = null) + { + var paymentRequest = new PaymentRequest(PaymentRequestId, new CreatePaymentRequestDto + { + InvoiceNumber = "INV-123", + Amount = 100, + PayeeName = "Supplier", + ContractNumber = "CON-123", + SupplierNumber = "SUP-123", + SupplierName = "Supplier", + CorrelationId = Guid.NewGuid(), + SiteId = Guid.NewGuid(), + AccountCodingId = Guid.NewGuid(), + BatchName = "Batch" + }); + + if (invoiceStatus != null) + { + paymentRequest.SetInvoiceStatus(invoiceStatus); + } + + if (paymentStatus != null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + + return paymentRequest; + } + + private static CasPaymentSearchResult CreateCasResult() + { + return new CasPaymentSearchResult + { + InvoiceStatus = "Paid", + PaymentStatus = "Paid", + PaymentNumber = "PAY-123", + PaymentDate = "2026-10-13" + }; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj index bc3d9950fd..fb10e454cf 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj @@ -19,6 +19,7 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs new file mode 100644 index 0000000000..0ffec4ba42 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Unity.Modules.Shared.Utils; + +public static class BackgroundExecutionPolicy +{ + public const string Name = "Unity.BackgroundExecution"; +} + +public sealed class BackgroundExecutionRequirement : IAuthorizationRequirement; + +public sealed class BackgroundExecutionAuthorizationHandler + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + BackgroundExecutionRequirement requirement) + { + if (BackgroundJobExecutionContext.IsActive) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs index 78ce8c6609..b450a2c219 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -21,21 +21,19 @@ public static class BackgroundJobContext /// The current principal accessor for setting user identity /// The current tenant accessor for setting tenant context /// The tenant ID to set context for - /// Optional user ID. If null, uses BackgroundJobConstants.BackgroundJobPersonId /// IDisposable that restores previous context when disposed (LIFO order) public static IDisposable Set( IAuditingManager auditingManager, ICurrentPrincipalAccessor principalAccessor, ICurrentTenant currentTenant, - Guid? tenantId, - Guid? userId = null) + Guid? tenantId) { - var effectiveUserId = userId ?? BackgroundJobConstants.BackgroundJobPersonId; + var backgroundJobPersonId = BackgroundJobConstants.BackgroundJobPersonId; var claims = new[] { - new Claim(AbpClaimTypes.UserId, effectiveUserId.ToString()), - new Claim(ClaimTypes.NameIdentifier, effectiveUserId.ToString()), // Standard claim for user ID + new Claim(AbpClaimTypes.UserId, backgroundJobPersonId.ToString()), + new Claim(ClaimTypes.NameIdentifier, backgroundJobPersonId.ToString()), // Standard claim for user ID new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName), new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail), new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()), @@ -57,7 +55,7 @@ public static IDisposable Set( // Ensure the current audit log has the user ID set for entity change tracking if (auditingManager.Current != null) { - auditingManager.Current.Log.UserId = effectiveUserId; + auditingManager.Current.Log.UserId = backgroundJobPersonId; auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName; auditingManager.Current.Log.TenantId = tenantId; } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs new file mode 100644 index 0000000000..398f983ae5 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Establishes the trusted execution context for every Quartz background worker. +/// +public abstract class UnityQuartzBackgroundWorkerBase : QuartzBackgroundWorkerBase +{ + public sealed override async Task Execute(IJobExecutionContext context) + { + var auditingManager = ServiceProvider.GetRequiredService(); + var principalAccessor = ServiceProvider.GetRequiredService(); + var currentTenant = ServiceProvider.GetRequiredService(); + + using (BackgroundJobExecutionContext.Use()) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null)) + { + await ExecuteInBackgroundAsync(context); + } + } + + protected abstract Task ExecuteInBackgroundAsync(IJobExecutionContext context); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml index aa19019208..af732d59e4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml @@ -1,6 +1,8 @@ @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Analytics +@using System.Globalization @using Microsoft.Extensions.Localization @using Microsoft.Extensions.Options +@using Microsoft.Extensions.Hosting @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.MainNavbar @@ -18,6 +20,7 @@ @using Volo.Abp.Ui.LayoutHooks @inject IServiceProvider ServiceProvider +@inject IHostEnvironment HostEnvironment @inject IBrandingProvider BrandingProvider @inject IOptions MultiTenancyOptions @inject ICurrentTenant CurrentTenant @@ -119,5 +122,9 @@ } @await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Account) + @if (HostEnvironment.IsDevelopment()) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml index 60b57b1baa..80fdb56db9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Application.cshtml @@ -1,5 +1,7 @@ @using Unity.GrantManager.Analytics; +@using System.Globalization; @using System.Reflection; +@using Microsoft.Extensions.Hosting; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.ContentTitle; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.MainNavbar; @@ -19,6 +21,7 @@ @inject IBrandingProvider BrandingProvider @inject IPageLayout PageLayout @inject IAbpAntiForgeryManager AbpAntiForgeryManager +@inject IHostEnvironment HostEnvironment @{ Layout = null; var containerClass = "container-fluid"; @@ -158,5 +161,9 @@ @await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Application) + @if (HostEnvironment.IsDevelopment()) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml index 9bf7c310f0..f11f9a36e2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml @@ -1,5 +1,7 @@ @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling; +@using System.Globalization; @using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.PageAlerts; +@using Microsoft.Extensions.Hosting; @using Volo.Abp.AspNetCore.Mvc.UI.Components.LayoutHook; @using Volo.Abp.AspNetCore.Mvc.UI.Layout; @@ -12,6 +14,7 @@ @inject IBrandingProvider BrandingProvider @inject IPageLayout PageLayout +@inject IHostEnvironment HostEnvironment @{ Layout = null; var containerClass = ViewBag.FluidLayout? "container-fluid" : "container"; @@ -76,5 +79,9 @@ @await Component.InvokeAsync(typeof(WidgetScriptsViewComponent)) @await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Empty) + @if (HostEnvironment.IsDevelopment()) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj index 8e9a68da48..eeae6d41e2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj @@ -14,6 +14,8 @@ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs index d5f13cb6c5..e59b872461 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs @@ -5,13 +5,12 @@ using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.SettingManagement; namespace Unity.GrantManager.Applicants.BackgroundWorkers { [DisallowConcurrentExecution] - public class ApplicantTenantMapReconciliationWorker : QuartzBackgroundWorkerBase + public class ApplicantTenantMapReconciliationWorker : UnityQuartzBackgroundWorkerBase { private readonly IApplicantTenantMapReconciler _reconciler; private readonly ILogger _logger; @@ -87,7 +86,7 @@ public ApplicantTenantMapReconciliationWorker( /// encountered. /// The execution context for the job, providing runtime information and job-specific data. /// A task that represents the asynchronous execution of the job. - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { _logger.LogInformation("Executing ApplicantTenantMapReconciliationWorker..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs index dc48be0ae7..7951f2525c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs @@ -6,7 +6,6 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; @@ -15,7 +14,7 @@ namespace Unity.GrantManager.Applicants.BackgroundWorkers; [DisallowConcurrentExecution] -public class FiscalYearEndRolloverWorker : QuartzBackgroundWorkerBase +public class FiscalYearEndRolloverWorker : UnityQuartzBackgroundWorkerBase { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; @@ -71,7 +70,7 @@ public FiscalYearEndRolloverWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { _logger.LogInformation("Executing FiscalYearEndRolloverWorker..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs index be3c202bdb..fcc85970ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs @@ -14,7 +14,6 @@ using Unity.Notifications.Events; using Unity.Notifications.Settings; using Unity.Notifications.Templates; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; @@ -33,7 +32,7 @@ namespace Unity.GrantManager.Events /// ContractExecutionDate, FiscalYearEnd) have passed and sends emails accordingly. /// [DisallowConcurrentExecution] - public class DateBasedScheduledNotificationJob : QuartzBackgroundWorkerBase, ITransientDependency + public class DateBasedScheduledNotificationJob : UnityQuartzBackgroundWorkerBase, ITransientDependency { private readonly IRepository _scheduledNotificationRepository; private readonly IApplicationRepository _applicationRepository; @@ -128,7 +127,7 @@ public DateBasedScheduledNotificationJob( } } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { try { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 21da7aa288..b4ceab4826 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -40,9 +40,9 @@ using Unity.GrantManager.Infrastructure; using Medallion.Threading; using Unity.GrantManager.Locks; -using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; using Unity.GrantManager.Integrations.Chefs; using Unity.Modules.Shared.Http; +using Unity.Modules.Shared.Utils; using Unity.GrantManager.Integrations.Geocoder; using Unity.GrantManager.GrantsPortal; using Unity.GrantManager.GrantsPortal.Configuration; @@ -153,6 +153,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) }); context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddAuthorizationBuilder() + .AddPolicy(BackgroundExecutionPolicy.Name, policy => + policy.Requirements.Add(new BackgroundExecutionRequirement())); context.Services.AddTransient(); context.Services.AddTransient(); context.Services.AddTransient(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs index e1cd8a10f9..e8a9c6531b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs @@ -6,7 +6,7 @@ using Quartz; using Unity.GrantManager.GrantsPortal.Configuration; using Unity.GrantManager.Messaging; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal; @@ -16,7 +16,7 @@ namespace Unity.GrantManager.GrantsPortal; /// Runs against the central host database. /// [DisallowConcurrentExecution] -public class GrantsPortalMessageCleanupWorker : QuartzBackgroundWorkerBase +public class GrantsPortalMessageCleanupWorker : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; private readonly int _retentionDays; @@ -43,7 +43,7 @@ public GrantsPortalMessageCleanupWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogDebug("GrantsPortalMessageCleanupWorker executing (retention={RetentionDays} days)...", _retentionDays); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs index 058f75d065..8e94efc57b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs @@ -7,7 +7,7 @@ using Quartz; using Unity.Notifications.EmailNotifications; using Unity.Notifications.Emails; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.Features; using Volo.Abp.Linq; using Volo.Abp.MultiTenancy; @@ -21,7 +21,7 @@ namespace Unity.GrantManager.GrantsPortal.Notifications; /// Recovers the commit-to-queue gap. Delivery failures are retried by the existing EmailConsumer. /// [DisallowConcurrentExecution] -public class ApplicantUpdateEmailRecoveryWorker : QuartzBackgroundWorkerBase +public class ApplicantUpdateEmailRecoveryWorker : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; @@ -35,7 +35,7 @@ public ApplicantUpdateEmailRecoveryWorker(IServiceProvider serviceProvider) .WithMisfireHandlingInstructionIgnoreMisfires()).Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { using var scope = _serviceProvider.CreateScope(); var currentTenant = scope.ServiceProvider.GetRequiredService(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs index 23b42e4cdb..4f5a2abec3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs @@ -7,7 +7,6 @@ using Unity.Notifications.EmailNotifications; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Enums; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; @@ -15,7 +14,7 @@ namespace Unity.GrantManager.HealthChecks.BackgroundWorkers { [DisallowConcurrentExecution] - public class DataHealthCheckWorker : QuartzBackgroundWorkerBase + public class DataHealthCheckWorker : UnityQuartzBackgroundWorkerBase { private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; @@ -49,7 +48,7 @@ public DataHealthCheckWorker(ICurrentTenant currentTenant, .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("Executing DataHealthCheckWorker..."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs index e4c2c6aa60..320576eff9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs @@ -7,7 +7,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Logs; using Unity.GrantManager.Settings; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; @@ -17,7 +17,7 @@ namespace Unity.GrantManager.History; [DisallowConcurrentExecution] -public class AuditLogCleanupWorker : QuartzBackgroundWorkerBase, ITransientDependency +public class AuditLogCleanupWorker : UnityQuartzBackgroundWorkerBase, ITransientDependency { private readonly IServiceProvider _serviceProvider; private readonly ITenantRepository _tenantRepository; @@ -45,7 +45,7 @@ public AuditLogCleanupWorker( .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { var enabled = await _settingProvider.GetAsync( SettingsConstants.Retention.CleanupEnabled, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs index e5177775a9..5c2ef2d0c8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.AuditLogging; using Volo.Abp.Data; @@ -10,6 +12,7 @@ namespace Unity.GrantManager.History; +[Authorize(IdentityConsts.ITOperationsPolicyName)] public class HistoryAppService( IAuditLogRepository auditLogRepository, IIdentityUserRepository identityUserRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs index dd35d8aaf0..d51b694060 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs @@ -6,7 +6,6 @@ using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; using Unity.Notifications.EmailNotifications; -using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; @@ -14,7 +13,7 @@ namespace Unity.GrantManager.Intakes.BackgroundWorkers { [DisallowConcurrentExecution] - public class IntakeSyncWorker : QuartzBackgroundWorkerBase + public class IntakeSyncWorker : UnityQuartzBackgroundWorkerBase { private readonly ICurrentTenant _currentTenant; private readonly ITenantRepository _tenantRepository; @@ -49,7 +48,7 @@ public IntakeSyncWorker(ICurrentTenant currentTenant, .Build(); } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { Logger.LogInformation("Executing IntakeSyncWorker..."); var tenants = await _tenantRepository.GetListAsync(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs index c89a40c643..1c3fba6a3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Css/CssApiService.cs @@ -81,13 +81,12 @@ public async Task SearchUsersAsync(string directory, string? f return new UserSearchResult { Success = true, - Data = successfulResults + Data = [.. successfulResults .SelectMany(result => result.Data ?? []) .GroupBy(user => user.Attributes?.IdirUserGuid?.FirstOrDefault() ?? user.Username ?? user.Email) - .Select(group => group.First()) - .ToArray() + .Select(group => group.First())] }; } @@ -214,12 +213,7 @@ private async Task GetAccessTokenAsync() TokenValidationResponse tokenResponse; try { - tokenResponse = JsonSerializer.Deserialize(content, _jsonOptions)!; - - if (tokenResponse == null) - { - throw new UserFriendlyException("Invalid token response format"); - } + tokenResponse = JsonSerializer.Deserialize(content, _jsonOptions)! ?? throw new UserFriendlyException("Invalid token response format"); } catch (JsonException ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index dd02614dac..ba75c56913 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -13,6 +14,7 @@ namespace Unity.GrantManager.Integrations.Endpoints { + [Authorize(IdentityConsts.ITOperationsPolicyName)] public class EndpointManagementAppService( IRepository repository, IDistributedCache cache, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index c7440d1744..4d03ec81c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Quartz; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; @@ -19,7 +19,7 @@ namespace Unity.GrantManager.Messaging; /// Handlers are resolved from DI as filtered by . /// [DisallowConcurrentExecution] -public abstract class InboxWorkerBase : QuartzBackgroundWorkerBase +public abstract class InboxWorkerBase : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; @@ -68,7 +68,7 @@ protected InboxWorkerBase(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { try { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs index f41e23c52a..d351c39d89 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Quartz; -using Volo.Abp.BackgroundWorkers.Quartz; +using Unity.Modules.Shared.Utils; using Volo.Abp.Uow; namespace Unity.GrantManager.Messaging; @@ -17,7 +17,7 @@ namespace Unity.GrantManager.Messaging; /// via . /// [DisallowConcurrentExecution] -public abstract class OutboxWorkerBase : QuartzBackgroundWorkerBase +public abstract class OutboxWorkerBase : UnityQuartzBackgroundWorkerBase { private readonly IServiceProvider _serviceProvider; @@ -44,7 +44,7 @@ protected OutboxWorkerBase(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } - public override async Task Execute(IJobExecutionContext context) + protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context) { try { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs index cf5128eba7..0ed0fecc72 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; @@ -8,6 +9,7 @@ namespace Unity.GrantManager.Notifications { + [Authorize] public class AutomatedNotificationAppService(IRepository repository) : ApplicationService, IAutomatedNotificationAppService { private readonly IRepository _repository = repository; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs new file mode 100644 index 0000000000..ce3f6dfc39 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20261013120000_AddQuartzMisfireOriginalFireTime.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; + +using Unity.GrantManager.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations; + +[DbContext(typeof(GrantManagerDbContext))] +[Migration("20261013120000_AddQuartzMisfireOriginalFireTime")] +public partial class AddQuartzMisfireOriginalFireTime : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "misfire_orig_fire_time", + table: "qrtz_triggers", + type: "bigint", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "misfire_orig_fire_time", + table: "qrtz_triggers"); + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index a084d239e6..b4fe9df92e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -19,38 +19,22 @@ namespace Unity.GrantManager.Web.Controllers { + [Authorize] [Route("api/form-notifications")] [ApiController] - public class FormNotificationsApiController : ControllerBase + public class FormNotificationsApiController(IApplicationStatusService statusService, + IEmailGroupsAppService emailGroupsAppService, + ITemplateService templateService, + Notifications.IAutomatedNotificationAppService automatedNotificationAppService, + EmailAttachmentService emailAttachmentService, ICurrentUser currentUser, + IEmailGroupUsersAppService emailGroupUsersAppService, + IIdentityUserIntegrationService identityUserIntegrationService, + IGrantApplicationAppService grantApplicationAppService, + ScheduledNotificationHelper scheduledNotificationHelper, + IApplicantEmailTemplateAppService applicantEmailTemplateService, + EmailComposerAccessChecker emailAccessChecker) : ControllerBase { - private readonly IApplicationStatusService _statusService; - private readonly IEmailGroupsAppService _emailGroupsAppService; - private readonly ITemplateService _templateService; - private readonly Notifications.IAutomatedNotificationAppService _automatedNotificationAppService; - private readonly EmailAttachmentService _emailAttachmentService; - private readonly ICurrentUser _currentUser; - private readonly IEmailGroupUsersAppService _emailGroupUsersAppService; - private readonly IIdentityUserIntegrationService _identityUserIntegrationService; - private readonly IGrantApplicationAppService _grantApplicationAppService; - private readonly ScheduledNotificationHelper _scheduledNotificationHelper; - private readonly IApplicantEmailTemplateAppService _applicantEmailTemplateService; - private readonly EmailComposerAccessChecker _emailAccessChecker; - - public FormNotificationsApiController(IApplicationStatusService statusService, IEmailGroupsAppService emailGroupsAppService, Unity.Notifications.Templates.ITemplateService templateService, Unity.GrantManager.Notifications.IAutomatedNotificationAppService automatedNotificationAppService, EmailAttachmentService emailAttachmentService, ICurrentUser currentUser, IEmailGroupUsersAppService emailGroupUsersAppService, IIdentityUserIntegrationService identityUserIntegrationService, IGrantApplicationAppService grantApplicationAppService, ScheduledNotificationHelper scheduledNotificationHelper, IApplicantEmailTemplateAppService applicantEmailTemplateService, EmailComposerAccessChecker emailAccessChecker) - { - _statusService = statusService; - _emailGroupsAppService = emailGroupsAppService; - _templateService = templateService; - _automatedNotificationAppService = automatedNotificationAppService; - _emailAttachmentService = emailAttachmentService; - _currentUser = currentUser; - _emailGroupUsersAppService = emailGroupUsersAppService; - _identityUserIntegrationService = identityUserIntegrationService; - _grantApplicationAppService = grantApplicationAppService; - _scheduledNotificationHelper = scheduledNotificationHelper; - _applicantEmailTemplateService = applicantEmailTemplateService; - _emailAccessChecker = emailAccessChecker; - } + [HttpGet("payment-statuses")] public ActionResult> GetPaymentStatuses() @@ -80,7 +64,7 @@ public ActionResult> GetPaymentStatuses() [HttpGet("templates")] public async Task>> GetTemplates([FromQuery] string? templateType = null) { - var templates = await _templateService.GetTemplatesByTenant(); + var templates = await templateService.GetTemplatesByTenant(); if (!string.IsNullOrWhiteSpace(templateType)) { templates = templates @@ -107,13 +91,13 @@ public async Task>> GetTemplates([FromQuery] [HttpGet("templates/{templateId:guid}/applicant-preview")] public async Task> GetApplicantTemplatePreview(Guid templateId, [FromQuery] Guid applicantId) { - return Ok(await _applicantEmailTemplateService.GetPreviewAsync(applicantId, templateId)); + return Ok(await applicantEmailTemplateService.GetPreviewAsync(applicantId, templateId)); } [HttpGet("templates/{templateId:guid}/resolved-recipients")] public async Task> GetResolvedTemplateRecipients(Guid templateId, [FromQuery] Guid? applicationId = null) { - var template = await _templateService.GetTemplateById(templateId); + var template = await templateService.GetTemplateById(templateId); if (template == null) { return NotFound(); @@ -134,11 +118,11 @@ public async Task> GetResolvedTemplateRecipi RecipientIdentifier = recipientIdentifier }; - var emailTo = await _scheduledNotificationHelper.GetInternalRecipientEmailAddressesAsync( + var emailTo = await scheduledNotificationHelper.GetInternalRecipientEmailAddressesAsync( notification, - _emailGroupsAppService, - _emailGroupUsersAppService, - _identityUserIntegrationService); + emailGroupsAppService, + emailGroupUsersAppService, + identityUserIntegrationService); return Ok(new ResolvedRecipientsDto { EmailTo = emailTo }); } @@ -150,7 +134,7 @@ public async Task> GetResolvedTemplateRecipi return Ok(new ResolvedRecipientsDto { EmailTo = string.Empty }); } - var application = await _grantApplicationAppService.GetAsync(applicationId.Value); + var application = await grantApplicationAppService.GetAsync(applicationId.Value); var identifiers = recipientIdentifier .Split(',') .Select(r => r.Trim()) @@ -195,12 +179,12 @@ public async Task> CopyTemplateAttachme if (input.EmailLogId == Guid.Empty) return BadRequest("EmailLogId is required"); - var email = await _emailAccessChecker.CheckEmailAsync(input.EmailLogId, NotificationsPermissions.Email.Send, requireDraft: true); - await _emailAccessChecker.CheckTemplateAsync(templateId, email.ApplicantId); - var copiedCount = await _emailAttachmentService.ReplaceTemplateAttachmentsAsync( + var email = await emailAccessChecker.CheckEmailAsync(input.EmailLogId, NotificationsPermissions.Email.Send, requireDraft: true); + await emailAccessChecker.CheckTemplateAsync(templateId, email.ApplicantId); + var copiedCount = await emailAttachmentService.ReplaceTemplateAttachmentsAsync( templateId, input.EmailLogId, - _currentUser.TenantId); + currentUser.TenantId); return Ok(new CopyAttachmentsResponseDto { AttachmentCount = copiedCount }); } @@ -208,7 +192,7 @@ public async Task> CopyTemplateAttachme [HttpGet("email-template/{templateId}/validate-attachments")] public async Task ValidateTemplateAttachments(Guid templateId) { - await _emailAttachmentService.ValidateTemplateAttachmentsAsync(templateId); + await emailAttachmentService.ValidateTemplateAttachmentsAsync(templateId); return NoContent(); } @@ -216,15 +200,15 @@ public async Task ValidateTemplateAttachments(Guid templateId) [HttpDelete("email-log/{emailLogId}/origin-attachments")] public async Task> DeleteOriginAttachments(Guid emailLogId) { - await _emailAccessChecker.CheckEmailAsync(emailLogId, NotificationsPermissions.Email.Send, requireDraft: true); - var deletedCount = await _emailAttachmentService.DeleteOriginAttachmentsAsync(emailLogId); + await emailAccessChecker.CheckEmailAsync(emailLogId, NotificationsPermissions.Email.Send, requireDraft: true); + var deletedCount = await emailAttachmentService.DeleteOriginAttachmentsAsync(emailLogId); return Ok(new CopyAttachmentsResponseDto { AttachmentCount = deletedCount }); } [HttpGet("statuses")] public async Task>> GetApplicationStatuses() { - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var list = statuses.Select(s => new { id = s.Id, internalStatus = s.InternalStatus }).ToList(); return Ok(list); } @@ -235,7 +219,7 @@ public async Task>> GetRecipients([FromQuery] st // For internal recipients, load EmailGroups from the notifications module and expose their Name if (string.Equals(category, "Internal", StringComparison.OrdinalIgnoreCase)) { - var groups = await _emailGroupsAppService.GetListAsync(); + var groups = await emailGroupsAppService.GetListAsync(); var list = groups.Select(g => new RecipientDto { Id = g.Name, DisplayName = g.Name }).ToList(); return Ok(list); } @@ -257,17 +241,17 @@ public async Task>> GetForForm(strin { if (!Guid.TryParse(formId, out var parsedFormId)) return BadRequest("Invalid form id"); - var listResult = await _automatedNotificationAppService.GetListAsync(new Notifications.GetNotificationsInput { FormId = parsedFormId, MaxResultCount = 1000 }); + var listResult = await automatedNotificationAppService.GetListAsync(new Notifications.GetNotificationsInput { FormId = parsedFormId, MaxResultCount = 1000 }); // Resolve template names and status labels var templateIds = listResult.Items.Select(x => x.EmailTemplateId).Where(id => id != Guid.Empty).Distinct().ToList(); var templateMap = new Dictionary(); foreach (var id in templateIds) { - templateMap[id] = await _templateService.GetTemplateById(id); + templateMap[id] = await templateService.GetTemplateById(id); } - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var statusMap = statuses.ToDictionary(s => s.Id, s => s.InternalStatus); var items = listResult.Items.Select(e => new ScheduledNotificationDto @@ -319,7 +303,7 @@ public async Task> CreateForForm(string f return BadRequest("ApplicationStatusId required for Application event trigger"); } - var template = await _templateService.GetTemplateById(input.TemplateId); + var template = await templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); if (!string.Equals(template.TemplateType, TemplateTypes.Application, StringComparison.OrdinalIgnoreCase)) { @@ -331,7 +315,7 @@ public async Task> CreateForForm(string f string? statusLabel = null; if (input.ApplicationStatusId.HasValue) { - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var match = statuses.FirstOrDefault(s => s.Id == input.ApplicationStatusId.Value); if (match != null) statusLabel = match.InternalStatus; } @@ -353,7 +337,7 @@ public async Task> CreateForForm(string f RecipientIdentifier = input.RecipientIdentifier }; - var created = await _automatedNotificationAppService.CreateAsync(createDto); + var created = await automatedNotificationAppService.CreateAsync(createDto); var dto = new ScheduledNotificationDto { @@ -378,7 +362,7 @@ public async Task> CreateForForm(string f [HttpDelete("{formId}/{id:guid}")] public async Task Delete(string formId, Guid id) { - await _automatedNotificationAppService.DeleteAsync(id); + await automatedNotificationAppService.DeleteAsync(id); return NoContent(); } @@ -386,14 +370,14 @@ public async Task Delete(string formId, Guid id) public async Task CancelNotification(string formId, Guid id) { if (!Guid.TryParse(formId, out _)) return BadRequest("Invalid form id"); - await _automatedNotificationAppService.CancelAsync(id); + await automatedNotificationAppService.CancelAsync(id); return NoContent(); } [HttpGet("can-delete-template/{templateId:guid}")] public async Task> CanDeleteTemplate(Guid templateId) { - var result = await _automatedNotificationAppService.GetListAsync( + var result = await automatedNotificationAppService.GetListAsync( new Notifications.GetNotificationsInput { MaxResultCount = 1000 }); var associatedPlans = result.Items @@ -423,9 +407,9 @@ public async Task> CanDeleteTemplate(Guid templateId) [HttpGet("template-notification-plans/{templateId:guid}")] public async Task> GetTemplateNotificationPlans(Guid templateId) { - var result = await _automatedNotificationAppService.GetListAsync( + var result = await automatedNotificationAppService.GetListAsync( new Notifications.GetNotificationsInput { MaxResultCount = 1000 }); - var template = await _templateService.GetTemplateById(templateId); + var template = await templateService.GetTemplateById(templateId); var templateName = template?.Name ?? "Template"; var planNames = result.Items @@ -447,11 +431,11 @@ public async Task> UpdateForForm(string f if (!ValidateModule(input, out var moduleError)) return BadRequest(moduleError); - var template = await _templateService.GetTemplateById(input.TemplateId); + var template = await templateService.GetTemplateById(input.TemplateId); if (template == null) return BadRequest("Template not found"); - var existingNotification = await _automatedNotificationAppService.GetAsync(id); - var existingTemplate = await _templateService.GetTemplateById(existingNotification.EmailTemplateId); + var existingNotification = await automatedNotificationAppService.GetAsync(id); + var existingTemplate = await templateService.GetTemplateById(existingNotification.EmailTemplateId); if (existingTemplate != null && !string.Equals(template.TemplateType, existingTemplate.TemplateType, StringComparison.OrdinalIgnoreCase)) { @@ -465,7 +449,7 @@ public async Task> UpdateForForm(string f string? statusLabel = null; if (input.ApplicationStatusId.HasValue) { - var statuses = await _statusService.GetListAsync(); + var statuses = await statusService.GetListAsync(); var match = statuses.FirstOrDefault(s => s.Id == input.ApplicationStatusId.Value); if (match != null) statusLabel = match.InternalStatus; } @@ -487,7 +471,7 @@ public async Task> UpdateForForm(string f RecipientIdentifier = input.RecipientIdentifier }; - var updated = await _automatedNotificationAppService.UpdateAsync(id, updateDto); + var updated = await automatedNotificationAppService.UpdateAsync(id, updateDto); var dto = new ScheduledNotificationDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index f5bd9a6963..32ea5f33c5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -506,7 +506,7 @@ private void ConfigureBundles() { Configure(options => { - options.Mode = BundlingMode.Bundle; + options.Mode = BundlingMode.BundleAndMinify; options.MinificationIgnoredFiles.Add("/js/notifications-realtime-client.js"); options.MinificationIgnoredFiles.Add("/Pages/UnityMessaging/Index.js"); options diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000000..a637c7b348 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,6 @@ +@using Volo.Abp.AspNetCore.Mvc.UI.Layout +@using Volo.Abp.AspNetCore.Mvc.UI.Theming +@inject IThemeManager ThemeManager +@{ + Layout = ThemeManager.CurrentTheme.GetLayout(StandardLayouts.Application); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs index 7890745e64..d59149d688 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/ApplicantAddressesController.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using System; using System.Threading.Tasks; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantAddresses { [ApiController] [Route("Widget/ApplicantAddresses")] + [Authorize(UnitySelector.ApplicantManagement.Addresses.Default)] public class ApplicantAddressesController : AbpController { [HttpGet] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs index 1e030c1977..f739fd5392 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantContacts/ApplicantContactsController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using System; using System.Threading.Tasks; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantContacts { [Route("Widget/ApplicantContacts")] + [Authorize(UnitySelector.ApplicantManagement.Contacts.Default)] public class ApplicantContactsController : AbpController { [HttpGet] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs index af4cbf1428..35e393c4fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/ApplicantOrganizationInfoController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; using System; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicantOrganizationInfo { [ApiController] [Route("Widget/ApplicantOrganizationInfo")] + [Authorize(UnitySelector.ApplicantManagement.ApplicantInfo.Default)] public class ApplicantOrganizationInfoController : AbpController { [HttpGet] From 70c0de2bfec473d9310717c8a2c135b7a6db2237 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 09:41:46 -0700 Subject: [PATCH 03/45] feature/AB#34582-TightenApi-Copilot --- .../CasPaymentRequestCoordinator.cs | 17 ++++++++++++ .../CasPaymentRequestCoordinatorTests.cs | 21 ++++++++++++++- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 27 +++++++++++++++++++ .../History/HistoryAppService.cs | 3 +-- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index a16fc69f34..f8a8c4c710 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -145,6 +145,23 @@ public async Task AddPaymentRequestsToReconciliationQueue() originalCasValues ??= GetCurrentCasValues(paymentRequest); var casValues = GetCasValues(paymentRequest, result); + if (attempt > 1) + { + var currentCasValues = GetCurrentCasValues(paymentRequest); + if (currentCasValues == casValues) + { + return paymentRequest; + } + + if (currentCasValues != originalCasValues) + { + Logger.LogWarning( + "Rejecting stale CAS result for payment request {PaymentRequestId}; the payment has a different CAS state.", + PaymentRequestId); + return paymentRequest; + } + } + ApplyCasValues(paymentRequest, casValues); await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs index f155acee09..ae88956239 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs @@ -9,7 +9,6 @@ using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Integrations.Cas; using Unity.Notifications.Integrations.RabbitMQ; -using Unity.Payments.PaymentRequests; using Volo.Abp.Application.Services; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; @@ -79,6 +78,26 @@ public async Task UpdatePaymentRequestStatus_RejectsStaleCasResult() _ = unitOfWork.Received(1).CompleteAsync(); } + [Fact] + public async Task UpdatePaymentRequestStatus_RejectsStateChangedBeforeRetryLoad() + { + var repository = Substitute.For(); + repository.GetAsync(PaymentRequestId) + .Returns( + CreatePaymentRequest(), + CreatePaymentRequest(), + CreatePaymentRequest(invoiceStatus: "Paid")); + var unitOfWork = Substitute.For(); + unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException())); + var coordinator = CreateCoordinator(repository, unitOfWork); + + var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()); + + result!.InvoiceStatus.ShouldBe("Paid"); + _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = unitOfWork.Received(1).CompleteAsync(); + } + [Fact] public async Task UpdatePaymentRequestStatus_ThrowsAfterMaximumConcurrencyAttempts() { diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs index 398f983ae5..e9083648eb 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Quartz; @@ -16,14 +17,40 @@ public abstract class UnityQuartzBackgroundWorkerBase : QuartzBackgroundWorkerBa public sealed override async Task Execute(IJobExecutionContext context) { var auditingManager = ServiceProvider.GetRequiredService(); + var auditingStore = ServiceProvider.GetRequiredService(); var principalAccessor = ServiceProvider.GetRequiredService(); var currentTenant = ServiceProvider.GetRequiredService(); using (BackgroundJobExecutionContext.Use()) using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null)) { + AddWorkerAuditAction(auditingManager, context); await ExecuteInBackgroundAsync(context); + + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + { + using (currentTenant.Change(null)) + { + await auditingStore.SaveAsync(log); + } + } + } + } + + private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext context) + { + if (auditingManager.Current?.Log == null) + { + return; } + + auditingManager.Current.Log.Actions.Add(new AuditLogActionInfo + { + ServiceName = GetType().FullName ?? GetType().Name, + MethodName = nameof(ExecuteInBackgroundAsync), + Parameters = System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }), + ExecutionTime = DateTime.UtcNow + }); } protected abstract Task ExecuteInBackgroundAsync(IJobExecutionContext context); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs index 5c2ef2d0c8..4c8b505df5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/HistoryAppService.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.AuditLogging; using Volo.Abp.Data; @@ -12,7 +11,7 @@ namespace Unity.GrantManager.History; -[Authorize(IdentityConsts.ITOperationsPolicyName)] +[Authorize] public class HistoryAppService( IAuditLogRepository auditLogRepository, IIdentityUserRepository identityUserRepository, From 127b39b07187b3b716251cf7ce9a7c1f16bd9adc Mon Sep 17 00:00:00 2001 From: nikhila-aot Date: Wed, 14 Oct 2026 09:58:04 -0700 Subject: [PATCH 04/45] changes to fix total currency summary issue --- .../Shared/Components/DataGridWidget/DataGridWidget.cs | 9 +++++---- documentation/flex/flex-datagrid.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs index fd735cc689..dc7b0e6295 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs @@ -117,7 +117,7 @@ private IViewComponentResult GenerateGridView(WorksheetFieldViewModel fieldModel Rows = [.. dataRows], AllowEdit = true, SummaryOption = ConvertSummaryOption(dataGridDefinition), - Summary = GenerateSummary([.. dataColumns], [.. dataRows], presentationSetttings), + Summary = GenerateSummary([.. dataColumns], dataGridRowsValue?.Rows ?? [], presentationSetttings), TableOptions = GenerateAvailableTableOptions(!dataGridDefinition.Dynamic), WorksheetId = worksheetId, WorksheetInstanceId = worksheetInstanceId, @@ -367,7 +367,7 @@ private static List GenerateDynamicPlaceholderColumn() } private static DataGridViewSummary GenerateSummary(DataGridColumn[]? dataColumns, - DataGridViewModelRow[] rows, + List rows, PresentationSettings presentationSettings) { var summary = new DataGridViewSummary(); @@ -386,10 +386,11 @@ private static DataGridViewSummary GenerateSummary(DataGridColumn[]? dataColumns return summary; } - private static string SumCells(string? key, DataGridViewModelRow[] rows) + // Sum the raw stored values - presentation-formatted values (e.g. "CA$ 567.00") do not parse back to decimals + private static string SumCells(string? key, List rows) { decimal sum = 0; - var cells = rows.Select(row => row.Cells.Find(x => x.Key == key)).Where(cell => cell != null); + var cells = rows.Select(row => row.Cells.Find(x => x.Key == key)).Where(cell => cell?.Value != null); foreach (var cell in cells) { var preparse = cell!.Value.Replace("$", "").Replace(",", ""); diff --git a/documentation/flex/flex-datagrid.md b/documentation/flex/flex-datagrid.md index 9b81f3a84d..01611a0077 100644 --- a/documentation/flex/flex-datagrid.md +++ b/documentation/flex/flex-datagrid.md @@ -32,7 +32,7 @@ DataGrid columns support a curated subset of `CustomFieldType` (not all fifteen) - `DataGridDefinition.SummaryOption`: `None` / `Above` / `Below` — controls whether, and where, a summary row renders relative to the grid. - Only **Numeric** and **Currency** columns are summable (`DataGridWidget._validTotalSummaryTypes`) — each gets its own `Total: {ColumnName}` summary field. -- `DataGridWidget.SumCells` sums every row's cell for that column as `decimal` (stripping `$` and `,` before parsing), clamped at `decimal.MaxValue` to avoid overflow rather than throwing. +- `DataGridWidget.SumCells` sums every row's **raw stored** cell value for that column (not the presentation-formatted value, e.g. `CA$ 567.00`, which would not parse) as `decimal` (stripping `$` and `,` before parsing), clamped at `decimal.MaxValue` to avoid overflow rather than throwing. - A dynamic grid with no resolvable columns yet shows a placeholder `Total: Dynamic` summary field in preview, since real column types aren't known until CHEFS data is actually mapped. ## Reporting behavior From 6486c0a5b481a3fd673b2f9dd9d08b1ab8f40aee Mon Sep 17 00:00:00 2001 From: nikhila-aot Date: Wed, 14 Oct 2026 09:59:01 -0700 Subject: [PATCH 05/45] [AB#34424] Ignore programmatic change events when loading an application writeVisibleEditor triggers change on EmailFrom via setEmailFromValue, which marked every opened application as validated. Flag editor hydration so only user edits surface inline field errors. Co-Authored-By: Claude Opus 5.5 --- .../ComposeAndSendEmailModal.js | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js index 29652845e1..b26e97f459 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ComposeAndSendEmailModal.js @@ -15,6 +15,7 @@ let editor = null; let variablesButtonApi = null; let sendConfirmed = false; + let isHydratingEditor = false; let rightPanelValidatedApplications = new Set(); function parseApplications() { @@ -97,14 +98,20 @@ } function writeVisibleEditor(state) { - $('#EmailTo').val(state.emailTo || ''); - $('#EmailCC').val(state.emailCC || ''); - $('#EmailBCC').val(state.emailBCC || ''); - setEmailFromValue(state.emailFrom || ''); - $('#EmailSubject').val(state.emailSubject || ''); - $('#EmailTemplate').val(state.templateId || ''); - $('#EmailTemplateName').val(state.templateName || ''); - setEditorBody(state.emailBody || ''); + // setEmailFromValue triggers a change event; flag it so loading an application isn't treated as a user edit. + isHydratingEditor = true; + try { + $('#EmailTo').val(state.emailTo || ''); + $('#EmailCC').val(state.emailCC || ''); + $('#EmailBCC').val(state.emailBCC || ''); + setEmailFromValue(state.emailFrom || ''); + $('#EmailSubject').val(state.emailSubject || ''); + $('#EmailTemplate').val(state.templateId || ''); + $('#EmailTemplateName').val(state.templateName || ''); + setEditorBody(state.emailBody || ''); + } finally { + isHydratingEditor = false; + } $('#btn-save-top').prop('disabled', true); } @@ -954,7 +961,7 @@ syncCurrentState(); if (currentStep === 2 && selectedApplicationId) { // A committed edit (change fires on blur) surfaces inline field errors for this application. - if (event.type === 'change') { + if (event.type === 'change' && !isHydratingEditor) { rightPanelValidatedApplications.add(selectedApplicationId); } const errors = updateRowValidation(selectedApplicationId); @@ -1114,7 +1121,7 @@ tinyEditor.on('input change undo redo', function (event) { syncCurrentState(); if (currentStep === 2 && selectedApplicationId) { - if (event.type === 'change') { + if (event.type === 'change' && !isHydratingEditor) { rightPanelValidatedApplications.add(selectedApplicationId); } const errors = updateRowValidation(selectedApplicationId); From b6eac16254dabca6ffc8303b5392f652f7b2828d Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 14 Oct 2026 10:04:26 -0700 Subject: [PATCH 06/45] AB#33423: Fix Cross-Site Scripting in Notification Preview --- .../Components/Notifications/Default.js | 8 +- .../formConfiguration/NotificationsPreview.js | 8 +- .../NotificationTemplatePreview.test.cjs | 133 ++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/NotificationTemplatePreview.test.cjs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js index d488b4f1a6..93cf0d8a04 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js @@ -618,7 +618,13 @@ subjectRow.append(subjectLabel, subjectValue); const bodyRow = document.createElement('div'); - bodyRow.innerHTML = resolveTemplatePreviewBody(template); + const bodyHtml = resolveTemplatePreviewBody(template); + // Sanitize after decoding so encoded markup cannot bypass the sanitizer. + if (globalThis.DOMPurify?.isSupported) { + bodyRow.innerHTML = globalThis.DOMPurify.sanitize(bodyHtml, { USE_PROFILES: { html: true } }); + } else { + bodyRow.textContent = bodyHtml; + } previewElement.append(subjectRow, document.createElement('br'), bodyRow); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/NotificationsPreview.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/NotificationsPreview.js index 6e12dea3ab..af8bf8a1a6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/NotificationsPreview.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/formConfiguration/NotificationsPreview.js @@ -80,7 +80,13 @@ subjectRow.append(subjectLabel, subjectValue); const bodyRow = document.createElement('div'); - bodyRow.innerHTML = resolveTemplateBodyHtml(template); + const bodyHtml = resolveTemplateBodyHtml(template); + // Sanitize after decoding so encoded markup cannot bypass the sanitizer. + if (globalThis.DOMPurify?.isSupported) { + bodyRow.innerHTML = globalThis.DOMPurify.sanitize(bodyHtml, { USE_PROFILES: { html: true } }); + } else { + bodyRow.textContent = bodyHtml; + } previewElement.append(subjectRow, document.createElement('br'), bodyRow); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/NotificationTemplatePreview.test.cjs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/NotificationTemplatePreview.test.cjs new file mode 100644 index 0000000000..d48cf7869e --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/NotificationTemplatePreview.test.cjs @@ -0,0 +1,133 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const webRoot = path.resolve(__dirname, '../../../src/Unity.GrantManager.Web'); +const previews = [ + { name: 'form configuration', file: 'wwwroot/js/formConfiguration/NotificationsPreview.js' }, + { name: 'notifications widget', file: 'Views/Shared/Components/Notifications/Default.js', privateRenderer: true } +]; + +// These adapters test the integration with the sanitizer, not DOMPurify itself. +// Parser fixtures explicitly supply the text a browser would return; no mock +// HTML parser or sanitizer is used to claim that an attack payload is safe. +function previewHarness(preview, { decodedText = new Map(), sanitizer = 'supported', sanitizedHtml = '' } = {}) { + const htmlWrites = []; + const sanitizerCalls = []; + function element(tagName) { + return { + tagName, children: [], textContent: '', + append(...children) { this.children.push(...children); }, + replaceChildren(...children) { this.children = children; }, + set innerHTML(value) { htmlWrites.push({ element: this, value }); } + }; + } + const context = { + document: { createElement: element, addEventListener() {} }, + DOMParser: class { + parseFromString(value, type) { + assert.equal(type, 'text/html'); + assert.ok(decodedText.has(value), `Missing parser fixture: ${value}`); + return { documentElement: { textContent: decodedText.get(value) } }; + } + } + }; + if (sanitizer !== 'missing') { + context.DOMPurify = { + isSupported: sanitizer === 'supported', + sanitize(html, options) { + assert.equal(sanitizer, 'supported', 'Unsupported sanitizer must not be called'); + sanitizerCalls.push({ html, options: JSON.parse(JSON.stringify(options)) }); + return sanitizedHtml; + } + }; + } + + let source = readFileSync(path.join(webRoot, preview.file), 'utf8'); + if (preview.privateRenderer) { + // Expose the existing private renderer only inside this test's VM. + // The production function body and its helpers execute unchanged. + assert.ok(source.startsWith('(function () {')); + source = source.replace('(function () {', + '(function () { globalThis.renderNotificationTemplatePreview = renderTemplatePreview;'); + } + vm.runInNewContext(source, context, { filename: preview.file }); + const container = element('div'); + return { + container, htmlWrites, sanitizerCalls, + render(template, target = container) { context.renderNotificationTemplatePreview(target, template); } + }; +} + +const rawBody = '

Hello team

Link'; +const cleanBody = '

Hello team

Link'; +const bodyText = 'Hello teamLinkalert(2)'; +function encode(value) { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} +const encodedBody = encode(rawBody); +const doubleEncodedBody = encode(encodedBody); +const decodedText = new Map([ + [rawBody, bodyText], [bodyText, bodyText], + [encodedBody, rawBody], [doubleEncodedBody, encodedBody] +]); + +for (const preview of previews) { + for (const [label, body] of [['raw', rawBody], ['encoded', encodedBody], ['double encoded', doubleEncodedBody]]) { + test(`${preview.name}: sanitizes ${label} HTML after decoding and inserts only sanitizer output`, () => { + const view = previewHarness(preview, { decodedText, sanitizedHtml: cleanBody }); + const subject = ''; + view.render({ subject, body }); + assert.deepEqual(view.sanitizerCalls, [{ html: rawBody, options: { USE_PROFILES: { html: true } } }]); + assert.equal(view.htmlWrites.length, 1); + assert.equal(view.htmlWrites[0].element, view.container.children[2]); + assert.equal(view.htmlWrites[0].value, cleanBody); + assert.equal(view.container.children[0].children[1].textContent, subject); + }); + } + + for (const sanitizer of ['missing', 'unsupported']) { + test(`${preview.name}: renders decoded markup as text when DOMPurify is ${sanitizer}`, () => { + const view = previewHarness(preview, { decodedText, sanitizer }); + view.render({ body: doubleEncodedBody }); + assert.equal(view.htmlWrites.length, 0, 'Fallback must never write raw HTML'); + assert.equal(view.sanitizerCalls.length, 0); + assert.equal(view.container.children[2].textContent, rawBody); + }); + } + + test(`${preview.name}: preserves supported body and subject property names`, () => { + for (const key of ['body', 'bodyHtml', 'bodyHTML', 'Body', 'BodyHtml', 'BodyHTML']) { + const view = previewHarness(preview, { decodedText, sanitizedHtml: cleanBody }); + view.render({ Subject: 'Subject', [key]: rawBody }); + assert.equal(view.sanitizerCalls[0].html, rawBody, key); + assert.equal(view.htmlWrites[0].value, cleanBody, key); + assert.equal(view.container.children[0].children[1].textContent, 'Subject', key); + } + }); + + test(`${preview.name}: preserves plain text and clears the previous preview`, () => { + const plainText = 'Hello team'; + const view = previewHarness(preview, { + decodedText: new Map([[plainText, plainText]]), sanitizedHtml: plainText + }); + view.container.append({ stale: true }); + view.render({ body: plainText }); + assert.equal(view.container.children.length, 3); + assert.equal(view.htmlWrites[0].value, plainText); + assert.equal(view.sanitizerCalls[0].html, plainText); + view.render(null); + assert.equal(view.container.children.length, 0); + assert.equal(view.sanitizerCalls.length, 1); + assert.doesNotThrow(() => view.render({ body: rawBody }, null)); + }); + + test(`${preview.name}: handles an empty template body`, () => { + const view = previewHarness(preview); + view.render({ subject: 'Empty body' }); + assert.equal(view.sanitizerCalls[0].html, ''); + assert.equal(view.htmlWrites[0].value, ''); + }); +} From ef3d1a31ede14f1422060522358cc7c0ebfef887 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 14 Oct 2026 10:53:40 -0700 Subject: [PATCH 07/45] AB#33423: Fix Cross-Site Scripting on Scoresheet Select List --- .../Components/Scoresheet/Scoresheet.js | 4 +- .../JavaScript/ScoresheetPreview.test.cjs | 149 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js index 6e2bc1c12a..ab5d598200 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js @@ -283,7 +283,9 @@ $(function () { let optionsHTML = ``; optionsHTML += options.map(option => { const truncatedValue = option.value.length > 100 ? option.value.substring(0, 100) + " ..." : option.value; - return ``; + // Encode option text and attributes before the preview is parsed as HTML. + const escapedValue = escapeHtml(option.value); + return ``; }).join(''); let req = item.dataset.required === 'True' ? "required" : null; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs new file mode 100644 index 0000000000..6c002f5324 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs @@ -0,0 +1,149 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const source = readFileSync(path.resolve(__dirname, + '../../../src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js'), 'utf8'); + +// Execute the actual ready/refresh handlers and capture the HTML written to the +// preview. These adapters do not parse HTML or simulate browser script execution. +function preview(options, previewId = 'scoresheet-preview') { + const readyHandlers = []; + const subscriptions = new Map(); + const htmlWrites = []; + const section = { + innerText: 'Section', dataset: { label: 'Section' }, + classList: { contains: name => name === 'section-item' } + }; + const question = { + innerText: 'Select question', + dataset: { + id: '11111111-1111-1111-1111-111111111111', questiontype: 'SelectList', + required: 'True', questionlabel: '', questiondesc: '', + definition: JSON.stringify({ options }) + }, + classList: { contains: () => false } + }; + const expandedBody = { + querySelectorAll(selector) { + assert.equal(selector, '.list-group-item'); + return [section, question]; + } + }; + const nodes = new Map([ + [previewId, { set innerHTML(html) { htmlWrites.push(html); } }], + ['scoresheet-accordion', {}], + ['previewSaveAllBtn', {}], + ['previewDiscardAllBtn', {}] + ]); + const context = { + $(handler) { assert.equal(typeof handler, 'function'); readyHandlers.push(handler); }, + document: { + getElementById(id) { return nodes.get(id) || null; }, + querySelectorAll(selector) { + if (selector === '#scoresheet-accordion .accordion-collapse.show') return [expandedBody]; + assert.ok(['[id^="sections-questions"]', '#scoresheet-accordion .accordion-button'].includes(selector)); + return []; + } + }, + Sortable: class {}, + abp: { ModalManager: class { onResult() {} } }, + PubSub: { subscribe(topic, handler) { subscriptions.set(topic, handler); } }, + updateSubtotal() {} + }; + vm.runInNewContext(source, context, { filename: 'Scoresheet.js' }); + readyHandlers.forEach(handler => handler()); + return { + get html() { return htmlWrites.at(-1); }, + get writes() { return htmlWrites.length; }, + refresh(nextOptions) { + question.dataset.definition = JSON.stringify({ options: nextOptions }); + subscriptions.get('refresh_scoresheet_configuration_page')(); + } + }; +} + +const placeholder = ''; + +function assertOption(html, { value, label = value, score = '0' }) { + assert.ok(html.includes(``), + 'Expected encoded attributes and text in the generated option'); +} + +const textCases = [ + { + name: 'literal punctuation', + value: `A & B < C > D "quoted" 'single'`, + encoded: `A & B < C > D "quoted" 'single'` + }, + { + name: 'markup and attribute breakout', + value: '">' + + ' data-feature-name="' + abp.utils.htmlEscape(feature.name) + '"' + checked + '>' + '' + + abp.utils.htmlEscape(feature.displayName || feature.name) + '' + ''; } @@ -504,8 +504,8 @@ } let html = ''; groups.forEach(function (group) { - html += '
'; - html += '
' + (group.displayName || group.name) + '
'; + html += '
'; + html += '
' + abp.utils.htmlEscape(group.displayName || group.name) + '
'; group.features?.forEach(function (feature) { html += _renderFeatureItem(feature); }); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/MetadataHtmlRendering.test.cjs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/MetadataHtmlRendering.test.cjs new file mode 100644 index 0000000000..1ff9e3b4a8 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/MetadataHtmlRendering.test.cjs @@ -0,0 +1,207 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const root = path.resolve(__dirname, '../../..'); +const abpSource = readFileSync(path.join(root, + 'src/Unity.GrantManager.Web/node_modules/@abp/core/src/abp.js'), 'utf8'); +const reportingRoot = 'modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/'; +const tenantFile = 'modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js'; + +// Isolate existing functions without starting unrelated page initialization. +// Read their complete declarations, including nested callbacks, from production +// source. These files indent function-closing braces by four spaces. +function loadFunctions(context, file, names) { + const source = readFileSync(path.join(root, file), 'utf8').replaceAll('\r\n', '\n'); + for (const name of names) { + const start = source.indexOf(` function ${name}(`); + assert.ok(start >= 0, `Missing production function: ${name}`); + const end = source.indexOf('\n }\n', start); + assert.ok(end > start, `Missing end of production function: ${name}`); + vm.runInContext(source.slice(start, end + 6), context, { filename: file }); + } +} + +// Use the installed ABP encoder, not a mock sanitizer. The DOM/AJAX adapters +// capture writes and callbacks; they do not simulate browser script execution. +function harness(response = {}, { fail = false } = {}) { + const nodes = new Map(); + const requests = []; + let modalShows = 0; + const context = vm.createContext({ + document: { querySelector: () => null, createElement: () => ({ classList: { add() {} } }) } + }); + vm.runInContext(abpSource, context, { filename: 'abp.js' }); + function node(selector) { + if (!nodes.has(selector)) { + const item = { + 0: {}, textValue: '', htmlValue: '', htmlWrites: [], visible: false, + text(value) { this.textValue = String(value); return this; }, + html(value) { + if (!arguments.length) return context.abp.utils.htmlEscape(this.textValue); + this.htmlValue = value; + this.htmlWrites.push(value); + return this; + }, + show() { this.visible = true; return this; }, + hide() { this.visible = false; return this; } + }; + nodes.set(selector, item); + } + return nodes.get(selector); + } + context.$ = node; + context.bootstrap = { Modal: class { show() { modalShows++; } } }; + context.getCorrelationProvider = () => 'formversion'; + context._createFeatureProviderKey = 'create-provider-key'; + context.abp.ajax = options => { + requests.push(options); + const result = { + done(callback) { if (!fail) callback(response); return result; }, + fail(callback) { if (fail) callback({ status: 500 }); return result; } + }; + return result; + }; + return { context, node, requests, get modalShows() { return modalShows; } }; +} + +function reportingPreview(response) { + const view = harness(); + loadFunctions(view.context, reportingRoot + 'ReportingConfigurationViewStatus/Default.js', ['showViewDataModal']); + view.context.showViewDataModal(response); + return view; +} + +test('reporting delete dialog treats an API-provided view name as literal text', () => { + const viewName = ' & "view"'; + const view = harness({ viewName }); + loadFunctions(view.context, reportingRoot + 'ReportingConfiguration/Default.js', ['updateDeleteModalContent']); + let callbacks = 0; + view.context.updateDeleteModalContent('correlation-id', () => callbacks++); + const item = view.node('#deleteViewListItem'); + assert.equal(item.textValue, `The associated database view (${viewName})`); + assert.equal(item.htmlWrites.length, 0); + assert.equal(item.visible, true); + assert.equal(callbacks, 1); + assert.equal(view.requests[0].data.correlationProvider, 'formversion'); +}); + +test('reporting delete dialog hides the view entry when absent or the request fails', () => { + for (const [response, options] of [[{}, {}], [{ viewName: ' ' }, {}], [{}, { fail: true }]]) { + const view = harness(response, options); + loadFunctions(view.context, reportingRoot + 'ReportingConfiguration/Default.js', ['updateDeleteModalContent']); + view.node('#deleteViewListItem').show(); + let callbacks = 0; + view.context.updateDeleteModalContent('correlation-id', () => callbacks++); + assert.equal(view.node('#deleteViewListItem').visible, false); + assert.equal(callbacks, 1); + } +}); + +test('reporting preview escapes column headers and still looks up rows by the original key', () => { + const column = ' & "column"'; + const view = reportingPreview({ viewName: 'View', columns: [column], data: [{ [column]: 'Correct cell' }], totalCount: 1 }); + const html = view.node('#viewDataPreviewContent').htmlValue; + assert.ok(html.includes('<img src=x onerror=alert(1)> & "column"')); + assert.ok(html.includes('Correct cell')); + assert.equal(html.includes(' { + const view = reportingPreview({ viewName: 'View', columns: ['column'], data: [{ column: 5 }], totalCount: '' }); + const html = view.node('#viewDataPreviewContent').htmlValue; + assert.ok(html.includes('<img src=x onerror=alert(1)> records')); + assert.equal(html.includes(' { + for (const totalCount of [1, 2]) { + const view = reportingPreview({ viewName: 'View', columns: ['column'], data: [{ column: 0 }], totalCount }); + const html = view.node('#viewDataPreviewContent').htmlValue; + assert.ok(html.includes(`${totalCount} record${totalCount === 1 ? '' : 's'} from this application`)); + assert.ok(html.includes('0')); + } + const empty = reportingPreview({ viewName: 'View', columns: [], data: [], totalCount: 0 }); + assert.ok(empty.node('#viewDataPreviewContent').htmlValue.includes('No preview data available.')); + assert.equal(empty.node('#viewDataPreviewModalLabel').textValue, 'View Data Preview: View (Sample Data)'); +}); + +test('reporting preview keeps existing data-cell escaping and null handling', () => { + const view = reportingPreview({ viewName: 'View', columns: ['a', 'b'], data: [{ a: '', b: null }], totalCount: 1 }); + const html = view.node('#viewDataPreviewContent').htmlValue; + assert.ok(html.includes('<img src=x>')); +}); + +function featureView(groups) { + const view = harness({ groups }); + loadFunctions(view.context, tenantFile, ['_renderFeatureItem', '_renderFeatureGroups', '_loadFeaturesTab', '_loadCreateFeaturesTab']); + return view; +} + +test('tenant feature rendering escapes group and feature display names', () => { + const view = featureView([]); + const html = view.context._renderFeatureGroups([{ + name: 'Group', displayName: '', + features: [{ name: 'Unity.Flex', displayName: 'A & B', value: 'True' }] + }]); + assert.ok(html.includes('<img src=x onerror=alert(1)>')); + assert.ok(html.includes('<b>A & B</b>')); + assert.equal(html.includes(''), false); +}); + +test('tenant feature rendering escapes identifiers in double-quoted attributes', () => { + const view = featureView([]); + const html = view.context._renderFeatureGroups([{ + name: 'Group" onclick="alert(1)', displayName: 'Group', + features: [{ name: 'Unity.Flex" onfocus="alert(2)', displayName: 'Feature', value: 'false' }] + }]); + assert.ok(html.includes('data-feature-group="Group" onclick="alert(1)"')); + assert.ok(html.includes('data-feature-name="Unity.Flex" onfocus="alert(2)"')); + assert.ok(html.includes('id="ft-Unity-Flex" onfocus="alert(2)"')); + assert.ok(html.includes('for="ft-Unity-Flex" onfocus="alert(2)"')); + assert.equal(html.includes(' onclick="'), false); + assert.equal(html.includes(' onfocus="'), false); +}); + +test('tenant feature rendering escapes fallback names and literal entity references', () => { + const view = featureView([]); + const html = view.context._renderFeatureGroups([{ + name: '', features: [{ name: 'A < B', value: 'false' }] + }]); + assert.ok(html.includes('<Group>')); + assert.ok(html.includes('A &lt; B')); +}); + +test('tenant feature rendering preserves normal identifiers, checked state, and empty groups', () => { + const view = featureView([]); + const html = view.context._renderFeatureGroups([{ + name: 'Specializations', features: [ + { name: 'Unity.Onboarding', value: 'True' }, + { name: 'Unity.Other', value: 'false' } + ] + }]); + assert.ok(html.includes('data-feature-group="Specializations"')); + assert.ok(html.includes('id="ft-Unity-Onboarding" data-feature-name="Unity.Onboarding" checked>')); + assert.ok(html.includes('for="ft-Unity-Onboarding"')); + assert.ok(html.includes('data-feature-name="Unity.Other">')); + assert.ok(view.context._renderFeatureGroups([]).includes('No features available.')); + assert.ok(view.context._renderFeatureGroups(null).includes('No features available.')); +}); + +for (const mode of ['config', 'create']) { + test(`tenant ${mode} feature loading inserts escaped metadata from the API`, () => { + const view = featureView([{ name: 'Group', features: [{ name: 'Unity.Flex', displayName: '', value: 'true' }] }]); + if (mode === 'config') view.context._loadFeaturesTab('tenant-id'); + else view.context._loadCreateFeaturesTab(); + const content = view.node(`#${mode}-features-content`); + assert.equal(content.htmlWrites[0], ''); + assert.ok(content.htmlValue.includes('<img src=x>')); + assert.equal(content.htmlValue.includes(' Date: Wed, 14 Oct 2026 12:42:36 -0700 Subject: [PATCH 09/45] feature/AB#34582-TightenApi-Co-Pilot --- .../CasPaymentRequestCoordinatorTests.cs | 18 +---- .../Utils/BackgroundJobContext.cs | 20 +++-- .../Utils/UnityQuartzBackgroundWorkerBase.cs | 24 +++++- .../FiscalYearEndRolloverWorker.cs | 43 +++++----- .../DateBasedScheduledNotificationJob.cs | 4 +- .../ApplicantUpdateEmailRecoveryWorker.cs | 11 ++- .../DataHealthCheckWorker.cs | 39 +++++----- .../History/AuditLogCleanupWorker.cs | 2 +- .../BackgroundWorkers/IntakeSyncWorker.cs | 15 ++-- .../Endpoints/EndpointManagementAppService.cs | 2 +- .../Messaging/InboxWorkerBase.cs | 15 ++-- .../AutomatedNotificationAppService.cs | 7 +- .../FormNotificationsApiController.cs | 7 +- .../FiscalYearEndRolloverWorkerTests.cs | 78 ++++++++++++++++++- ...ApplicantUpdateEmailRecoveryWorkerTests.cs | 15 +++- 15 files changed, 209 insertions(+), 91 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs index ae88956239..c22d8869b0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs @@ -129,7 +129,7 @@ private static CasPaymentRequestCoordinator CreateCoordinator( var unitOfWorkManager = Substitute.For(); unitOfWorkManager.Begin(Arg.Any(), Arg.Any()).Returns(unitOfWork); - var coordinator = new TestableCasPaymentRequestCoordinator( + var coordinator = new CasPaymentRequestCoordinator( paymentQueueService: null!, paymentRequestsRepository: repository, unitOfWorkManager: unitOfWorkManager, @@ -146,22 +146,6 @@ private static CasPaymentRequestCoordinator CreateCoordinator( return coordinator; } - private sealed class TestableCasPaymentRequestCoordinator( - PaymentQueueService paymentQueueService, - IPaymentRequestRepository paymentRequestsRepository, - IUnitOfWorkManager unitOfWorkManager, - ITenantRepository tenantRepository, - ICurrentTenant currentTenant, - ILocalEventBus localEventBus) - : CasPaymentRequestCoordinator( - paymentQueueService, - paymentRequestsRepository, - unitOfWorkManager, - tenantRepository, - currentTenant, - localEventBus) - { } - private static PaymentRequest CreatePaymentRequest( string? invoiceStatus = null, string? paymentStatus = null) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs index b450a2c219..81c1a1804f 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -50,20 +50,26 @@ public static IDisposable Set( var principalDisposable = principalAccessor.Change(principal); // NOW start auditing - it will see the correct tenant/user context + var auditingDisposable = BeginAuditScope(auditingManager, tenantId); + + // Dispose in LIFO order: last registered is disposed first. + // audit scope closes first (while tenant/user context is still valid), + // then principal, then tenant. + return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + } + + public static IDisposable BeginAuditScope(IAuditingManager auditingManager, Guid? tenantId) + { var auditingDisposable = auditingManager.BeginScope(); - - // Ensure the current audit log has the user ID set for entity change tracking + if (auditingManager.Current != null) { - auditingManager.Current.Log.UserId = backgroundJobPersonId; + auditingManager.Current.Log.UserId = BackgroundJobConstants.BackgroundJobPersonId; auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName; auditingManager.Current.Log.TenantId = tenantId; } - // Dispose in LIFO order: last registered is disposed first. - // audit scope closes first (while tenant/user context is still valid), - // then principal, then tenant. - return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + return auditingDisposable; } /// diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs index e9083648eb..ed3ce84c85 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Quartz; @@ -37,7 +38,26 @@ public sealed override async Task Execute(IJobExecutionContext context) } } - private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext context) + protected async Task ExecuteInTenantAuditScopeAsync(Guid? tenantId, Func action) + { + var auditingManager = ServiceProvider.GetRequiredService(); + var auditingStore = ServiceProvider.GetRequiredService(); + var currentTenant = ServiceProvider.GetRequiredService(); + + using (currentTenant.Change(tenantId)) + using (BackgroundJobContext.BeginAuditScope(auditingManager, tenantId)) + { + AddWorkerAuditAction(auditingManager, null); + await action(); + + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + { + await auditingStore.SaveAsync(log); + } + } + } + + private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext? context) { if (auditingManager.Current?.Log == null) { @@ -48,7 +68,7 @@ private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutio { ServiceName = GetType().FullName ?? GetType().Name, MethodName = nameof(ExecuteInBackgroundAsync), - Parameters = System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }), + Parameters = context == null ? "{}" : System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }), ExecutionTime = DateTime.UtcNow }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs index 7951f2525c..a987be15bc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorker.cs @@ -86,31 +86,34 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont var currentTenant = tenantScope.ServiceProvider.GetRequiredService(); var uowManager = tenantScope.ServiceProvider.GetRequiredService(); - // The rollover runs raw SQL directly against the tenant connection, which bypasses - // ABP's EF Core query filters (soft-delete, multi-tenancy) entirely, so no explicit - // IDataFilter bypass is required here - CurrentTenant.Change only selects the - // correct tenant connection string. - using (currentTenant.Change(tenant.Id, tenant.Name)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); - var applicantRepository = tenantScope.ServiceProvider.GetRequiredService(); + // The rollover runs raw SQL directly against the tenant connection, which bypasses + // ABP's EF Core query filters (soft-delete, multi-tenancy) entirely, so no explicit + // IDataFilter bypass is required here - CurrentTenant.Change only selects the + // correct tenant connection string. + using (currentTenant.Change(tenant.Id, tenant.Name)) + { + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + var applicantRepository = tenantScope.ServiceProvider.GetRequiredService(); - var result = await applicantRepository.RollOverFiscalYearEndAsync(); + var result = await applicantRepository.RollOverFiscalYearEndAsync(); - if (!result.ColumnsAvailable) - { - _logger.LogWarning( - "Skipped fiscal year end rollover for tenant {TenantName} ({TenantId}) - missing column(s): {MissingColumns}", - tenant.Name, tenant.Id, string.Join(", ", result.MissingColumns)); - continue; - } + if (!result.ColumnsAvailable) + { + _logger.LogWarning( + "Skipped fiscal year end rollover for tenant {TenantName} ({TenantId}) - missing column(s): {MissingColumns}", + tenant.Name, tenant.Id, string.Join(", ", result.MissingColumns)); + return; + } - await uow.CompleteAsync(context.CancellationToken); + await uow.CompleteAsync(context.CancellationToken); - _logger.LogInformation( - "Fiscal year end rollover completed for tenant {TenantName} ({TenantId}). Rows affected: {RowsAffected}", - tenant.Name, tenant.Id, result.RowsAffected); - } + _logger.LogInformation( + "Fiscal year end rollover completed for tenant {TenantName} ({TenantId}). Rows affected: {RowsAffected}", + tenant.Name, tenant.Id, result.RowsAffected); + } + }); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs index fcc85970ee..0e4d5f333e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/DateBasedScheduledNotificationJob.cs @@ -147,11 +147,11 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont // Process each tenant separately foreach (var tenant in allTenants) { - using (_currentTenant.Change(tenant.Id)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { _logger.LogDebug("DateBasedScheduledNotificationJob: Processing notifications for tenant '{TenantName}' ({TenantId}).", tenant.Name, tenant.Id); await ProcessTenantNotificationsAsync(); - } + }); } _logger.LogInformation("DateBasedScheduledNotificationJob: Completed execution."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs index 8e94efc57b..a00147f8b6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Notifications/ApplicantUpdateEmailRecoveryWorker.cs @@ -52,11 +52,14 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont { try { - using var tenantScope = _serviceProvider.CreateScope(); - using (tenantScope.ServiceProvider.GetRequiredService().Change(tenant.Id)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - await RecoverTenantAsync(tenantScope.ServiceProvider); - } + using var tenantScope = _serviceProvider.CreateScope(); + using (tenantScope.ServiceProvider.GetRequiredService().Change(tenant.Id)) + { + await RecoverTenantAsync(tenantScope.ServiceProvider); + } + }); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs index 4f5a2abec3..995d5421c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/DataHealthCheckWorker.cs @@ -64,27 +64,30 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont foreach (var tenant in tenants) { - using (_currentTenant.Change(tenant.Id, tenant.Name)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - // Lookup the missing emails - var missingEmailsCount = await _emailNotificationService.GetEmailsChesWithNoResponseCountAsync(); - if (missingEmailsCount > 0) + using (_currentTenant.Change(tenant.Id, tenant.Name)) { - Logger.LogWarning("Tenant {TenantName} has {MissingEmailsCount} missing email(s) with a status of Initialized or Sent but no CHES Response.", tenant.Name, missingEmailsCount); - string missingEmailBody = $"Unity tenant {tenant.Name} has {missingEmailsCount} email(s) that were sent but have no CHES Response."; - sendEmail = true; - emailBodyBuilder.AppendLine($"{missingEmailBody}
"); + // Lookup the missing emails + var missingEmailsCount = await _emailNotificationService.GetEmailsChesWithNoResponseCountAsync(); + if (missingEmailsCount > 0) + { + Logger.LogWarning("Tenant {TenantName} has {MissingEmailsCount} missing email(s) with a status of Initialized or Sent but no CHES Response.", tenant.Name, missingEmailsCount); + string missingEmailBody = $"Unity tenant {tenant.Name} has {missingEmailsCount} email(s) that were sent but have no CHES Response."; + sendEmail = true; + emailBodyBuilder.AppendLine($"{missingEmailBody}
"); + } + // Lookup the missing payments + var missingPayments = await GetPaymentsSentWithoutResponseCountAsync(); + if (missingPayments > 0) + { + Logger.LogWarning("Tenant {TenantName} has {MissingPaymentsCount} payments sent without a response.", tenant.Name, missingPayments); + string missingPaymentBody = $"Unity tenant {tenant.Name} has {missingPayments} payment(s) that are in Submitted status but have no CAS Response."; + sendEmail = true; + emailBodyBuilder.AppendLine($"{missingPaymentBody}
"); + } } - // Lookup the missing payments - var missingPayments = await GetPaymentsSentWithoutResponseCountAsync(); - if (missingPayments > 0) - { - Logger.LogWarning("Tenant {TenantName} has {MissingPaymentsCount} payments sent without a response.", tenant.Name, missingPayments); - string missingPaymentBody = $"Unity tenant {tenant.Name} has {missingPayments} payment(s) that are in Submitted status but have no CAS Response."; - sendEmail = true; - emailBodyBuilder.AppendLine($"{missingPaymentBody}
"); - } - } + }); } if (sendEmail) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs index 320576eff9..8e8750009b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/History/AuditLogCleanupWorker.cs @@ -122,7 +122,7 @@ private async Task CleanupInScopeAsync( { try { - await cleanup(scope.ServiceProvider); + await ExecuteInTenantAuditScopeAsync(tenantId, () => cleanup(scope.ServiceProvider)); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs index d51b694060..c70899448d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/BackgroundWorkers/IntakeSyncWorker.cs @@ -65,15 +65,18 @@ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext cont foreach (var tenant in tenants) { - using (_currentTenant.Change(tenant.Id, tenant.Name)) + await ExecuteInTenantAuditScopeAsync(tenant.Id, async () => { - var (missingSubmissions, submissionReport) = await _applicationFormSynchronizationService.GetMissingSubmissions(numberDaysBack); - if (missingSubmissions?.Count > 0) + using (_currentTenant.Change(tenant.Id, tenant.Name)) { - emailBodyBuilder.AppendLine($"

Tenant: {tenant.Name}
{submissionReport}"); - sendEmail = true; + var (missingSubmissions, submissionReport) = await _applicationFormSynchronizationService.GetMissingSubmissions(numberDaysBack); + if (missingSubmissions?.Count > 0) + { + emailBodyBuilder.AppendLine($"

Tenant: {tenant.Name}
{submissionReport}"); + sendEmail = true; + } } - } + }); } emailBodyBuilder.AppendLine("
Bests"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index ba75c56913..bb91b61e09 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -14,7 +14,7 @@ namespace Unity.GrantManager.Integrations.Endpoints { - [Authorize(IdentityConsts.ITOperationsPolicyName)] + [Authorize(IdentityConsts.ITAdminOrITOperationsPolicyName)] public class EndpointManagementAppService( IRepository repository, IDistributedCache cache, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index 4d03ec81c3..0f03a5eb7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -114,7 +114,7 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i inboxMsg.MessageId, inboxMsg.Source, inboxMsg.DataType, inboxMsg.TenantId); string ackStatus; - string details; + string details = string.Empty; try { @@ -149,12 +149,15 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i else { // Switch to tenant context ONLY for the domain handler execution - using (currentTenant.Change(inboxMsg.TenantId)) + await ExecuteInTenantAuditScopeAsync(inboxMsg.TenantId, async () => { - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); - details = await handler.HandleAsync(inboxMsg.Payload); - await uow.CompleteAsync(); - } + using (currentTenant.Change(inboxMsg.TenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + details = await handler.HandleAsync(inboxMsg.Payload); + await uow.CompleteAsync(); + } + }); ackStatus = "SUCCESS"; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs index 0ed0fecc72..3f59ebe8df 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Notifications/AutomatedNotificationAppService.cs @@ -6,14 +6,16 @@ using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.Permissions; namespace Unity.GrantManager.Notifications { - [Authorize] + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] public class AutomatedNotificationAppService(IRepository repository) : ApplicationService, IAutomatedNotificationAppService { private readonly IRepository _repository = repository; + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task CreateAsync(CreateUpdateNotificationDto input) { var entity = new ScheduledNotification @@ -53,11 +55,13 @@ public async Task CreateAsync(CreateUpdateNotificationDto input }; } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task DeleteAsync(Guid id) { await _repository.DeleteAsync(id, autoSave: true); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task CancelAsync(Guid id) { var entity = await _repository.GetAsync(id); @@ -127,6 +131,7 @@ public async Task> GetListAsync(GetNotifications return new PagedResultDto(total, items); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] public async Task UpdateAsync(Guid id, CreateUpdateNotificationDto input) { var e = await _repository.GetAsync(id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index b4fe9df92e..8fcbdf413f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -13,13 +13,14 @@ using Unity.Notifications.Templates; using Volo.Abp.Users; using Unity.GrantManager.Events; +using Unity.GrantManager.Permissions; using Unity.Payments.Codes; using Unity.Payments.Enums; using Volo.Abp.Identity.Integration; namespace Unity.GrantManager.Web.Controllers { - [Authorize] + [Authorize(GrantManagerPermissions.NotificationScheduler.Default)] [Route("api/form-notifications")] [ApiController] public class FormNotificationsApiController(IApplicationStatusService statusService, @@ -277,6 +278,7 @@ public async Task>> GetForForm(strin return Ok(items); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpPost("{formId}")] public async Task> CreateForForm(string formId, [FromBody] CreateScheduledNotificationInput input) { @@ -359,6 +361,7 @@ public async Task> CreateForForm(string f return CreatedAtAction(nameof(GetForForm), new { formId }, dto); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpDelete("{formId}/{id:guid}")] public async Task Delete(string formId, Guid id) { @@ -366,6 +369,7 @@ public async Task Delete(string formId, Guid id) return NoContent(); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpPatch("{formId}/{id:guid}/cancel")] public async Task CancelNotification(string formId, Guid id) { @@ -423,6 +427,7 @@ public async Task> GetTemplateNotificationPlans(Guid templa return Ok(new { notificationPlanNames = planNames }); } + [Authorize(GrantManagerPermissions.NotificationScheduler.ManageSchedules)] [HttpPut("{formId}/{id:guid}")] public async Task> UpdateForForm(string formId, Guid id, [FromBody] CreateScheduledNotificationInput input) { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs index 52a8280546..0605864c03 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +10,9 @@ using Shouldly; using Unity.GrantManager.Applicants.BackgroundWorkers; using Unity.GrantManager.Applications; +using Volo.Abp.Auditing; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; using Volo.Abp.Uow; @@ -94,30 +98,96 @@ public async Task ShouldContinueProcessingRemainingTenants_WhenOneTenantThrows() await applicantRepository.Received(2).RollOverFiscalYearEndAsync(); } + [Fact] + public async Task ShouldPersistSeparateAuditLogs_WhenProcessingMultipleTenants() + { + var tenantOne = CreateTenant("Tenant One"); + var tenantTwo = CreateTenant("Tenant Two"); + var currentTenant = CreateCurrentTenant(); + var applicantRepository = Substitute.For(); + var auditingManager = CreateAuditingManager(out var savedLogs); + var auditingStore = Substitute.For(); + auditingStore.SaveAsync(Arg.Any()).Returns(callInfo => + { + savedLogs.Add(callInfo.Arg()); + return Task.CompletedTask; + }); + applicantRepository.RollOverFiscalYearEndAsync().Returns(_ => + { + auditingManager.Current!.Log.EntityChanges.Add(new EntityChangeInfo()); + return Task.FromResult(new FiscalYearEndRolloverResult { RowsAffected = 1 }); + }); + + var worker = CreateWorker( + currentTenant, + CreateTenantRepository(tenantOne, tenantTwo), + applicantRepository, + auditingManager, + auditingStore); + + await worker.Execute(Substitute.For()); + + savedLogs.Count.ShouldBe(2); + savedLogs.Select(log => log.TenantId).ShouldBe([tenantOne.Id, tenantTwo.Id]); + savedLogs.ShouldAllBe(log => log.EntityChanges.Count == 1); + } + private static FiscalYearEndRolloverWorker CreateWorker( ICurrentTenant currentTenant, ITenantRepository tenantRepository, - IApplicantRepository applicantRepository) + IApplicantRepository applicantRepository, + IAuditingManager? auditingManager = null, + IAuditingStore? auditingStore = null) { var logger = Substitute.For>(); var settingManager = Substitute.For(); - var serviceProvider = CreateServiceProvider(currentTenant, applicantRepository); + var serviceProvider = CreateServiceProvider(currentTenant, applicantRepository, auditingManager, auditingStore); - return new FiscalYearEndRolloverWorker(logger, serviceProvider, tenantRepository, settingManager); + var worker = new FiscalYearEndRolloverWorker(logger, serviceProvider, tenantRepository, settingManager) + { + ServiceProvider = serviceProvider + }; + return worker; } - private static IServiceProvider CreateServiceProvider(ICurrentTenant currentTenant, IApplicantRepository applicantRepository) + private static IServiceProvider CreateServiceProvider( + ICurrentTenant currentTenant, + IApplicantRepository applicantRepository, + IAuditingManager? auditingManager = null, + IAuditingStore? auditingStore = null) { var uowManager = Substitute.For(); uowManager.Begin(Arg.Any(), Arg.Any()).Returns(Substitute.For()); var services = new ServiceCollection(); services.AddSingleton(currentTenant); + services.AddSingleton(Substitute.For()); services.AddSingleton(uowManager); services.AddSingleton(applicantRepository); + services.AddSingleton(auditingManager ?? CreateAuditingManager(out _)); + services.AddSingleton(auditingStore ?? Substitute.For()); return services.BuildServiceProvider(); } + private static IAuditingManager CreateAuditingManager(out List auditLogs) + { + auditLogs = []; + var scopes = new Stack(); + var auditingManager = Substitute.For(); + auditingManager.Current.Returns(_ => scopes.TryPeek(out var scope) ? scope : null); + auditingManager.BeginScope().Returns(_ => + { + var scope = Substitute.For(); + scope.Log.Returns(new AuditLogInfo()); + scopes.Push(scope); + + var disposable = Substitute.For(); + disposable.When(x => x.Dispose()).Do(_ => scopes.Pop()); + return disposable; + }); + return auditingManager; + } + private static ICurrentTenant CreateCurrentTenant() { var currentTenant = Substitute.For(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs index 21f525635c..b599c1cd60 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ApplicantUpdateEmailRecoveryWorkerTests.cs @@ -12,8 +12,10 @@ using Unity.Notifications.EmailNotifications; using Unity.Notifications.Emails; using Volo.Abp.Features; +using Volo.Abp.Auditing; using Volo.Abp.Linq; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; using Volo.Abp.TenantManagement; using Volo.Abp.Timing; using Volo.Abp.Uow; @@ -68,10 +70,21 @@ public async Task ShouldRequeueOnlyStaleUnattemptedPortalEmailsWithOriginalIdsAn services.AddSingleton(clock); services.AddSingleton(features); services.AddSingleton(currentTenant); + services.AddSingleton(Substitute.For()); services.AddSingleton(manager); services.AddSingleton(uowManager); + var auditingManager = Substitute.For(); + var auditScope = Substitute.For(); + auditScope.Log.Returns(new AuditLogInfo()); + auditingManager.Current.Returns(auditScope); + auditingManager.BeginScope().Returns(Substitute.For()); + services.AddSingleton(auditingManager); + services.AddSingleton(Substitute.For()); using var provider = services.BuildServiceProvider(); - var worker = new ApplicantUpdateEmailRecoveryWorker(provider); + var worker = new ApplicantUpdateEmailRecoveryWorker(provider) + { + ServiceProvider = provider + }; await worker.Execute(Substitute.For()); From 89b9033fb912ecd172cec58fa0886e70de2118fb Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 12:53:57 -0700 Subject: [PATCH 10/45] feature/AB#34582-TightenApi-Co-Pilot --- .../BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs index 0605864c03..aa7a6bbaca 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/BackgroundWorkers/FiscalYearEndRolloverWorkerTests.cs @@ -109,7 +109,10 @@ public async Task ShouldPersistSeparateAuditLogs_WhenProcessingMultipleTenants() var auditingStore = Substitute.For(); auditingStore.SaveAsync(Arg.Any()).Returns(callInfo => { - savedLogs.Add(callInfo.Arg()); + if (callInfo.Arg() is { } auditLog) + { + savedLogs.Add(auditLog); + } return Task.CompletedTask; }); applicantRepository.RollOverFiscalYearEndAsync().Returns(_ => From b9493266f079de4eeb531b132790f58483c77dfb Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 14 Oct 2026 13:25:31 -0700 Subject: [PATCH 11/45] AB#33423: Fix Cross-Site Scripting on Scoresheet Title --- .../Components/Scoresheet/Default.cshtml | 7 +- .../JavaScript/ScoresheetExport.test.cjs | 108 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml index 6396a537ba..1ece49d1e1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml @@ -62,7 +62,12 @@ @L["Scoresheet:Configuration:PublishScoresheetButtonText"].Value } -
} } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxWidget/Default.cshtml index fbc2b5f70a..a998afa576 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CheckboxWidget/Default.cshtml @@ -17,7 +17,9 @@ } else @@ -25,8 +27,10 @@ + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field?.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)" /> }
-} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.cshtml index 810d8b832a..daf717c150 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/CurrencyWidget/Default.cshtml @@ -22,7 +22,9 @@ placeholder="@Model.Field.Definition?.ConvertDefinition(Model.Field.Type)?.Placeholder" id="@fieldId" name="@fieldId" - onchange="@($"notifyFieldChange('{Model.Name}', '{Model.Field?.UiAnchor}', this)")"/> + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field?.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)"/> -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.cshtml index c069444095..c31ac0a2f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DateWidget/Default.cshtml @@ -19,6 +19,8 @@ label="@Model.Field.Label" id="@fieldId" name="@fieldId" - onchange="@($"notifyFieldChange('{Model.Name}', '{Model.Field?.UiAnchor}', this)")" /> + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field?.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)" /> -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DefaultFieldWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DefaultFieldWidget/Default.cshtml index 926c9b6a5b..08c9b9582c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DefaultFieldWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DefaultFieldWidget/Default.cshtml @@ -22,5 +22,7 @@ placeholder="@fieldDef?.Placeholder" id="@fieldId" name="@fieldId" - onchange="@($"notifyFieldChange('{Model.Name}', '{Model.Field.UiAnchor}', this)")" /> + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)" /> } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioWidget/Default.cshtml index 59d3a1ccaf..2a43ed4d8a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioWidget/Default.cshtml @@ -28,7 +28,9 @@ id="@optionId" name="@fieldBaseName" value="@($"{option.Value}")" - onchange="@($"notifyFieldChange('{Model.Name}', '{Model.Field?.UiAnchor}', this)")" + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field?.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)" checked /> } else @@ -37,11 +39,13 @@ id="@optionId" name="@fieldBaseName" value="@($"{option.Value}")" - onchange="@($"notifyFieldChange('{Model.Name}', '{Model.Field?.UiAnchor}', this)")" /> + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field?.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)" /> } } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.cshtml index 1c4a6512b3..439d37cf1d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListWidget/Default.cshtml @@ -15,7 +15,9 @@ -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextAreaWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextAreaWidget/Default.cshtml index 0a3eb5241e..d6efb9efcb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextAreaWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/TextAreaWidget/Default.cshtml @@ -20,5 +20,7 @@ maxlength="@(fieldDef?.MaxLength)" placeholder="@fieldDef?.Placeholder" class="form-control" - onchange="@($"notifyFieldChange('{Model.Name}', '{Model.Field?.UiAnchor}', this)")">@Model?.Field?.CurrentValue?.ConvertInputValueOrNull(Model.Field.Type) -} \ No newline at end of file + data-worksheet-name="@Model.Name" + data-ui-anchor="@Model.Field?.UiAnchor" + onchange="notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)">@Model?.Field?.CurrentValue?.ConvertInputValueOrNull(Model.Field.Type) +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.cshtml index 2ee1bfefd9..a7f6c16d50 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/YesNoWidget/Default.cshtml @@ -14,9 +14,11 @@ -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs new file mode 100644 index 0000000000..d422d81968 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs @@ -0,0 +1,154 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const moduleDirectory = path.resolve(__dirname, '../../..'); +const componentDirectory = path.join(moduleDirectory, 'src/Unity.Flex.Web/Views/Shared/Components'); +const detailsSource = readFileSync(path.resolve(moduleDirectory, + '../../src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js'), 'utf8'); +const widgets = [ + ['DefaultFieldWidget', 1], ['YesNoWidget', 1], ['CheckboxWidget', 2], + ['CurrencyWidget', 1], ['DateWidget', 1], ['RadioWidget', 2], + ['SelectListWidget', 1], ['CheckboxGroupWidget', 2], ['TextAreaWidget', 1] +].map(([name, expectedBranches]) => ({ + name, expectedBranches, + source: readFileSync(path.join(componentDirectory, name, 'Default.cshtml'), 'utf8') +})); + +function controlsFor(widget) { + // Match source control boundaries without interpreting embedded Razor quotes. + return [...widget.source.matchAll(/<(?:input|select|textarea)\b[\s\S]*?(?:\/>|>)/g)] + .map(match => match[0]).filter(markup => markup.includes('notifyFieldChange')); +} + +function handlerFor(markup) { + const handler = markup.match(/\bonchange="([^"]*)"/)?.[1]; + assert.ok(handler, 'The field must have a change handler'); + assert.doesNotMatch(handler, /@/, 'Metadata must not be interpolated into executable code'); + return handler; +} + +function invokeHandler(context, handler, field) { + context.field = field; + vm.runInContext(`(function () { ${handler} }).call(field);`, context); + assert.equal(context.xssExecuted, false, 'Metadata must not execute as JavaScript'); +} + +const metadataCases = [ + { worksheetName: 'Worksheet-v1', uiAnchor: 'ProjectInfo' }, + { worksheetName: "Reviewer's worksheet", uiAnchor: "Reviewer's tab" }, + { worksheetName: 'A & B < C > D "quoted"', uiAnchor: '">' }, + { worksheetName: 'Café <name> ' \\ line\nnext', uiAnchor: 'Custom\\Tab\nNext' }, + { worksheetName: "');globalThis.xssExecuted = true;//", uiAnchor: 'ProjectInfo' }, + { worksheetName: 'Worksheet-v1', uiAnchor: "');globalThis.xssExecuted = true;//" }, + { worksheetName: '', uiAnchor: '' } +]; + +// These checks inspect Razor bindings and run actual handler source with decoded +// dataset values. They do not render Razor or parse HTML in a browser. +for (const widget of widgets) { + test(`${widget.name} forwards metadata as data in every field branch`, () => { + const controls = controlsFor(widget); + assert.equal(controls.length, widget.expectedBranches); + for (const markup of controls) { + assert.match(markup, /\bdata-worksheet-name="@Model\.Name"/); + assert.match(markup, /\bdata-ui-anchor="@Model\.Field\??\.UiAnchor"/); + const handler = handlerFor(markup); + for (const dataset of metadataCases) { + const calls = []; + const context = vm.createContext({ + xssExecuted: false, + notifyFieldChange(...args) { calls.push(args); } + }); + const field = { + dataset, id: 'custom_field.worksheet.field-id.worksheet-id', + value: 'original value', checked: /\schecked\b/.test(markup) + }; + invokeHandler(context, handler, field); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], [dataset.worksheetName, dataset.uiAnchor, field]); + assert.equal(field.value, 'original value'); + assert.equal(field.checked, /\schecked\b/.test(markup)); + } + } + }); +} + +function notificationHarness() { + const events = []; + const fields = new Map(); + const context = vm.createContext({ + xssExecuted: false, + $() {}, // Skip page initialization; exercise the real notification functions. + document: { getElementById(id) { return fields.get(id); } }, + PubSub: { + publish(topic, payload) { + events.push({ topic, payload: typeof payload === 'object' ? { ...payload } : payload }); + } + } + }); + vm.runInContext(detailsSource, context, { filename: 'Details.js' }); + return { + events, + change(handler, field) { + fields.set(field.id, field); + invokeHandler(context, handler, field); + } + }; +} + +for (const anchor of ['ProjectInfo', 'ApplicantInfo', 'AssessmentInfo', 'PaymentInfo', 'FundingAgreementInfo']) { + test(`all field handlers preserve ${anchor} notification routing and current values`, () => { + const harness = notificationHarness(); + for (const widget of widgets) { + for (const [index, markup] of controlsFor(widget).entries()) { + const field = { + id: `${widget.name}-${index}`, value: `changed ${widget.name}-${index}`, + dataset: { worksheetName: 'Worksheet-v1', uiAnchor: anchor } + }; + const count = harness.events.length; + harness.change(handlerFor(markup), field); + assert.equal(harness.events.length, count + 1); + assert.deepEqual(harness.events.at(-1), { + topic: `fields_${anchor.toLowerCase()}`, payload: field.value + }); + } + } + }); +} + +test('custom and preview anchors retain the exact worksheet name and field ID in notifications', () => { + const harness = notificationHarness(); + const datasets = metadataCases.filter(dataset => dataset.uiAnchor !== 'ProjectInfo') + .concat([{ worksheetName: 'Preview worksheet', uiAnchor: 'Preview' }]); + for (const widget of widgets) { + for (const markup of controlsFor(widget)) { + for (const dataset of datasets) { + const field = { dataset, id: `${widget.name}.${dataset.worksheetName}.field-id`, value: 'value' }; + const count = harness.events.length; + harness.change(handlerFor(markup), field); + assert.equal(harness.events.length, count + 1); + assert.deepEqual(harness.events.at(-1), { + topic: 'fields_tab', payload: { worksheet: dataset.worksheetName, fieldId: field.id } + }); + } + } + } +}); + +test('a refreshed field uses its own worksheet metadata and current value', () => { + const harness = notificationHarness(); + const handler = handlerFor(controlsFor(widgets[0])[0]); + harness.change(handler, { + id: 'field-id', value: 'old', dataset: { worksheetName: 'Old-v1', uiAnchor: 'ProjectInfo' } + }); + harness.change(handler, { + id: 'field-id', value: 'new', dataset: { worksheetName: 'New-v2', uiAnchor: 'PaymentInfo' } + }); + assert.deepEqual(harness.events, [ + { topic: 'fields_projectinfo', payload: 'old' }, + { topic: 'fields_paymentinfo', payload: 'new' } + ]); +}); From 10816bf94a34881d5da4330713921b487899e3ed Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 14 Oct 2026 14:20:34 -0700 Subject: [PATCH 14/45] AB#33423: Fix cross-site scripting on attachment name --- .../ApplicantAttachments.js | 10 +- .../ApplicationAttachments.js | 8 +- .../AssessmentResultAttachments.js | 8 +- .../ChefsAttachments/ChefsAttachments.js | 10 +- .../Shared/Components/_Shared/Attachments.js | 10 +- .../AttachmentHtmlRendering.test.cjs | 230 ++++++++++++++++++ 6 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAttachments/ApplicantAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAttachments/ApplicantAttachments.js index 6edfebff8f..1ff1131aaa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAttachments/ApplicantAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAttachments/ApplicantAttachments.js @@ -2,6 +2,7 @@ $(function () { const l = abp.localization.getResource('GrantManager'); const nullPlaceholder = '—'; + const textRenderer = $.fn.dataTable.render.text(); let inputAction = function (requestData, dataTableSettings) { const applicantId = decodeURIComponent($('#DetailsViewApplicantId').val()); @@ -36,6 +37,7 @@ $(function () { { title: l('AssessmentResultAttachments:DocumentName'), data: 'fileName', + render: textRenderer, className: 'data-table-header text-break', width: '30%', }, @@ -44,8 +46,9 @@ $(function () { data: 'displayName', className: 'data-table-header text-break', width: '20%', - render: function (data) { - return data ?? nullPlaceholder; + render: function (data, type) { + const value = data ?? nullPlaceholder; + return type === 'display' || type === 'filter' ? textRenderer[type](value) : value; } }, { @@ -63,6 +66,7 @@ $(function () { { title: l('AssessmentResultAttachments:AttachedBy'), data: 'attachedBy', + render: textRenderer, className: 'data-table-header', width: '25%', }, @@ -112,4 +116,4 @@ function attachmentCountResponseCallback(result) { return { data: result }; -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js index 145741ee23..6e544326aa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationAttachments/ApplicationAttachments.js @@ -2,6 +2,7 @@ $(function () { const l = abp.localization.getResource('GrantManager'); const nullPlaceholder = '—'; + const textRenderer = $.fn.dataTable.render.text(); let inputAction = function (requestData, dataTableSettings) { const urlParams = new URL(window.location.toLocaleString()).searchParams; @@ -49,6 +50,7 @@ $(function () { { title: l('AssessmentResultAttachments:DocumentName'), data: 'fileName', + render: textRenderer, className: 'data-table-header text-break', width: '30%', }, @@ -57,8 +59,9 @@ $(function () { data: 'displayName', className: 'data-table-header text-break', width: '20%', - render: function (data) { - return data ?? nullPlaceholder; + render: function (data, type) { + const value = data ?? nullPlaceholder; + return type === 'display' || type === 'filter' ? textRenderer[type](value) : value; } }, { @@ -76,6 +79,7 @@ $(function () { { title: l('AssessmentResultAttachments:AttachedBy'), data: 'attachedBy', + render: textRenderer, className: 'data-table-header', width: '25%', }, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResultAttachments/AssessmentResultAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResultAttachments/AssessmentResultAttachments.js index b1bb619a35..2b02c7a2cd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResultAttachments/AssessmentResultAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResultAttachments/AssessmentResultAttachments.js @@ -2,6 +2,7 @@ $(function () { const l = abp.localization.getResource('GrantManager'); const nullPlaceholder = '—'; + const textRenderer = $.fn.dataTable.render.text(); let inputAction = function (requestData, dataTableSettings) { let assessmentId = decodeURIComponent($("#AssessmentId").val()); @@ -43,6 +44,7 @@ $(function () { { title: l('AssessmentResultAttachments:DocumentName'), data: 'fileName', + render: textRenderer, className: 'data-table-header text-break', width: '30%', }, @@ -51,8 +53,9 @@ $(function () { data: 'displayName', className: 'data-table-header text-break', width: '20%', - render: function (data) { - return data ?? nullPlaceholder; + render: function (data, type) { + const value = data ?? nullPlaceholder; + return type === 'display' || type === 'filter' ? textRenderer[type](value) : value; } }, { @@ -70,6 +73,7 @@ $(function () { { title: l('AssessmentResultAttachments:AttachedBy'), data: 'attachedBy', + render: textRenderer, className: 'data-table-header', width: '25%', }, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 7990ed0f0e..0cd9c84b69 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -11,6 +11,7 @@ $(function () { let chefsDataTable; let selectedAttachmentIds = []; const nullPlaceholder = '—'; + const textRenderer = $.fn.dataTable.render.text(); let inputAction = function (requestData, dataTableSettings) { return getApplicationId(); @@ -106,6 +107,7 @@ $(function () { title: 'Document Name', name: 'chefsFileName', data: 'fileName', + render: textRenderer, className: 'data-table-header text-break', index: 1, orderable: false, @@ -119,8 +121,9 @@ $(function () { data: 'displayName', className: 'data-table-header text-break', width: '35%', - render: function (data) { - return data ?? nullPlaceholder; + render: function (data, type) { + const value = data ?? nullPlaceholder; + return type === 'display' || type === 'filter' ? textRenderer[type](value) : value; }, }; } @@ -472,7 +475,8 @@ function getChefsFileDownloadColumn() { 'Download Attachment' + '' + '' + '' + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/Attachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/Attachments.js index 1692f5da18..8968435917 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/Attachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/Attachments.js @@ -9,12 +9,14 @@ function generateAttachmentButtonContent(data, type, full, meta, attachmentType) { let ownerId = getAttachmentOwnerId(attachmentType); - let downloadUrl = `/api/app/attachment/${attachmentType}/${encodeURIComponent(ownerId)}/download/${encodeURIComponent(full.fileName)}`; + let downloadUrl = `/api/app/attachment/${encodeURIComponent(attachmentType)}/${encodeURIComponent(ownerId)}/download/${encodeURIComponent(full.fileName)}`; let isCreator = abp.currentUser.id == full.creatorId; let escapedAttachmentType = escapeHtmlAttribute(attachmentType); let escapedOwnerId = escapeHtmlAttribute(ownerId); let escapedFileName = escapeHtmlAttribute(full.fileName); let escapedDisplayName = escapeHtmlAttribute(full.displayName || full.fileName); + let escapedObjectKey = escapeHtmlAttribute(data); + let escapedAttachmentId = escapeHtmlAttribute(full.id); let html = ` diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs new file mode 100644 index 0000000000..498ca97649 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs @@ -0,0 +1,230 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const webRoot = path.resolve(__dirname, '../../../src/Unity.GrantManager.Web'); +const components = path.join(webRoot, 'Views/Shared/Components'); +const sharedSource = readFileSync(path.join(components, '_Shared/Attachments.js'), 'utf8'); +const dataTablesSource = readFileSync(path.join(webRoot, 'node_modules/datatables.net/js/dataTables.js'), 'utf8'); + +// Use the installed DataTables text renderer and cell writer. DOM/table/modal +// adapters capture output and callbacks; they do not parse browser HTML, run +// ABP's table normalization, persist changes, or download files. +const escapeSource = dataTablesSource.match(/var _escapeHtml = function \( d \) \{[\s\S]*?\n\t\};/)?.[0]; +const textSource = dataTablesSource.match(/text: function \(\) \{[\s\S]*?\n\t\t\}/)?.[0]; +const writeCellSource = dataTablesSource.match(/function _fnWriteCell\(td, val\)[\s\S]*?\n\t\}/)?.[0]; +assert.ok(escapeSource && textSource && writeCellSource, 'Locate the installed DataTables implementations'); + +const owners = { + Assessment: ['#AssessmentId', 'assessment-owner'], + Application: ['#DetailsViewApplicationId', 'application-owner'], + Applicant: ['#DetailsViewApplicantId', 'applicant-owner'] +}; + +function harness() { + const ready = []; + const handlers = new Map(); + const subscriptions = new Map(); + const modals = []; + const events = []; + let configuration; + let reloads = 0; + const table = { on() { return this; }, ajax: { reload() { reloads++; } } }; + const document = {}; + const nodes = new Map(); + function $(selector) { + if (typeof selector === 'function') { ready.push(selector); return; } + if (selector?.attributes) return { attr(name) { return selector.attributes[name]; } }; + if (!nodes.has(selector)) { + nodes.set(selector, { + length: 0, + on(event, delegatedSelector, callback) { + if (selector === document) handlers.set(delegatedSelector, callback); + return this; + }, + one() { return this; }, + DataTable(options) { configuration = options; return table; }, + val() { return Object.values(owners).find(([id]) => id === selector)?.[1]; } + }); + } + return nodes.get(selector); + } + const context = vm.createContext({ + $, document, console, xssExecuted: false, + abp: { + currentUser: { id: 'current-user' }, + localization: { getResource() { return key => key; } }, + libs: { datatables: { normalizeConfiguration: value => value, createAjax() {} } }, + notify: { success() {} }, + ModalManager: class { + constructor(options) { this.options = options; modals.push(this); } + onResult(callback) { this.result = callback; } + open(args) { this.args = { ...args }; } + } + }, + unity: { grantManager: { attachments: { attachment: {} } } }, + PubSub: { + subscribe(topic, callback) { subscriptions.set(topic, callback); }, + publish(topic) { events.push(topic); } + }, + initializeFilterRowPlugin() {}, + getSelectColumn() { return {}; } + }); + vm.runInContext(`${escapeSource}\n${writeCellSource}\nglobalThis.textRendererFactory = ({${textSource}}).text;`, context); + $.fn = { dataTable: { render: { text: context.textRendererFactory } } }; + vm.runInContext(sharedSource, context, { filename: 'Attachments.js' }); + return { + context, modals, events, subscriptions, + get configuration() { return configuration; }, + get reloads() { return reloads; }, + load(widget) { + const file = `${widget}/${widget}.js`; + vm.runInContext(readFileSync(path.join(components, file), 'utf8'), context, { filename: file }); + ready.splice(0).forEach(callback => callback()); + }, + click(button) { + context.button = button; + if (button.attributes.class?.includes('js-preview-attachment')) { + handlers.get('.js-preview-attachment').call(button); + } else { + assert.ok(button.attributes.onclick, 'An enabled action must have a handler'); + assert.match(button.attributes.onclick, /^\w+\(this\.dataset\./); + vm.runInContext(`(function () { ${button.attributes.onclick} }).call(button);`, context); + } + assert.equal(context.xssExecuted, false); + } + }; +} + +// Decode only the entities emitted by the production attribute encoder, once. +// This is a test adapter for generated attributes, not a sanitizer or HTML parser. +function decode(value) { + const entities = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; + return value.replace(/&(?:amp|lt|gt|quot|#39);/g, entity => entities[entity]); +} + +function elements(html, tag) { + return [...html.matchAll(new RegExp(`<${tag}\\b([^>]*)>([\\s\\S]*?)<\\/${tag}>`, 'g'))].map(match => { + const attributes = Object.fromEntries([...match[1].matchAll(/([\w-]+)="([^"]*)"/g)] + .map(([, name, value]) => [name, decode(value)])); + const dataset = Object.fromEntries(Object.entries(attributes) + .filter(([name]) => name.startsWith('data-')) + .map(([name, value]) => [name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), value])); + return { attributes, dataset, content: match[2], disabled: /\sdisabled(?:\s|$)/.test(match[1]) }; + }); +} + +const specialRows = [ + { fileName: "Reviewer's report.pdf", displayName: 'A & B < C > D "quoted"', s3ObjectKey: 'object-key', id: 'attachment-id' }, + { fileName: "');globalThis.xssExecuted = true;//.pdf", displayName: '', s3ObjectKey: 'key" onmouseover="alert(1)', id: 'attachment-id' }, + { fileName: '<report> ' résumé.pdf', displayName: '', s3ObjectKey: 'key & "quoted"', id: 'id" onclick="alert(1)' } +]; + +for (const [attachmentType, [, ownerId]] of Object.entries(owners)) { + test(`${attachmentType} attachment actions preserve special metadata as data`, () => { + for (const row of specialRows) { + const view = harness(); + const html = view.context.generateAttachmentButtonContent(row.s3ObjectKey, 'display', { + ...row, creatorId: 'current-user' + }, {}, attachmentType); + assert.equal((html.match(/ button.content.includes('Preview Attachment')); + const edit = buttons.find(button => button.content.includes('Edit Attachment')); + const remove = buttons.find(button => button.content.includes('Delete Attachment')); + const download = elements(html, 'a')[0]; + assert.equal(download.attributes.download, row.s3ObjectKey); + assert.equal(download.attributes.href, + `/api/app/attachment/${attachmentType}/${ownerId}/download/${encodeURIComponent(row.fileName)}`); + view.click(preview); + view.click(edit); + view.click(remove); + assert.deepEqual(view.modals.map(modal => modal.args), [ + { attachmentType, ownerId, fileName: row.fileName, displayName: row.displayName || row.fileName }, + { attachmentType, attachmentId: row.id }, + { s3ObjectKey: row.s3ObjectKey, fileName: row.fileName, attachmentType, attachmentTypeId: ownerId } + ]); + assert.deepEqual(view.modals.map(modal => modal.options.viewUrl), [ + '../Attachments/PreviewAttachmentModal', '../Attachments/UpdateAttachmentModal', '../Attachments/DeleteAttachmentModal' + ]); + view.modals[1].result(); + view.modals[2].result(); + assert.deepEqual(view.events, Array(2).fill(`refresh_${attachmentType.toLowerCase()}_attachment_list`)); + } + }); +} + +test('attachment deletion remains disabled for a different creator', () => { + const view = harness(); + const html = view.context.generateAttachmentButtonContent('key', 'display', { + ...specialRows[0], creatorId: 'another-user' + }, {}, 'Assessment'); + const button = elements(html, 'button').find(item => item.content.includes('Delete Attachment')); + assert.equal(button.disabled, true); + assert.equal(button.attributes.onclick, undefined); +}); + +function render(column, data, type) { + if (typeof column.render === 'function') return column.render(data, type, {}, {}); + return column.render?.[type] ? column.render[type](data) : data; +} + +const widgets = [ + ['AssessmentResultAttachments', 'assessment'], ['ApplicationAttachments', 'application'], + ['ApplicantAttachments', 'applicant'], ['ChefsAttachments', 'chefs'] +]; +for (const [widget, refreshType] of widgets) { + test(`${widget} encodes display/filter text while preserving raw sort values`, () => { + const view = harness(); + view.load(widget); + const textColumns = view.configuration.columnDefs.filter(column => ['fileName', 'displayName', 'attachedBy'].includes(column.data)); + assert.equal(textColumns.length, widget === 'ChefsAttachments' ? 2 : 3); + const raw = 'O\'Brien & <literal>'; + const escaped = 'O\'Brien & <img src=x onerror=alert(1) title="quote"> &lt;literal&gt;'; + for (const column of textColumns) { + for (const type of ['display', 'filter']) assert.equal(render(column, raw, type), escaped); + for (const type of ['sort', 'type', undefined]) assert.equal(render(column, raw, type), raw); + // Capture the installed DataTables writer's HTML assignment: safe text + // is what enters the table and is subsequently copied into print HTML. + const cell = {}; + view.context._fnWriteCell(cell, render(column, raw, 'display')); + assert.equal(cell.innerHTML, escaped); + } + view.subscriptions.get(`refresh_${refreshType}_attachment_list`)(); + assert.equal(view.reloads, 1); + }); + + test(`${widget} preserves label placeholders, empty labels, and ordinary text`, () => { + const view = harness(); + view.load(widget); + const label = view.configuration.columnDefs.find(column => column.data === 'displayName'); + for (const [value, expected] of [[null, '—'], [undefined, '—'], ['', ''], ['Ordinary label', 'Ordinary label']]) { + for (const type of ['display', 'filter', 'sort', 'type']) assert.equal(render(label, value, type), expected); + } + }); +} + +test('CHEFS edit actions use encoded metadata and retain the CHEFS refresh event', () => { + const view = harness(); + view.load('ChefsAttachments'); + const row = { + ...specialRows[2], chefsSubmissionId: 'submission/id', chefsFileId: 'file/id' + }; + const column = view.configuration.columnDefs.find(item => item.data === 'chefsFileId'); + const html = column.render(row.chefsFileId, 'display', row, {}); + const buttons = elements(html, 'button'); + assert.equal(buttons.length, 4); + const preview = buttons.find(button => button.content.includes('Preview Attachment')); + assert.equal(preview.attributes['chefs-file-name'], row.fileName); + assert.equal(preview.attributes['chefs-display-name'], row.fileName); + assert.equal(preview.attributes['chefs-submission-id'], encodeURIComponent(row.chefsSubmissionId)); + const edit = buttons.find(button => button.content.includes('Edit Attachment')); + view.click(edit); + assert.deepEqual(view.modals[0].args, { attachmentType: 'CHEFS', attachmentId: row.id }); + view.modals[0].result(); + assert.deepEqual(view.events, ['refresh_chefs_attachment_list']); +}); From 72d4b7d5188e9b48e34dc20f5c1bd9c22451928d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 14:31:26 -0700 Subject: [PATCH 15/45] feature/AB#34582-TightenApi-Co-Pilot --- .../src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml deleted file mode 100644 index a637c7b348..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/_ViewStart.cshtml +++ /dev/null @@ -1,6 +0,0 @@ -@using Volo.Abp.AspNetCore.Mvc.UI.Layout -@using Volo.Abp.AspNetCore.Mvc.UI.Theming -@inject IThemeManager ThemeManager -@{ - Layout = ThemeManager.CurrentTheme.GetLayout(StandardLayouts.Application); -} From 127efc2febe5ad0edef5021935aeccbb5dd173a0 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 14 Oct 2026 14:49:41 -0700 Subject: [PATCH 16/45] AB#33423: Fix sonarqube issues --- .../JavaScript/ScoresheetExport.test.cjs | 24 ++++++++++++------- .../JavaScript/ScoresheetPreview.test.cjs | 8 ++++++- .../WorksheetFieldHandlers.test.cjs | 10 ++++---- .../AttachmentHtmlRendering.test.cjs | 6 +++-- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs index 9fb367a53d..59179fc3b8 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const path = require('node:path'); -const { test } = require('node:test'); +const { before, test } = require('node:test'); const vm = require('node:vm'); const componentDirectory = path.resolve(__dirname, @@ -9,9 +9,12 @@ const componentDirectory = path.resolve(__dirname, const view = readFileSync(path.join(componentDirectory, 'Default.cshtml'), 'utf8'); const source = readFileSync(path.join(componentDirectory, 'Scoresheet.js'), 'utf8'); const buttonMarkup = view.match(/]*\bid="export_scoresheet_btn"[^>]*>/s)?.[0]; -assert.ok(buttonMarkup, 'The shared scoresheet view must contain the export button'); -const clickHandler = buttonMarkup.match(/\bonclick="([^"]*)"/)?.[1]; -assert.ok(clickHandler, 'The export button must have a click handler'); +const clickHandler = buttonMarkup?.match(/\bonclick="([^"]*)"/)?.[1]; + +before(() => { + assert.ok(buttonMarkup, 'The shared scoresheet view must contain the export button'); + assert.ok(clickHandler, 'The export button must have a click handler'); +}); test('export metadata uses Razor-encoded data attributes instead of JavaScript interpolation', () => { assert.match(buttonMarkup, /\bdata-scoresheet-id="@scoresheet\.Id"/); @@ -20,9 +23,9 @@ test('export metadata uses Razor-encoded data attributes instead of JavaScript i assert.doesNotMatch(clickHandler, /@/); }); -// Execute the view's actual handler and production export function. These DOM -// adapters receive decoded dataset values; they do not render Razor, parse HTML, -// or simulate browser downloads. Attribute encoding remains a manual check. +// Verify the view's fixed handler, then call the production export function with +// decoded dataset values. These adapters do not render Razor, parse HTML, or +// simulate browser downloads. Attribute encoding remains a manual check. function exportsHarness() { const requests = []; const downloads = []; @@ -57,12 +60,15 @@ function exportsHarness() { } }; vm.createContext(context); + // Trusted test input: only Scoresheet.js from the fixed repository path above. + // VM separates browser globals; it is not a sandbox for untrusted source code. vm.runInContext(source, context, { filename: 'Scoresheet.js' }); return { requests, downloads, revokedUrls, async click(dataset) { - context.button = { dataset }; - vm.runInContext(`(function () { ${clickHandler} }).call(button);`, context); + assert.equal(clickHandler, + 'exportScoresheet(this.dataset.scoresheetId, this.dataset.scoresheetName, this.dataset.scoresheetTitle);'); + context.exportScoresheet(dataset.scoresheetId, dataset.scoresheetName, dataset.scoresheetTitle); await new Promise(resolve => setImmediate(resolve)); assert.equal(context.xssExecuted, false, 'Metadata must not execute as JavaScript'); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs index 6c002f5324..cb0da48a8c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs @@ -48,11 +48,17 @@ function preview(options, previewId = 'scoresheet-preview') { return []; } }, - Sortable: class {}, + Sortable: class { + constructor(element) { + assert.ok(element, 'Sortable must receive an existing preview element'); + } + }, abp: { ModalManager: class { onResult() {} } }, PubSub: { subscribe(topic, handler) { subscriptions.set(topic, handler); } }, updateSubtotal() {} }; + // Trusted test input: only Scoresheet.js from the fixed repository path above. + // Test option values stay in dataset data, never in the evaluated source. vm.runInNewContext(source, context, { filename: 'Scoresheet.js' }); readyHandlers.forEach(handler => handler()); return { diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs index d422d81968..aaa4751e5c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs @@ -31,8 +31,8 @@ function handlerFor(markup) { } function invokeHandler(context, handler, field) { - context.field = field; - vm.runInContext(`(function () { ${handler} }).call(field);`, context); + assert.equal(handler, 'notifyFieldChange(this.dataset.worksheetName, this.dataset.uiAnchor, this)'); + context.notifyFieldChange(field.dataset.worksheetName, field.dataset.uiAnchor, field); assert.equal(context.xssExecuted, false, 'Metadata must not execute as JavaScript'); } @@ -46,8 +46,8 @@ const metadataCases = [ { worksheetName: '', uiAnchor: '' } ]; -// These checks inspect Razor bindings and run actual handler source with decoded -// dataset values. They do not render Razor or parse HTML in a browser. +// These checks verify the exact fixed Razor handler, then invoke its target with +// decoded dataset values. They do not render Razor or parse HTML in a browser. for (const widget of widgets) { test(`${widget.name} forwards metadata as data in every field branch`, () => { const controls = controlsFor(widget); @@ -89,6 +89,8 @@ function notificationHarness() { } } }); + // Trusted test input: only Details.js from the fixed repository path above. + // Metadata is passed as function arguments, never as evaluated source code. vm.runInContext(detailsSource, context, { filename: 'Details.js' }); return { events, diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs index 498ca97649..81912b4fff 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/JavaScript/AttachmentHtmlRendering.test.cjs @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const path = require('node:path'); -const { test } = require('node:test'); +const { before, test } = require('node:test'); const vm = require('node:vm'); const webRoot = path.resolve(__dirname, '../../../src/Unity.GrantManager.Web'); @@ -15,7 +15,9 @@ const dataTablesSource = readFileSync(path.join(webRoot, 'node_modules/datatable const escapeSource = dataTablesSource.match(/var _escapeHtml = function \( d \) \{[\s\S]*?\n\t\};/)?.[0]; const textSource = dataTablesSource.match(/text: function \(\) \{[\s\S]*?\n\t\t\}/)?.[0]; const writeCellSource = dataTablesSource.match(/function _fnWriteCell\(td, val\)[\s\S]*?\n\t\}/)?.[0]; -assert.ok(escapeSource && textSource && writeCellSource, 'Locate the installed DataTables implementations'); +before(() => { + assert.ok(escapeSource && textSource && writeCellSource, 'Locate the installed DataTables implementations'); +}); const owners = { Assessment: ['#AssessmentId', 'assessment-owner'], From 546f66e320b033ffe388abbc544ab5d8de42faf7 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 14 Oct 2026 15:00:03 -0700 Subject: [PATCH 17/45] feature/AB#34582-TightenApi-Queue-Retry --- .../Constants/QueueingConstants.cs | 12 ++++ .../Interfaces/IQueueChannelProvider.cs | 8 +++ .../QueueChannelProvider.cs | 56 +++++++++++++++++ .../QueueConsumerHandler.cs | 63 ++++++++++++++++++- 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs index 6e70f6c617..aab9837dba 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs @@ -1,9 +1,21 @@  +using System; + namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants { public static class QueueingConstants { public const string DeadletterAddition = "-deadletter"; + public const string RetryAddition = ".retry"; + public const string RetryCountHeader = "x-retry-count"; + public const int MaxMessageRetries = 3; public const int MAX_RABBIT_CONCURRENT_CONSUMERS = 10; + + public static readonly TimeSpan[] RetryDelays = + [ + TimeSpan.FromSeconds(30), + TimeSpan.FromMinutes(2), + TimeSpan.FromMinutes(10) + ]; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs index 1baa6ebc8c..db472ad5eb 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; using RabbitMQ.Client; +using RabbitMQ.Client.Events; #pragma warning disable CA1005 // Avoid excessive parameters on generic types #pragma warning disable S2326 @@ -16,6 +18,12 @@ public interface IQueueChannelProvider : IDisposable where TQueue /// Task GetChannelAsync(); + /// + /// Republishes a failed delivery to a delayed retry queue. The returned task completes + /// only after RabbitMQ confirms the retry publish. + /// + Task PublishRetryAsync(IChannel channel, BasicDeliverEventArgs delivery, int retryCount, TimeSpan delay, CancellationToken cancellationToken = default); + /// /// Returns a channel obtained from so it can be pooled /// or disposed and its throttling permit released. Callers that finish with a channel diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 9be537782d..3a46e2a315 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RabbitMQ.Client; +using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; @@ -71,6 +72,38 @@ public async Task GetChannelAsync() } } + public async Task PublishRetryAsync( + IChannel channel, + BasicDeliverEventArgs delivery, + int retryCount, + TimeSpan delay, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(channel); + ArgumentNullException.ThrowIfNull(delivery); + + var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}"; + var retryQueue = $"{retryExchange}.{GetRetryDelayKey(delay)}"; + var properties = new BasicProperties + { + Persistent = true, + Type = delivery.BasicProperties.Type, + MessageId = delivery.BasicProperties.MessageId, + Headers = new Dictionary + { + [QueueingConstants.RetryCountHeader] = retryCount + } + }; + + await channel.BasicPublishAsync( + exchange: retryExchange, + routingKey: retryQueue, + mandatory: false, + basicProperties: properties, + body: delivery.Body, + cancellationToken: cancellationToken); + } + public void ReturnChannel(IChannel channel) { if (channel?.IsOpen == true && !_disposed) @@ -122,6 +155,7 @@ private async Task DeclareQueueAsync(IChannel channel) { var dlxName = $"{_queueName}.dlx"; var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}"; + var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}"; // Ensure DLX exchange exists await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); @@ -135,6 +169,25 @@ await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDe }); await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null); + await channel.ExchangeDeclareAsync(retryExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); + foreach (var retryDelay in QueueingConstants.RetryDelays) + { + var retryQueue = $"{retryExchange}.{GetRetryDelayKey(retryDelay)}"; + await channel.QueueDeclareAsync( + retryQueue, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + { "x-queue-type", "quorum" }, + { "x-message-ttl", checked((int)retryDelay.TotalMilliseconds) }, + { "x-dead-letter-exchange", $"{_queueName}.exchange" }, + { "x-dead-letter-routing-key", _queueName } + }); + await channel.QueueBindAsync(retryQueue, retryExchange, retryQueue, arguments: null); + } + // Declare main queue with DLX args await channel.QueueDeclareAsync( _queueName, @@ -172,6 +225,9 @@ await channel.QueueDeclareAsync( } } + private static string GetRetryDelayKey(TimeSpan delay) => + $"{(int)delay.TotalSeconds}s"; + private async Task BindToExchangeAsync(IChannel channel) { var mainExchange = $"{_queueName}.exchange"; diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index 37e07c7048..5cb14e0cea 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -1,12 +1,13 @@ using System; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using RabbitMQ.Client; using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Exceptions; +using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; using Unity.Modules.Shared.Utils; using Volo.Abp.Auditing; @@ -126,9 +127,65 @@ private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea) } catch (Exception ex) { - _logger.LogError(ex, "Error processing message on {Queue}", _queueName); - await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); + var retryCount = GetRetryCount(ea.BasicProperties.Headers); + if (IsPermanentFailure(ex) || retryCount >= QueueingConstants.MaxMessageRetries) + { + _logger.LogError(ex, + "Permanently rejecting message {MessageId} on {Queue} after {RetryCount} retries", + ea.BasicProperties.MessageId, + _queueName, + retryCount); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); + return; + } + + var nextRetryCount = retryCount + 1; + var delay = QueueingConstants.RetryDelays[retryCount]; + var channelProvider = consumerScope.ServiceProvider + .GetRequiredService>(); + + _logger.LogWarning(ex, + "Scheduling retry {RetryCount} for message {MessageId} on {Queue} in {Delay}", + nextRetryCount, + ea.BasicProperties.MessageId, + _queueName, + delay); + + await channelProvider.PublishRetryAsync(consumingChannel, ea, nextRetryCount, delay); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); + } + } + + private static int GetRetryCount(IDictionary? headers) + { + if (headers == null || !headers.TryGetValue(QueueingConstants.RetryCountHeader, out var value)) + { + return 0; + } + + return value switch + { + int count => Math.Max(count, 0), + long count => Math.Max((int)count, 0), + byte[] bytes when int.TryParse(Encoding.UTF8.GetString(bytes), out var count) => Math.Max(count, 0), + _ when int.TryParse(value?.ToString(), out var count) => Math.Max(count, 0), + _ => 0 + }; + } + + private static bool IsPermanentFailure(Exception exception) + { + for (var current = exception; current != null; current = current.InnerException) + { + if (current is ArgumentException || + current is Volo.Abp.Validation.AbpValidationException || + current is Volo.Abp.Domain.Entities.EntityNotFoundException) + { + return true; + } } + + return false; } /// From 1d2ca3b59040408c03ba970ff67cc9bb6b5faba4 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 14 Oct 2026 15:08:44 -0700 Subject: [PATCH 18/45] AB#33423: Fix sonarqube issues --- .../JavaScript/ScoresheetExport.test.cjs | 114 ------------- .../JavaScript/ScoresheetPreview.test.cjs | 155 ----------------- .../WorksheetFieldHandlers.test.cjs | 156 ------------------ 3 files changed, 425 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs delete mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs delete mode 100644 applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/WorksheetFieldHandlers.test.cjs diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs deleted file mode 100644 index 59179fc3b8..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetExport.test.cjs +++ /dev/null @@ -1,114 +0,0 @@ -const assert = require('node:assert/strict'); -const { readFileSync } = require('node:fs'); -const path = require('node:path'); -const { before, test } = require('node:test'); -const vm = require('node:vm'); - -const componentDirectory = path.resolve(__dirname, - '../../../src/Unity.Flex.Web/Views/Shared/Components/Scoresheet'); -const view = readFileSync(path.join(componentDirectory, 'Default.cshtml'), 'utf8'); -const source = readFileSync(path.join(componentDirectory, 'Scoresheet.js'), 'utf8'); -const buttonMarkup = view.match(/]*\bid="export_scoresheet_btn"[^>]*>/s)?.[0]; -const clickHandler = buttonMarkup?.match(/\bonclick="([^"]*)"/)?.[1]; - -before(() => { - assert.ok(buttonMarkup, 'The shared scoresheet view must contain the export button'); - assert.ok(clickHandler, 'The export button must have a click handler'); -}); - -test('export metadata uses Razor-encoded data attributes instead of JavaScript interpolation', () => { - assert.match(buttonMarkup, /\bdata-scoresheet-id="@scoresheet\.Id"/); - assert.match(buttonMarkup, /\bdata-scoresheet-name="@scoresheet\.Name"/); - assert.match(buttonMarkup, /\bdata-scoresheet-title="@scoresheet\.Title"/); - assert.doesNotMatch(clickHandler, /@/); -}); - -// Verify the view's fixed handler, then call the production export function with -// decoded dataset values. These adapters do not render Razor, parse HTML, or -// simulate browser downloads. Attribute encoding remains a manual check. -function exportsHarness() { - const requests = []; - const downloads = []; - const revokedUrls = []; - const context = { - xssExecuted: false, - $() {}, // Export does not require the preview's ready callbacks. - abp: { ModalManager: class { onResult() {} } }, - console, - document: { - createElement(tag) { - assert.equal(tag, 'a'); - return { - style: {}, - click() { downloads.push({ href: this.href, name: this.download }); } - }; - }, - body: { appendChild() {} } - }, - window: { - URL: { - createObjectURL(blob) { - assert.equal(blob, 'scoresheet-json'); - return 'blob:scoresheet-export'; - }, - revokeObjectURL(url) { revokedUrls.push(url); } - } - }, - async fetch(url) { - requests.push(url); - return { ok: true, async blob() { return 'scoresheet-json'; } }; - } - }; - vm.createContext(context); - // Trusted test input: only Scoresheet.js from the fixed repository path above. - // VM separates browser globals; it is not a sandbox for untrusted source code. - vm.runInContext(source, context, { filename: 'Scoresheet.js' }); - return { - requests, downloads, revokedUrls, - async click(dataset) { - assert.equal(clickHandler, - 'exportScoresheet(this.dataset.scoresheetId, this.dataset.scoresheetName, this.dataset.scoresheetTitle);'); - context.exportScoresheet(dataset.scoresheetId, dataset.scoresheetName, dataset.scoresheetTitle); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(context.xssExecuted, false, 'Metadata must not execute as JavaScript'); - } - }; -} - -const scoresheetId = '11111111-1111-1111-1111-111111111111'; -const cases = [ - { label: 'ordinary metadata', name: 'application-v1', title: 'Application Review' }, - { label: 'apostrophes', name: "reviewer's-v1", title: "Reviewer's scoresheet" }, - { label: 'quotes and markup', name: 'review-v1', title: 'A & B < C > D "quoted"' }, - { label: 'literal entities and Unicode', name: 'review-v2', title: 'Café <review> '' }, - { label: 'script-shaped title', name: 'review-v1', title: "');globalThis.xssExecuted = true;//" }, - { label: 'script-shaped name', name: "');globalThis.xssExecuted = true;//", title: 'Review' }, - { label: 'attribute-shaped title', name: 'review-v1', title: '">' } -]; - -for (const { label, name, title } of cases) { - test(`export preserves ${label} as filename data`, async () => { - const harness = exportsHarness(); - await harness.click({ scoresheetId, scoresheetName: name, scoresheetTitle: title }); - assert.deepEqual(harness.requests, [`/api/app/scoresheet/export/${scoresheetId}`]); - assert.deepEqual(harness.downloads, [{ - href: 'blob:scoresheet-export', name: `scoresheet_${title}_${name}.json` - }]); - assert.deepEqual(harness.revokedUrls, ['blob:scoresheet-export']); - }); -} - -test('a refreshed button exports its own updated metadata and ID once per click', async () => { - const harness = exportsHarness(); - await harness.click({ scoresheetId, scoresheetName: 'old-v1', scoresheetTitle: 'Old' }); - const refreshedId = '22222222-2222-2222-2222-222222222222'; - await harness.click({ - scoresheetId: refreshedId, scoresheetName: 'new-v2', scoresheetTitle: "Updated reviewer's title" - }); - assert.deepEqual(harness.requests, [ - `/api/app/scoresheet/export/${scoresheetId}`, `/api/app/scoresheet/export/${refreshedId}` - ]); - assert.deepEqual(harness.downloads.map(download => download.name), [ - 'scoresheet_Old_old-v1.json', "scoresheet_Updated reviewer's title_new-v2.json" - ]); -}); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs deleted file mode 100644 index cb0da48a8c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/JavaScript/ScoresheetPreview.test.cjs +++ /dev/null @@ -1,155 +0,0 @@ -const assert = require('node:assert/strict'); -const { readFileSync } = require('node:fs'); -const path = require('node:path'); -const { test } = require('node:test'); -const vm = require('node:vm'); - -const source = readFileSync(path.resolve(__dirname, - '../../../src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js'), 'utf8'); - -// Execute the actual ready/refresh handlers and capture the HTML written to the -// preview. These adapters do not parse HTML or simulate browser script execution. -function preview(options, previewId = 'scoresheet-preview') { - const readyHandlers = []; - const subscriptions = new Map(); - const htmlWrites = []; - const section = { - innerText: 'Section', dataset: { label: 'Section' }, - classList: { contains: name => name === 'section-item' } - }; - const question = { - innerText: 'Select question', - dataset: { - id: '11111111-1111-1111-1111-111111111111', questiontype: 'SelectList', - required: 'True', questionlabel: '', questiondesc: '', - definition: JSON.stringify({ options }) - }, - classList: { contains: () => false } - }; - const expandedBody = { - querySelectorAll(selector) { - assert.equal(selector, '.list-group-item'); - return [section, question]; - } - }; - const nodes = new Map([ - [previewId, { set innerHTML(html) { htmlWrites.push(html); } }], - ['scoresheet-accordion', {}], - ['previewSaveAllBtn', {}], - ['previewDiscardAllBtn', {}] - ]); - const context = { - $(handler) { assert.equal(typeof handler, 'function'); readyHandlers.push(handler); }, - document: { - getElementById(id) { return nodes.get(id) || null; }, - querySelectorAll(selector) { - if (selector === '#scoresheet-accordion .accordion-collapse.show') return [expandedBody]; - assert.ok(['[id^="sections-questions"]', '#scoresheet-accordion .accordion-button'].includes(selector)); - return []; - } - }, - Sortable: class { - constructor(element) { - assert.ok(element, 'Sortable must receive an existing preview element'); - } - }, - abp: { ModalManager: class { onResult() {} } }, - PubSub: { subscribe(topic, handler) { subscriptions.set(topic, handler); } }, - updateSubtotal() {} - }; - // Trusted test input: only Scoresheet.js from the fixed repository path above. - // Test option values stay in dataset data, never in the evaluated source. - vm.runInNewContext(source, context, { filename: 'Scoresheet.js' }); - readyHandlers.forEach(handler => handler()); - return { - get html() { return htmlWrites.at(-1); }, - get writes() { return htmlWrites.length; }, - refresh(nextOptions) { - question.dataset.definition = JSON.stringify({ options: nextOptions }); - subscriptions.get('refresh_scoresheet_configuration_page')(); - } - }; -} - -const placeholder = ''; - -function assertOption(html, { value, label = value, score = '0' }) { - assert.ok(html.includes(``), - 'Expected encoded attributes and text in the generated option'); -} - -const textCases = [ - { - name: 'literal punctuation', - value: `A & B < C > D "quoted" 'single'`, - encoded: `A & B < C > D "quoted" 'single'` - }, - { - name: 'markup and attribute breakout', - value: '">