From a6744d23d73f10b9379713926bb33aa613f0ec74 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 28 Jul 2026 14:17:52 -0700 Subject: [PATCH 1/9] feature/AB#33419-ForwardForSecurity --- .../EmailNotificationService.cs | 125 ++++++++++++------ .../EmailTemplates/CommentNotification.cshtml | 46 +++++++ 2 files changed, 127 insertions(+), 44 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index 2c3974a5e..8b2f5817f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; +using System.IO; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; +using System.Reflection; using System.Threading.Tasks; using Unity.GrantManager.Notifications; using Unity.Notifications.Emails; @@ -29,7 +32,8 @@ public class EmailNotificationService( IExternalUserLookupServiceProvider externalUserLookupServiceProvider, ISettingManager settingManager, IFeatureChecker featureChecker, - IHttpContextAccessor httpContextAccessor) : ApplicationService, IEmailNotificationService + IConfiguration configuration, + IWebHostEnvironment webHostEnvironment) : ApplicationService, IEmailNotificationService { public async Task InitializeDraftAsync(Guid applicationId) @@ -80,22 +84,16 @@ protected virtual async Task NotifyTeamsChannel(string chesEmailError) public Task GetBaseUrlAsync() { - var httpContext = httpContextAccessor.HttpContext - ?? throw new InvalidOperationException("No active HTTP context available to resolve base URL."); - - var request = httpContext.Request; - - var host = request.Headers["X-Forwarded-Host"].FirstOrDefault() - ?? request.Host.Value; - - var scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() - ?? request.Scheme; - - var pathBase = request.Headers["X-Forwarded-Prefix"].FirstOrDefault() - ?? request.PathBase.Value - ?? string.Empty; - - return Task.FromResult($"{scheme}://{host}{pathBase}".TrimEnd('/')); + var selfUrl = configuration["App:SelfUrl"]; + + if (string.IsNullOrWhiteSpace(selfUrl)) + { + throw new InvalidOperationException( + "App:SelfUrl configuration is not set. Cannot resolve base URL for email notifications. " + + "Ensure the configuration is properly set in appsettings or environment variables."); + } + + return Task.FromResult(selfUrl.TrimEnd('/')); } public async Task SendCommentNotification(EmailCommentDto input) @@ -129,32 +127,7 @@ public async Task SendCommentNotification(EmailCommentDto i _ => CurrentUser.UserName ?? "Unknown User" }; - string htmlBody = $@" - - -

{currentUserText} mentioned you in a comment.

- - - - -
-

{input.Body}

-
-
- - - - -
- View Comment -
-

*Note - Please do not reply to this email as it is an automated notification.

- - "; + string htmlBody = await RenderCommentNotificationTemplateAsync(currentUserText, input.Body, commentLink); foreach (var email in input.MentionNamesEmail) { @@ -274,4 +247,68 @@ private async Task UpdateTenantSettings(string settingKey, string valueString) await settingManager.SetForCurrentTenantAsync(settingKey, valueString); } } + + /// + /// Renders the comment notification email template with the provided parameters. + /// + /// Display name of the user who mentioned + /// The comment body text (may contain HTML) + /// The URL link to view the comment + /// Rendered HTML email body + private async Task RenderCommentNotificationTemplateAsync(string currentUserText, string commentBody, string commentLink) + { + // Load template from embedded resources or file system + string templateContent = await LoadEmailTemplateAsync("CommentNotification"); + + // Replace placeholders with actual values + var renderedTemplate = templateContent + .Replace("@Model.CurrentUserText", currentUserText) + .Replace("@Html.Raw(Model.CommentBody)", commentBody) + .Replace("@Model.CommentLink", commentLink); + + return renderedTemplate; + } + + /// + /// Loads an email template from the Views/EmailTemplates directory. + /// + /// Template name without extension (e.g., "CommentNotification") + /// Template content as a string + private async Task LoadEmailTemplateAsync(string templateName) + { + try + { + // Content root is at: .../Unity.GrantManager/src/Unity.GrantManager.Web + // We need to go up 2 levels to reach Unity.GrantManager, then into modules + var contentRoot = webHostEnvironment.ContentRootPath; + + var templatePath = Path.Combine( + contentRoot, + "..", + "..", + "modules", + "Unity.Notifications", + "src", + "Unity.Notifications.Web", + "Views", + "EmailTemplates", + $"{templateName}.cshtml"); + + // Normalize the path to remove .. references + templatePath = Path.GetFullPath(templatePath); + + if (!File.Exists(templatePath)) + { + throw new FileNotFoundException($"Email template not found at: {templatePath}"); + } + + var content = await File.ReadAllTextAsync(templatePath); + return content; + } + catch (Exception ex) + { + Logger.LogError(ex, $"Failed to load email template '{templateName}'"); + throw; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml new file mode 100644 index 000000000..703a31673 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml @@ -0,0 +1,46 @@ +@model dynamic + + + + + + Comment Notification + + +

@Model.CurrentUserText mentioned you in a comment.

+ + + + + + + + + + + +
Comment
+

@Html.Raw(Model.CommentBody)

+
+
+ + + + + + + + + + + +
Action
+ View Comment +
+

*Note - Please do not reply to this email as it is an automated notification.

+ + From abde7fe30a6b7b3befa291a67ce9e28936d5c467 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Tue, 28 Jul 2026 14:20:54 -0700 Subject: [PATCH 2/9] AB#33905 - Changed to Reloade the application in a fresh and short-lived UOW --- .../AIGenerationRequestJobHelper.cs | 21 +++++++++++++++++++ .../GenerateApplicationAnalysisJob.cs | 8 +++++-- .../GenerateApplicationScoringJob.cs | 9 ++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs index 5ab904e4c..f251b9c36 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Unity.AI.Domain; using Unity.AI.Cooldown; +using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; @@ -13,6 +14,26 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public static class AIGenerationRequestJobHelper { + /// + /// Reloads the application in a fresh, short-lived unit of work and persists only the + /// result mutated by . Keeping this load-to-save window + /// short (rather than holding the aggregate loaded across a slow AI call) avoids + /// AbpDbConcurrencyException when unrelated parts of the aggregate (e.g. ApplicationForm) + /// are modified concurrently. + /// + public static async Task SaveApplicationResultInNewUowAsync( + IUnitOfWorkManager unitOfWorkManager, + IApplicationRepository applicationRepository, + Guid applicationId, + Action applyResult) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var application = await applicationRepository.GetAsync(applicationId); + applyResult(application); + await applicationRepository.UpdateAsync(application); + await uow.CompleteAsync(); + } + public static async Task MarkRunningAsync( IRepository generationRequestRepository, AIGenerationRequest? request) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 7ece6972b..6712f59ec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -50,8 +50,12 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( var promptData = objectMapper.Map(application); var analysisInput = await aiApplicationInputBuilder.BuildApplicationAnalysisInputAsync(promptData, args.PromptVersion); var analysisJson = await applicationAnalysisService.RegenerateAsync(analysisInput); - application.AIAnalysis = analysisJson; - await applicationRepository.UpdateAsync(application); + + await AIGenerationRequestJobHelper.SaveApplicationResultInNewUowAsync( + unitOfWorkManager, + applicationRepository, + args.ApplicationId, + app => app.AIAnalysis = analysisJson); await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index 949e7f461..8a0da347b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -52,8 +52,13 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( var promptData = objectMapper.Map(application); var scoringInput = await aiApplicationInputBuilder.BuildApplicationScoringInputAsync(promptData, args.PromptVersion); var scoresheetAnswers = await applicationScoringService.RegenerateAsync(scoringInput); - application.AIScoresheetAnswers = scoresheetAnswers; - await applicationRepository.UpdateAsync(application); + + await AIGenerationRequestJobHelper.SaveApplicationResultInNewUowAsync( + unitOfWorkManager, + applicationRepository, + args.ApplicationId, + app => app.AIScoresheetAnswers = scoresheetAnswers); + await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent { ApplicationId = args.ApplicationId From 218481dfb0c5dfd6cdaec6bb7e36645e309e5e97 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 28 Jul 2026 14:22:05 -0700 Subject: [PATCH 3/9] feature/AB#33419-ForwardForSecurity --- .../EmailNotificaions/EmailNotificationService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index 8b2f5817f..0abcfd1e3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Reflection; using System.Threading.Tasks; using Unity.GrantManager.Notifications; using Unity.Notifications.Emails; From 42b8796ecd2ebaa9d3df4c793cab6c298b8a20b3 Mon Sep 17 00:00:00 2001 From: Armin Hasanpour Date: Tue, 28 Jul 2026 14:43:56 -0700 Subject: [PATCH 4/9] AB#33905 - Removed unused using Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs index f251b9c36..5f059859a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs @@ -6,7 +6,6 @@ using Unity.AI.Domain; using Unity.AI.Cooldown; using Unity.GrantManager.Applications; -using Unity.GrantManager.GrantApplications; using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; From 99f6f0b116600fa9c23af44a0d76e19cc08bb1f3 Mon Sep 17 00:00:00 2001 From: "Todosichuk, Daryl" Date: Wed, 29 Jul 2026 07:24:03 -0700 Subject: [PATCH 5/9] AB#33654 Workflow migration changes --- .github/workflows/docker-build-dev.yml | 46 +++++---- .github/workflows/docker-build-dev2.yml | 120 ++++++++++++++++++++++++ .github/workflows/docker-build-main.yml | 33 +++---- .github/workflows/docker-build-test.yml | 46 +++++---- 4 files changed, 191 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/docker-build-dev2.yml diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index be32d7f22..d9cab254f 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -24,7 +24,9 @@ env: GH_TOKEN: ${{secrets.GH_API_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_TOOLS_TOKEN: ${{ secrets.TOOLS_OPENSHIFT_TOKEN }} + OC_TOOLS_PROJECT: ${{ vars.TOOLS_OPENSHIFT_NAMESPACE }} + OC_TARGET_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} @@ -47,7 +49,7 @@ jobs: run: | echo "Target: $TARGET_ENV" echo "BaseRef: $GITHUB_REF_NAME" - echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV OC_TOOLS_PROJECT=$OC_TOOLS_PROJECT OC_TARGET_PROJECT=$OC_TARGET_PROJECT" echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" echo "..." env | sort @@ -124,18 +126,15 @@ jobs: docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - - name: Connect to JFrog Artifactory non-interactive login using --password-stdin - run: | - echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry + continue-on-error: true run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - - name: Disconnect docker from JFrog Artifactory - run: | - docker logout + docker logout $JFROG_SERVICE - name: Install OpenShift CLI run: | curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz @@ -143,17 +142,30 @@ jobs: sudo mv oc /usr/local/bin - name: Verify OpenShift CLI installation run: oc version - - name: Connect to OpenShift API non-interactive login using current session token + - name: Push application images into ce395f-tools run: | - oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + oc login --token=$OC_TOOLS_TOKEN --server=$OC_CLUSTER oc registry login docker login -u unused -p $(oc whoami -t) $OC_REGISTRY - - name: Push application images to OpenShift container registry + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker logout $OC_REGISTRY + - name: Promote dbMigrator into ce395f-dev and run migrations run: | - docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - - name: Disconnect docker from OpenShift container registry + oc login --token=$OC_TARGET_TOKEN --server=$OC_CLUSTER + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + oc -n $OC_TARGET_PROJECT delete jobs $TARGET_ENV-unity-dbmigrator --ignore-not-found=true + oc -n $OC_TARGET_PROJECT process unity-grantmanager-dbmigrator-job \ + -p APPLICATION_GROUP=$TARGET_ENV-unity-grantmanager \ + -p DATABASE_SERVICE_NAME=$TARGET_ENV-unity-data-postgres \ + -p APPLICATION_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGEPULL_NAMESPACE=$OC_TARGET_PROJECT \ + -p IMAGESTREAM_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGESTREAM_TAG=latest | oc -n $OC_TARGET_PROJECT create -f - + oc -n $OC_TARGET_PROJECT wait jobs/$TARGET_ENV-unity-dbmigrator --for condition=complete --timeout=300s + - name: Promote grantmanager-web into ce395f-dev and wait run: | - docker logout + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + oc -n $OC_TARGET_PROJECT rollout status deployment/$TARGET_ENV-unity-grantmanager-web --timeout=180s diff --git a/.github/workflows/docker-build-dev2.yml b/.github/workflows/docker-build-dev2.yml new file mode 100644 index 000000000..8b5911c07 --- /dev/null +++ b/.github/workflows/docker-build-dev2.yml @@ -0,0 +1,120 @@ +name: Dev2 - Build & Push docker images + +on: + push: + branches: [ "dev2" ] + paths-ignore: + - '.github/**' + - '.gitignore' + - 'database/**' + - 'documentation/**' + - '**/docs/**' + - '**/README*' + - 'CODE_OF_CONDUCT.md' + - 'COMPLIANCE.yaml' + - 'CONTRIBUTING.md' + - 'LICENSE' + - 'README.md' + - 'SECURITY.md' + # Allow manual workflow triggering + workflow_dispatch: + +env: + TARGET_ENV: dev2 + GH_TOKEN: ${{secrets.GH_API_TOKEN}} + OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} + OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} + OC_TARGET_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} + JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} + JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} + JFROG_REPO_PATH: ${{ vars.ARTIFACTORY_REPO }} + JFROG_SERVICE: ${{ vars.ARTIFACTORY_SERVICE }} + +jobs: + Setup: + runs-on: ubuntu-latest + environment: dev2 + permissions: + contents: read + steps: + - name: Get variables + run: | + echo "Target: $TARGET_ENV" + echo "BaseRef: $GITHUB_REF_NAME" + echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" + echo "..." + env | sort + Branch: + needs: [Setup] + runs-on: ubuntu-latest + environment: dev2 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: '1' + - name: Get short commitId + id: get_commit + run: | + echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + outputs: + SHA_SHORT: ${{steps.get_commit.outputs.SHA_SHORT}} + Build: + needs: [Setup,Branch] + runs-on: ubuntu-latest + environment: dev2 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Build Docker images + run: | + rm -f ./docker-compose.override.yml + docker build --build-arg UNITY_BUILD_VERSION=dev2 --build-arg UNITY_BUILD_REVISION=${{needs.Branch.outputs.SHA_SHORT}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . + docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . + working-directory: ./applications/Unity.GrantManager + - name: Push application images to Artifactory container registry + continue-on-error: true + run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE + docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker logout $JFROG_SERVICE + - name: Install OpenShift CLI + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz + tar -xvf oc.tar.gz + sudo mv oc /usr/local/bin + - name: Verify OpenShift CLI installation + run: oc version + - name: Push application images into ce395f-dev (no ce395f-tools hop for dev2) + run: | + oc login --token=$OC_TARGET_TOKEN --server=$OC_CLUSTER + oc registry login + docker login -u unused -p $(oc whoami -t) $OC_REGISTRY + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + docker push $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + docker push $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + docker logout $OC_REGISTRY + - name: Run dbMigrator Job + run: | + oc -n $OC_TARGET_PROJECT delete jobs $TARGET_ENV-unity-dbmigrator --ignore-not-found=true + oc -n $OC_TARGET_PROJECT process unity-grantmanager-dbmigrator-job \ + -p APPLICATION_GROUP=$TARGET_ENV-unity-grantmanager \ + -p DATABASE_SERVICE_NAME=$TARGET_ENV-unity-data-postgres \ + -p APPLICATION_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGEPULL_NAMESPACE=$OC_TARGET_PROJECT \ + -p IMAGESTREAM_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGESTREAM_TAG=latest | oc -n $OC_TARGET_PROJECT create -f - + oc -n $OC_TARGET_PROJECT wait jobs/$TARGET_ENV-unity-dbmigrator --for condition=complete --timeout=300s + - name: Roll out grantmanager-web and wait + run: | + oc -n $OC_TARGET_PROJECT rollout restart deployment/$TARGET_ENV-unity-grantmanager-web + oc -n $OC_TARGET_PROJECT rollout status deployment/$TARGET_ENV-unity-grantmanager-web --timeout=180s diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml index b146da454..f967c2061 100644 --- a/.github/workflows/docker-build-main.yml +++ b/.github/workflows/docker-build-main.yml @@ -24,8 +24,8 @@ env: GH_TOKEN: ${{secrets.GH_API_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} - OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} + OC_TOOLS_TOKEN: ${{ secrets.TOOLS_OPENSHIFT_TOKEN }} + OC_TOOLS_PROJECT: ${{ vars.TOOLS_OPENSHIFT_NAMESPACE }} JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} JFROG_REPO_PATH: ${{ vars.ARTIFACTORY_REPO }} @@ -47,7 +47,7 @@ jobs: run: | echo "Target: $TARGET_ENV" echo "BaseRef: $GITHUB_REF_NAME" - echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV OC_TOOLS_PROJECT=$OC_TOOLS_PROJECT" echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" echo "..." env | sort @@ -181,18 +181,15 @@ jobs: docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - - name: Connect to JFrog Artifactory non-interactive login using --password-stdin - run: | - echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry + continue-on-error: true run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:stable docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:stable docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:stable docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:stable - - name: Disconnect docker from JFrog Artifactory - run: | - docker logout + docker logout $JFROG_SERVICE - name: Install OpenShift CLI run: | curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz @@ -200,17 +197,13 @@ jobs: sudo mv oc /usr/local/bin - name: Verify OpenShift CLI installation run: oc version - - name: Connect to OpenShift API non-interactive login using current session token + - name: Push application images into ce395f-tools run: | - oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + oc login --token=$OC_TOOLS_TOKEN --server=$OC_CLUSTER oc registry login docker login -u unused -p $(oc whoami -t) $OC_REGISTRY - - name: Push application images to OpenShift container registry - run: | - docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator:stable - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator:stable - docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web:stable - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web:stable - - name: Disconnect docker from OpenShift container registry - run: | - docker logout + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:stable + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:stable + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:stable + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:stable + docker logout $OC_REGISTRY diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index 9728ee15d..9ea19ba14 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -24,7 +24,9 @@ env: GH_TOKEN: ${{secrets.GH_API_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_TOOLS_TOKEN: ${{ secrets.TOOLS_OPENSHIFT_TOKEN }} + OC_TOOLS_PROJECT: ${{ vars.TOOLS_OPENSHIFT_NAMESPACE }} + OC_TARGET_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} @@ -47,7 +49,7 @@ jobs: run: | echo "Target: $TARGET_ENV" echo "BaseRef: $GITHUB_REF_NAME" - echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV OC_TOOLS_PROJECT=$OC_TOOLS_PROJECT OC_TARGET_PROJECT=$OC_TARGET_PROJECT" echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" echo "..." env | sort @@ -160,18 +162,15 @@ jobs: docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - - name: Connect to JFrog Artifactory non-interactive login using --password-stdin - run: | - echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry + continue-on-error: true run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - - name: Disconnect docker from JFrog Artifactory - run: | - docker logout + docker logout $JFROG_SERVICE - name: Install OpenShift CLI run: | curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz @@ -179,17 +178,30 @@ jobs: sudo mv oc /usr/local/bin - name: Verify OpenShift CLI installation run: oc version - - name: Connect to OpenShift API non-interactive login using current session token + - name: Push application images into ce395f-tools run: | - oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + oc login --token=$OC_TOOLS_TOKEN --server=$OC_CLUSTER oc registry login docker login -u unused -p $(oc whoami -t) $OC_REGISTRY - - name: Push application images to OpenShift container registry + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker logout $OC_REGISTRY + - name: Promote dbMigrator into ce395f-test and run migrations run: | - docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - - name: Disconnect docker from OpenShift container registry + oc login --token=$OC_TARGET_TOKEN --server=$OC_CLUSTER + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + oc -n $OC_TARGET_PROJECT delete jobs $TARGET_ENV-unity-dbmigrator --ignore-not-found=true + oc -n $OC_TARGET_PROJECT process unity-grantmanager-dbmigrator-job \ + -p APPLICATION_GROUP=$TARGET_ENV-unity-grantmanager \ + -p DATABASE_SERVICE_NAME=$TARGET_ENV-unity-data-postgres \ + -p APPLICATION_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGEPULL_NAMESPACE=$OC_TARGET_PROJECT \ + -p IMAGESTREAM_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGESTREAM_TAG=latest | oc -n $OC_TARGET_PROJECT create -f - + oc -n $OC_TARGET_PROJECT wait jobs/$TARGET_ENV-unity-dbmigrator --for condition=complete --timeout=300s + - name: Promote grantmanager-web into ce395f-test and wait run: | - docker logout + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + oc -n $OC_TARGET_PROJECT rollout status deployment/$TARGET_ENV-unity-grantmanager-web --timeout=180s From abc50bfcea042ae6a405078edb83a3279ae9dc5f Mon Sep 17 00:00:00 2001 From: "Todosichuk, Daryl" Date: Wed, 29 Jul 2026 09:28:18 -0700 Subject: [PATCH 6/9] AB#33654 Bugfix dev2 resolve version from dev values --- .github/workflows/docker-build-dev2.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-build-dev2.yml b/.github/workflows/docker-build-dev2.yml index 8b5911c07..5a9cad542 100644 --- a/.github/workflows/docker-build-dev2.yml +++ b/.github/workflows/docker-build-dev2.yml @@ -71,10 +71,14 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - name: Resolve build version (from dev) and revision (dev2's own commit) + run: | + echo "UGM_BUILD_VERSION=$(gh variable get UGM_BUILD_VERSION --env dev)" >> $GITHUB_ENV + echo "UGM_BUILD_REVISION=${{needs.Branch.outputs.SHA_SHORT}}" >> $GITHUB_ENV - name: Build Docker images run: | rm -f ./docker-compose.override.yml - docker build --build-arg UNITY_BUILD_VERSION=dev2 --build-arg UNITY_BUILD_REVISION=${{needs.Branch.outputs.SHA_SHORT}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . + docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - name: Push application images to Artifactory container registry From 90b48366258ac2e7304c911b6cd6aabd4eece01c Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 29 Jul 2026 10:32:58 -0700 Subject: [PATCH 7/9] AB#33919 - fix tenant map reconciler uow issue --- .../TenantMappings/ApplicantTenantMapReconciler.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs index 0c43076a1..378603969 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/TenantMappings/ApplicantTenantMapReconciler.cs @@ -10,6 +10,7 @@ using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; +using Volo.Abp.Uow; namespace Unity.GrantManager.ApplicantProfile; @@ -22,6 +23,7 @@ public class ApplicantTenantMapReconciler( ITenantRepository tenantRepository, IRepository applicantTenantMapRepository, IRepository applicationFormSubmissionRepository, + IUnitOfWorkManager unitOfWorkManager, ILogger logger) : IApplicantTenantMapReconciler, ITransientDependency { @@ -41,6 +43,7 @@ public class ApplicantTenantMapReconciler( logger.LogDebug("Collecting submissions from tenant: {TenantName}", tenant.Name); using (currentTenant.Change(tenant.Id)) + using (var unitOfWork = unitOfWorkManager.Begin(requiresNew: true)) { var submissionQueryable = await applicationFormSubmissionRepository.GetQueryableAsync(); var distinctOidcSubs = await submissionQueryable @@ -49,6 +52,8 @@ public class ApplicantTenantMapReconciler( .Distinct() .ToListAsync(); + await unitOfWork.CompleteAsync(); + foreach (var oidcSub in distinctOidcSubs) { var subUsername = SubjectNormalizer.Normalize(oidcSub); @@ -78,6 +83,7 @@ public class ApplicantTenantMapReconciler( int totalMappingsUpdated = 0; using (currentTenant.Change(null)) + using (var unitOfWork = unitOfWorkManager.Begin(requiresNew: true)) { var allSubUsernames = desiredMappings.Select(m => m.SubUsername).Distinct().ToList(); @@ -113,6 +119,8 @@ public class ApplicantTenantMapReconciler( subUsername, tenantName); } } + + await unitOfWork.CompleteAsync(); } logger.LogInformation("ApplicantTenantMap reconciliation completed. Created: {Created}, Updated: {Updated}", From 223dcb12b049e1b1563e2d3331de4b34b13998f3 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 29 Jul 2026 13:59:36 -0700 Subject: [PATCH 8/9] AB#33411: Security Finding-Validate The IDs Belong To The Tenant On Download --- .../Controllers/AttachmentController.cs | 35 +++- .../Components/AttachmentControllerTests.cs | 161 ++++++++++++++++-- 2 files changed, 176 insertions(+), 20 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs index cf05a1abd..ea85ba233 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs @@ -13,11 +13,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Assessments; using Unity.GrantManager.Attachments; using Unity.GrantManager.Intakes; using Unity.GrantManager.Models; using Unity.Notifications.Emails; using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.Validation; @@ -64,6 +67,9 @@ public class AttachmentController : AbpController private readonly ICurrentTenant _currentTenant; private readonly ILibreOfficeConversionService _libreOfficeConversionService; private readonly IAttachmentPreviewAppService _attachmentPreviewAppService; + private readonly IApplicantRepository _applicantRepository; + private readonly IApplicationRepository _applicationRepository; + private readonly IAssessmentRepository _assessmentRepository; // LazyServiceProvider is populated via property injection when ASP.NET Core activates this // controller through DI/routing. Unit tests that construct AttachmentController directly // (new AttachmentController(...)) bypass that activation, leaving it null - guard against @@ -87,7 +93,10 @@ public AttachmentController( IEmailLogAttachmentUploadService emailLogAttachmentUploadService, ICurrentTenant currentTenant, ILibreOfficeConversionService libreOfficeConversionService, - IAttachmentPreviewAppService attachmentPreviewAppService) + IAttachmentPreviewAppService attachmentPreviewAppService, + IApplicantRepository applicantRepository, + IApplicationRepository applicationRepository, + IAssessmentRepository assessmentRepository) { _fileAppService = fileAppService; _configuration = configuration; @@ -96,6 +105,9 @@ public AttachmentController( _currentTenant = currentTenant; _libreOfficeConversionService = libreOfficeConversionService; _attachmentPreviewAppService = attachmentPreviewAppService; + _applicantRepository = applicantRepository; + _applicationRepository = applicationRepository; + _assessmentRepository = assessmentRepository; } [HttpGet("applicant/{applicantId}/download/{fileName}")] @@ -116,6 +128,11 @@ public async Task DownloadApplicantAttachment(string applicantId, return BadRequest(badRequestFileMsg); } + if (!Guid.TryParse(applicantId, out var parsedApplicantId) || await _applicantRepository.FindAsync(parsedApplicantId) == null) + { + return NotFound(NotFoundFileMsg); + } + var folder = _configuration["S3:ApplicantS3Folder"] ?? throw new AbpValidationException("Missing server configuration: S3:ApplicantS3Folder"); if (!folder.EndsWith('/')) @@ -163,6 +180,11 @@ public async Task DownloadApplicationAttachment(string applicatio return BadRequest(badRequestFileMsg); } + if (!Guid.TryParse(applicationId, out var parsedApplicationId) || await _applicationRepository.FindAsync(parsedApplicationId) == null) + { + return NotFound(NotFoundFileMsg); + } + var folder = _configuration["S3:ApplicationS3Folder"] ?? throw new AbpValidationException("Missing server configuration: S3:ApplicationS3Folder"); if (!folder.EndsWith('/')) @@ -210,6 +232,11 @@ public async Task DownloadAssessmentAttachment(string assessmentI return BadRequest(badRequestFileMsg); } + if (!Guid.TryParse(assessmentId, out var parsedAssessmentId) || await _assessmentRepository.FindAsync(parsedAssessmentId) == null) + { + return NotFound(NotFoundFileMsg); + } + var folder = _configuration["S3:AssessmentS3Folder"] ?? throw new AbpValidationException("Missing server configuration: S3:AssessmentS3Folder"); if (!folder.EndsWith('/')) @@ -336,6 +363,7 @@ public async Task PreviewApplicationAttachment(string application if (string.IsNullOrWhiteSpace(applicationId)) return BadRequest("Application ID must be provided."); if (!Guid.TryParse(applicationId, out var parsedApplicationId)) return BadRequest("Application ID must be a valid GUID."); if (string.IsNullOrWhiteSpace(fileName)) return BadRequest(badRequestFileMsg); + if (await _applicationRepository.FindAsync(parsedApplicationId) == null) return NotFound(NotFoundFileMsg); if (!LibreOfficeInstallationCache.IsInstalled(() => _libreOfficeConversionService.IsInstalled())) return StatusCode(503, new { error = libreOfficeNotInstalledMsg }); try { @@ -357,6 +385,7 @@ public async Task PreviewAssessmentAttachment(string assessmentId if (string.IsNullOrWhiteSpace(assessmentId)) return BadRequest("Assessment ID must be provided."); if (!Guid.TryParse(assessmentId, out var parsedAssessmentId)) return BadRequest("Assessment ID must be a valid GUID."); if (string.IsNullOrWhiteSpace(fileName)) return BadRequest(badRequestFileMsg); + if (await _assessmentRepository.FindAsync(parsedAssessmentId) == null) return NotFound(NotFoundFileMsg); if (!LibreOfficeInstallationCache.IsInstalled(() => _libreOfficeConversionService.IsInstalled())) return StatusCode(503, new { error = libreOfficeNotInstalledMsg }); try { @@ -376,11 +405,13 @@ public async Task PreviewApplicantAttachment(string applicantId, { if (!ModelState.IsValid) return BadRequest(ModelState); if (string.IsNullOrWhiteSpace(applicantId)) return BadRequest("Applicant ID must be provided."); + if (!Guid.TryParse(applicantId, out var parsedApplicantId)) return BadRequest("Applicant ID must be a valid GUID."); if (string.IsNullOrWhiteSpace(fileName)) return BadRequest(badRequestFileMsg); + if (await _applicantRepository.FindAsync(parsedApplicantId) == null) return NotFound(NotFoundFileMsg); if (!_libreOfficeConversionService.IsInstalled()) return StatusCode(503, new { error = libreOfficeNotInstalledMsg }); try { - var blob = await _attachmentPreviewAppService.GetOrCreatePreviewPdfAsync(AttachmentType.APPLICANT, Guid.Parse(applicantId), fileName); + var blob = await _attachmentPreviewAppService.GetOrCreatePreviewPdfAsync(AttachmentType.APPLICANT, parsedApplicantId, fileName); if (blob?.Content == null) return NotFound(NotFoundFileMsg); return File(blob.Content, PdfContentType); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs index ca38ccddb..5dadb5c5f 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Assessments; using Unity.GrantManager.Attachments; using Unity.GrantManager.Controllers; using Unity.GrantManager.Intakes; @@ -30,7 +32,7 @@ public async Task UploadApplicationAttachments_InvalidInput_ReturnsBadRequest() var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -66,7 +68,7 @@ public async Task UploadApplicationAttachments_ExtensionNotOnOldDenylist_Returns var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -105,7 +107,7 @@ public async Task UploadApplicationAttachments_ContentTypeDoesNotMatchExtension_ var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -149,7 +151,7 @@ public async Task UploadApplicationAttachments_GenericOctetStreamContentType_Upl var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -193,7 +195,7 @@ public async Task UploadApplicationAttachments_ValidPdf_UploadsSuccessfully() var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -238,7 +240,7 @@ public async Task UploadApplicationAttachments_EmlFile_UploadsSuccessfully() var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -281,7 +283,7 @@ public async Task UploadApplicationAttachments_OutlookMsgFile_UploadsSuccessfull var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -322,7 +324,7 @@ public async Task UploadApplicationAttachments_OpenDocumentTextFile_UploadsSucce var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -362,7 +364,7 @@ public async Task UploadApplicationAttachments_MissingAllowedFileTypesConfig_Fal var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -410,7 +412,7 @@ public async Task UploadApplicationAttachments_MalformedAllowedFileTypesConfig_F var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -459,7 +461,7 @@ public async Task UploadApplicationAttachments_AllowedFileTypesConfigContainsNul var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -508,7 +510,7 @@ public async Task UploadApplicationAttachments_ConfigAddsExtensionOutsideDefault var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -544,7 +546,7 @@ public async Task UploadApplicationAttachments_OversizedFile_ReturnsBadRequest() var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var applicationId = Guid.NewGuid(); var userId = "testUserId"; var userName = "testUserName"; @@ -586,7 +588,7 @@ public async Task UploadEmailAttachments_ExceedsEmailPerFileMax_ReturnsBadReques var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var emailLogId = Guid.NewGuid(); // S3:EmailAttachmentMaxFileSize is 20 MB - stricter than the general S3:MaxFileSize @@ -637,7 +639,7 @@ public async Task UploadEmailAttachments_MalformedEmailMaxFileSizeConfig_StillEn var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var emailLogId = Guid.NewGuid(); // 22 MB - under the general 25 MB cap, but over the 20 MB default email per-file cap. @@ -686,7 +688,7 @@ public async Task UploadEmailAttachments_MalformedEmailTotalMaxFileSizeConfig_St var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var emailLogId = Guid.NewGuid(); // Each file is 15 MB - under the 20 MB per-file cap - but 30 MB combined exceeds the @@ -734,7 +736,7 @@ public async Task UploadEmailAttachments_ExceedsEmailTotalMax_ReturnsBadRequest( var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); var emailLogId = Guid.NewGuid(); // Each file is 15 MB - under both the general S3:MaxFileSize (25 MB) and the @@ -793,7 +795,7 @@ public async Task DownloadChefsAttachments_ReturnsChefsAttachmentFile() var currentTenant = Substitute.For(); var libreOfficeConversionService = Substitute.For(); var attachmentPreviewAppService = Substitute.For(); - var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), Substitute.For()); // Act Task download = attachmentController.DownloadChefsAttachment(formSubmissionId, chefsFileAttachmentId, fileName); @@ -804,5 +806,128 @@ public async Task DownloadChefsAttachments_ReturnsChefsAttachmentFile() Assert.Equal(fileName, downloadedFile.FileDownloadName); Assert.Equal(contentType,downloadedFile.ContentType); } + + // Security regression tests (CWE-639 / IDOR): the controller must confirm the id in the + // route belongs to a real, tenant-scoped entity before ever touching S3. FindAsync returning + // null stands in for both "no such id" and "id belongs to another tenant" - ABP's automatic + // IMultiTenant query filter makes those indistinguishable at the repository level, which is + // exactly the property this fix relies on. + + [Fact] + public async Task DownloadApplicantAttachment_ApplicantNotFoundOrWrongTenant_ReturnsNotFound() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var applicantRepository = Substitute.For(); + applicantRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Applicant?)null); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, applicantRepository, Substitute.For(), Substitute.For()); + + var fileName = "secret.pdf"; + fileAppService.GetBlobAsync(Arg.Any()) + .Returns(new BlobDto { Name = fileName, Content = [1, 2, 3], ContentType = "application/pdf" }); + + // Act + var result = await attachmentController.DownloadApplicantAttachment(Guid.NewGuid().ToString(), fileName); + + // Assert + Assert.IsType(result); + await fileAppService.DidNotReceive().GetBlobAsync(Arg.Any()); + } + + [Fact] + public async Task DownloadApplicationAttachment_ApplicationNotFoundOrWrongTenant_ReturnsNotFound() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var applicationRepository = Substitute.For(); + applicationRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Application?)null); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), applicationRepository, Substitute.For()); + + var fileName = "secret.pdf"; + fileAppService.GetBlobAsync(Arg.Any()) + .Returns(new BlobDto { Name = fileName, Content = [1, 2, 3], ContentType = "application/pdf" }); + + // Act + var result = await attachmentController.DownloadApplicationAttachment(Guid.NewGuid().ToString(), fileName); + + // Assert + Assert.IsType(result); + await fileAppService.DidNotReceive().GetBlobAsync(Arg.Any()); + } + + [Fact] + public async Task DownloadAssessmentAttachment_AssessmentNotFoundOrWrongTenant_ReturnsNotFound() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + var attachmentPreviewAppService = Substitute.For(); + var assessmentRepository = Substitute.For(); + assessmentRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Assessment?)null); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, Substitute.For(), Substitute.For(), assessmentRepository); + + var fileName = "secret.pdf"; + fileAppService.GetBlobAsync(Arg.Any()) + .Returns(new BlobDto { Name = fileName, Content = [1, 2, 3], ContentType = "application/pdf" }); + + // Act + var result = await attachmentController.DownloadAssessmentAttachment(Guid.NewGuid().ToString(), fileName); + + // Assert + Assert.IsType(result); + await fileAppService.DidNotReceive().GetBlobAsync(Arg.Any()); + } + + [Fact] + public async Task PreviewApplicantAttachment_ApplicantNotFoundOrWrongTenant_ReturnsNotFound() + { + // Arrange + var builder = new ConfigurationBuilder().AddJsonFile($"appsettings.json", optional: false); + var configuration = builder.Build(); + var fileAppService = Substitute.For(); + var submissionAppService = Substitute.For(); + var emailLogAttachmentUploadService = Substitute.For(); + var currentTenant = Substitute.For(); + var libreOfficeConversionService = Substitute.For(); + libreOfficeConversionService.IsInstalled().Returns(true); + var attachmentPreviewAppService = Substitute.For(); + var applicantRepository = Substitute.For(); + applicantRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Applicant?)null); + var attachmentController = new AttachmentController(fileAppService, configuration, submissionAppService, emailLogAttachmentUploadService, currentTenant, libreOfficeConversionService, attachmentPreviewAppService, applicantRepository, Substitute.For(), Substitute.For()); + + var fileName = "secret.pdf"; + attachmentPreviewAppService.GetOrCreatePreviewPdfAsync(AttachmentType.APPLICANT, Arg.Any(), fileName) + .Returns(new BlobDto { Name = fileName, Content = [1, 2, 3], ContentType = "application/pdf" }); + + // Act + var result = await attachmentController.PreviewApplicantAttachment(Guid.NewGuid().ToString(), fileName); + + // Assert + Assert.IsType(result); + await attachmentPreviewAppService.DidNotReceive().GetOrCreatePreviewPdfAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } } } From 4970c4d779624b7cfe78a1a53ae291b1cd8ab53a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 29 Jul 2026 14:14:11 -0700 Subject: [PATCH 9/9] AB#33922 Number. prefers --- .../Unity.AutoUI/cypress/e2e/lists.cy.ts | 2 +- .../pages/ApplicationDetailsRightTabPage.ts | 8 ++++---- .../Unity.AutoUI/cypress/pages/DashboardPage.ts | 12 ++++++------ .../Unity.AutoUI/cypress/pages/ListPages.ts | 2 +- .../cypress/utilities/TestDataHelper.ts | 2 +- .../UpsertSectionModal.cshtml | 10 +++++----- .../UpdatePaymentRequestStatusModal.js | 4 ++-- .../CreatePaymentRequestsModal.js | 14 +++++++------- .../BulkApprovals/ApproveApplicationsModal.js | 4 ++-- .../Components/AssessmentScoresWidget/Default.js | 16 ++++++++-------- .../Components/PaymentConfiguration/Default.js | 2 +- .../Shared/Components/ProjectInfo/Default.js | 8 ++++---- 12 files changed, 42 insertions(+), 42 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts index 4ef0eda37..3a73fef29 100644 --- a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts @@ -92,7 +92,7 @@ describe('Grant Manager Login and List Navigation', () => { cy.get('#applicationStatusChart text', { timeout: 30000 }) .first() .should(($el) => { - expect(parseInt($el.text(), 10)).to.be.gt(0) + expect(Number.parseInt($el.text(), 10)).to.be.gt(0) }) cy.visit(Cypress.env('webapp.url')) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts index 1115fb496..4060f51d9 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts @@ -193,7 +193,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.emails, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } /** @@ -203,7 +203,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.comments, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } /** @@ -213,7 +213,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.attachments, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } /** @@ -223,7 +223,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.links, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } // ============ Details Tab Methods ============ diff --git a/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts b/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts index 62d7106df..04d39e1b3 100644 --- a/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts @@ -114,7 +114,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.applicationStatusChart) .invoke("text") .then((text) => { - const number = parseInt(text, 10); + const number = Number.parseInt(text, 10); expect(number).to.be.gt(0); }); } @@ -126,7 +126,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.economicRegionChart) .invoke("text") .then((text) => { - const number = parseInt(text, 10); + const number = Number.parseInt(text, 10); expect(number).to.be.gt(0); }); } @@ -138,7 +138,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.applicationAssigneeChart) .invoke("text") .then((text) => { - const number = parseInt(text, 10); + const number = Number.parseInt(text, 10); expect(number).to.be.gt(0); }); } @@ -150,7 +150,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.subsectorRequestedAmountChart) .invoke("text") .then((text) => { - const amount = parseFloat(text.replace("$", "")); + const amount = Number.parseFloat(text.replace("$", "")); expect(amount).to.be.gt(0); }); } @@ -171,7 +171,7 @@ export class DashboardPage extends BasePage { getChartValue(chartSelector: string): Cypress.Chainable { return this.getElement(chartSelector) .invoke("text") - .then((text) => parseInt(text, 10)); + .then((text) => Number.parseInt(text, 10)); } /** @@ -180,6 +180,6 @@ export class DashboardPage extends BasePage { getChartCurrencyValue(chartSelector: string): Cypress.Chainable { return this.getElement(chartSelector) .invoke("text") - .then((text) => parseFloat(text.replace("$", ""))); + .then((text) => Number.parseFloat(text.replace("$", ""))); } } diff --git a/applications/Unity.AutoUI/cypress/pages/ListPages.ts b/applications/Unity.AutoUI/cypress/pages/ListPages.ts index d58131d1d..5cdf7cdb2 100644 --- a/applications/Unity.AutoUI/cypress/pages/ListPages.ts +++ b/applications/Unity.AutoUI/cypress/pages/ListPages.ts @@ -543,7 +543,7 @@ export class ApplicationsPage extends ListPage { .find(`td:nth-child(${this.columns.requestedAmount + 1})`) .text() .trim(); - const amount = parseFloat(amountText.replace(/[$,]/g, "")); + const amount = Number.parseFloat(amountText.replace(/[$,]/g, "")); if (!isNaN(amount)) { total += amount; } diff --git a/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts b/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts index f725161b4..25b02c39e 100644 --- a/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts +++ b/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts @@ -81,7 +81,7 @@ export class TestDataHelper { * Parse currency string to number */ static parseCurrency(currencyString: string): number { - return parseFloat(currencyString.replace(/[$,]/g, "")); + return Number.parseFloat(currencyString.replace(/[$,]/g, "")); } /** diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml index b46393d92..d03969aa7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/WorksheetConfiguration/UpsertSectionModal.cshtml @@ -85,19 +85,19 @@ // Returns width percentage for a given column count (1–10) function columnsToWidth(cols) { - const c = Math.min(10, Math.max(1, parseInt(cols, 10) || 1)); + const c = Math.min(10, Math.max(1, Number.parseInt(cols, 10) || 1)); return Math.round(100 / c); } function onSliderInput(value) { - const clamped = Math.min(100, Math.max(0, parseInt(value, 10) || 0)); + const clamped = Math.min(100, Math.max(0, Number.parseInt(value, 10) || 0)); document.getElementById('FieldWidthNumber').value = clamped; document.getElementById('FieldColumns').value = widthToColumns(clamped); updateFieldWidthDisplay(clamped); } function onNumberInput(value) { - let clamped = parseInt(value, 10); + let clamped = Number.parseInt(value, 10); if (isNaN(clamped)) clamped = 0; clamped = Math.min(100, Math.max(0, clamped)); document.getElementById('FieldWidth').value = clamped; @@ -106,7 +106,7 @@ } function onColumnsInput(value) { - const cols = Math.min(10, Math.max(1, parseInt(value, 10) || 1)); + const cols = Math.min(10, Math.max(1, Number.parseInt(value, 10) || 1)); const width = columnsToWidth(cols); document.getElementById('FieldWidth').value = width; document.getElementById('FieldWidthNumber').value = width; @@ -115,7 +115,7 @@ // Initialise the columns input from the current FieldWidth on page load (function () { - const initialWidth = parseInt(document.getElementById('FieldWidth').value, 10) || 0; + const initialWidth = Number.parseInt(document.getElementById('FieldWidth').value, 10) || 0; document.getElementById('FieldColumns').value = widthToColumns(initialWidth); })(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatusModal.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatusModal.js index f1fe8946b..1c54e8d99 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatusModal.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatusModal.js @@ -48,7 +48,7 @@ function closePaymentModal() { } function checkMaxValue(applicationId, input, amountRemaining) { - let enteredValue = parseFloat(input.value.replace(/,/g, "")); + let enteredValue = Number.parseFloat(input.value.replace(/,/g, "")); let remainingErrorId = "#column_" + applicationId + "_remaining_error"; if (amountRemaining < enteredValue) { $(remainingErrorId).css("display", "block"); @@ -114,7 +114,7 @@ function calculateUpdateTotalAmount() { $('.amount').each(function () { // Remove commas and $ symbols before parsing let rawValue = $(this).val().replace(/[$,]/g, ''); - let value = parseFloat(rawValue) || 0; + let value = Number.parseFloat(rawValue) || 0; total += value; this.value = upatePaymentNumberFormatter.format(value); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js index 5d19c2279..e3a185a23 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js @@ -88,13 +88,13 @@ function validateAllPaymentAmounts() { let amountInput = $( `input[name="ApplicationPaymentRequestForm[${index}].Amount"]` ); - let remainingAmount = parseFloat( + let remainingAmount = Number.parseFloat( $( `input[name="ApplicationPaymentRequestForm[${index}].RemainingAmount"]` ).val() ); let enteredValue = - parseFloat(amountInput.val().replace(/,/g, '')) || 0; + Number.parseFloat(amountInput.val().replace(/,/g, '')) || 0; let remainingErrorId = `#error_column_${correlationId}`; if (enteredValue > remainingAmount) { @@ -127,7 +127,7 @@ function submitPayments() { function calculateTotalAmount() { let total = 0; $('.amount').each(function () { - let value = parseFloat($(this).val().replace(/,/g, '')) || 0; + let value = Number.parseFloat($(this).val().replace(/,/g, '')) || 0; total += value; }); @@ -146,7 +146,7 @@ function getIndexByCorrelationId(correlationId) { .attr('name') .match(/\[(\d+)\]/); if (match) { - index = parseInt(match[1], 10); + index = Number.parseInt(match[1], 10); } return false; // break } @@ -170,7 +170,7 @@ function formatCurrency(value) { const numericValue = typeof value === 'number' ? value - : parseFloat(String(value ?? '').replace(/,/g, '')); + : Number.parseFloat(String(value ?? '').replace(/,/g, '')); return cadFormatter.format( Number.isFinite(numericValue) ? numericValue : 0 ); @@ -193,10 +193,10 @@ function validateParentChildAmounts(correlationId) { `input[name="ApplicationPaymentRequestForm[${index}].ParentApprovedAmount"]` ).val(); let maximumAllowed = maximumAllowedInput - ? parseFloat(maximumAllowedInput) + ? Number.parseFloat(maximumAllowedInput) : 0; let approvedAmount = parentApprovedAmount - ? parseFloat(parentApprovedAmount) + ? Number.parseFloat(parentApprovedAmount) : 0; // Determine if this is a parent or child diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkApprovals/ApproveApplicationsModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkApprovals/ApproveApplicationsModal.js index 5c59611d1..472427545 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkApprovals/ApproveApplicationsModal.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkApprovals/ApproveApplicationsModal.js @@ -7,7 +7,7 @@ function approvedAmountUpdated(event) { const input = event.target; - const value = parseFloat(input.value.replace(/,/g, '')); + const value = Number.parseFloat(input.value.replace(/,/g, '')); setNote(event.target, '_APPROVED_AMOUNT_DEFAULTED', false); @@ -45,7 +45,7 @@ function runValidations() { $('#bulkApprovalForm input[name="BulkApplicationApprovals.Index"]').each(function () { itemCount++; let index = $(this).val(); - let approvedAmount = parseFloat($('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].ApprovedAmount"]').val().replace(/,/g, '')); + let approvedAmount = Number.parseFloat($('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].ApprovedAmount"]').val().replace(/,/g, '')); let decisionDate = new Date($('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].DecisionDate"]').val()); let isValidField = $('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].IsValid"]').val(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 4e50303cb..6b030038f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -413,10 +413,10 @@ function updateSum() { let cleanGrowth = $('#cleanGrowth').val() || 0; let economicImpact = $('#economicImpact').val() || 0; let sum = - parseInt(financialAnalysis) + - parseInt(inclusiveGrowth) + - parseInt(cleanGrowth) + - parseInt(economicImpact); + Number.parseInt(financialAnalysis) + + Number.parseInt(inclusiveGrowth) + + Number.parseInt(cleanGrowth) + + Number.parseInt(economicImpact); $('#subTotal').val(sum); } @@ -575,7 +575,7 @@ function updateSubtotal() { // Handle number inputs const numberInputs = document.querySelectorAll('.answer-number-input'); numberInputs.forEach((input) => { - subtotal += parseFloat(input.value) || 0; + subtotal += Number.parseFloat(input.value) || 0; }); // Handle Yes/No inputs @@ -584,11 +584,11 @@ function updateSubtotal() { let value = 0; if (input.value === 'Yes') { value = - parseFloat(input.dataset.yesNumericValue) || + Number.parseFloat(input.dataset.yesNumericValue) || 0; } else if (input.value === 'No') { value = - parseFloat(input.dataset.noNumericValue) || + Number.parseFloat(input.dataset.noNumericValue) || 0; } subtotal += value; @@ -601,7 +601,7 @@ function updateSubtotal() { selectListInputs.forEach((select) => { const selectedOption = select.options[select.selectedIndex]; const numericValue = - parseFloat(selectedOption.dataset.numericValue) || + Number.parseFloat(selectedOption.dataset.numericValue) || 0; subtotal += numericValue; }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js index 569cc89c9..bc5d59041 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js @@ -200,7 +200,7 @@ UIElements.btnSave.prop('disabled', true); const hierarchyValue = UIElements.formHierarchy.val(); - const formHierarchy = hierarchyValue ? parseInt(hierarchyValue, 10) : null; + const formHierarchy = hierarchyValue ? Number.parseInt(hierarchyValue, 10) : null; const parentFormId = UIElements.parentFormSelect.val(); const defaultPaymentGroupValue = UIElements.payable.is(':checked') ? (UIElements.defaultPaymentGroup.val() || '1') diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ProjectInfo/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ProjectInfo/Default.js index f1865b24c..8915d9559 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ProjectInfo/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ProjectInfo/Default.js @@ -226,7 +226,7 @@ abp.widgets.ProjectInfo = function ($wrapper) { } if (this.isNumberField(input)) { - fieldValue = fieldValue === '' ? 0 : Math.min(parseFloat(fieldValue), this.getMaxNumberField(input)); + fieldValue = fieldValue === '' ? 0 : Math.min(Number.parseFloat(fieldValue), this.getMaxNumberField(input)); } else if (fieldValue === '') { fieldValue = null; } @@ -298,12 +298,12 @@ $(function () { }); function calculatePercentage() { - const requestedAmount = parseFloat(document.getElementById("RequestedAmountInputPI")?.value.replace(/,/g, '')); - const totalProjectBudget = parseFloat(document.getElementById("TotalBudgetInputPI")?.value.replace(/,/g, '')); + const requestedAmount = Number.parseFloat(document.getElementById("RequestedAmountInputPI")?.value.replace(/,/g, '')); + const totalProjectBudget = Number.parseFloat(document.getElementById("TotalBudgetInputPI")?.value.replace(/,/g, '')); if (isNaN(requestedAmount) || isNaN(totalProjectBudget) || totalProjectBudget == 0) { document.getElementById("ProjectInfo_PercentageTotalProjectBudget").value = 0; return; } const percentage = ((requestedAmount / totalProjectBudget) * 100.00).toFixed(2); - $("#ProjectInfo_PercentageTotalProjectBudget").maskMoney('mask', parseFloat(percentage)); + $("#ProjectInfo_PercentageTotalProjectBudget").maskMoney('mask', Number.parseFloat(percentage)); }