From 446ffd22621d8106a36c5152fad212cf0385e278 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 30 Jul 2026 11:41:03 -0700 Subject: [PATCH 01/18] feature/AB#33848-AddTabPermisionToRole --- .../Permissions/PermissionGrantsDataSeeder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index bacbdb780..8a83f3358 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -74,6 +74,7 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) ]; public readonly List NotificationsScheduling_CommonPermissions = [ + NotificationsPermissions.Email.NotificationsTab, NotificationsPermissions.Email.CancelScheduled, NotificationsPermissions.Email.ScheduleCreate, NotificationsPermissions.Email.ScheduleCancel, From 1fb6da4f6f6603d3ce2b447a6c11493157e2422d Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Thu, 30 Jul 2026 15:25:43 -0700 Subject: [PATCH 02/18] AB#33409: Fix SQL Injection on Reporting Configuration --- .../ReportColumnsMapRepository.cs | 211 +++++++++++++++++- ...eportColumnsMapRepositorySqlSafetyTests.cs | 147 ++++++++++++ 2 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs index 6d3cb324d..57e9c0ac5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Dynamic; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Unity.Reporting.Configuration; @@ -108,6 +109,15 @@ public async Task GetViewPreviewDataAsync(string viewName, ViewD // Normalize view name to lowercase for consistency var normalizedViewName = viewName.Trim().ToLowerInvariant(); + // SECURITY: Validate the identifier before it is interpolated into SQL below. + // ViewExistsAsync alone is not sufficient - it only proves a matching row exists in + // pg_views, not that the name is free of characters that would break out of the + // quoted identifier it gets embedded in. + if (!IsValidPostgreSqlIdentifier(normalizedViewName)) + { + throw new ArgumentException($"Invalid view name format: {viewName}", nameof(viewName)); + } + var dbContext = await GetDbContextAsync(); var connection = dbContext.Database.GetDbConnection(); await dbContext.Database.OpenConnectionAsync(); @@ -133,15 +143,17 @@ LIMIT 1 )"; // Add filtering if provided - if (!string.IsNullOrWhiteSpace(request.Filter)) + var previewFilterExpression = ValidateFilterExpression(request.Filter, result.ColumnNames); + if (!string.IsNullOrEmpty(previewFilterExpression)) { - previewQuery += $" AND ({request.Filter})"; + previewQuery += $" AND ({previewFilterExpression})"; } // Add ordering if provided - if (!string.IsNullOrWhiteSpace(request.OrderBy)) + var previewOrderByExpression = ValidateOrderByExpression(request.OrderBy, result.ColumnNames); + if (!string.IsNullOrEmpty(previewOrderByExpression)) { - previewQuery += $" ORDER BY {request.OrderBy}"; + previewQuery += $" ORDER BY {previewOrderByExpression}"; } // Execute the preview query @@ -180,6 +192,15 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ // Normalize view name to lowercase for consistency var normalizedViewName = viewName.Trim().ToLowerInvariant(); + // SECURITY: Validate the identifier before it is interpolated into SQL below. + // ViewExistsAsync alone is not sufficient - it only proves a matching row exists in + // pg_views, not that the name is free of characters that would break out of the + // quoted identifier it gets embedded in. + if (!IsValidPostgreSqlIdentifier(normalizedViewName)) + { + throw new ArgumentException($"Invalid view name format: {viewName}", nameof(viewName)); + } + var dbContext = await GetDbContextAsync(); var connection = dbContext.Database.GetDbConnection(); await dbContext.Database.OpenConnectionAsync(); @@ -197,9 +218,10 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ var countQuery = $@"SELECT COUNT(*) FROM ""Reporting"".""{normalizedViewName}"""; // Add filtering if provided - if (!string.IsNullOrWhiteSpace(request.Filter)) + var filterExpression = ValidateFilterExpression(request.Filter, result.ColumnNames); + if (!string.IsNullOrEmpty(filterExpression)) { - var whereClause = $" WHERE {request.Filter}"; + var whereClause = $" WHERE {filterExpression}"; baseQuery += whereClause; countQuery += whereClause; } @@ -213,9 +235,10 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ } // Add ordering if provided - if (!string.IsNullOrWhiteSpace(request.OrderBy)) + var orderByExpression = ValidateOrderByExpression(request.OrderBy, result.ColumnNames); + if (!string.IsNullOrEmpty(orderByExpression)) { - baseQuery += $" ORDER BY {request.OrderBy}"; + baseQuery += $" ORDER BY {orderByExpression}"; } // Add pagination @@ -399,6 +422,14 @@ FROM pg_views // Grant SELECT permission on each view to the role foreach (var viewName in viewNames) { + // SECURITY: Validate each identifier read back from pg_views before it is + // interpolated into SQL - quoted PostgreSQL identifiers can contain characters + // (embedded quotes, semicolons) that would otherwise break out of the quotes below. + if (!IsValidPostgreSqlIdentifier(viewName)) + { + throw new ArgumentException($"Invalid view name format: {viewName}", nameof(viewName)); + } + var sql = $"GRANT SELECT ON \"Reporting\".\"{viewName}\" TO \"{role}\""; await dbContext.Database.ExecuteSqlRawAsync(sql); } @@ -546,12 +577,174 @@ FROM information_schema.views } } + // Tokens allowed inside a Filter expression: whitespace, string/number literals, + // quoted or bare identifiers, comparison operators, and parentheses/commas. + private static readonly Regex FilterTokenRegex = new( + @"\G(\s+|'(?:[^']|'')*'|\d+(?:\.\d+)?|""[a-zA-Z_][a-zA-Z0-9_]*""|[a-zA-Z_][a-zA-Z0-9_]*|<>|!=|<=|>=|=|<|>|[(),])", + RegexOptions.Compiled); + + private static readonly HashSet AllowedFilterKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "AND", "OR", "NOT", "IS", "NULL", "TRUE", "FALSE", "LIKE", "ILIKE", "IN", "BETWEEN" + }; + + // Tokens allowed inside an OrderBy expression: whitespace, quoted or bare identifiers, and commas. + private static readonly Regex OrderByTokenRegex = new( + @"\G(\s+|""[a-zA-Z_][a-zA-Z0-9_]*""|[a-zA-Z_][a-zA-Z0-9_]*|,)", + RegexOptions.Compiled); + + private static readonly HashSet AllowedOrderByKeywords = new(StringComparer.OrdinalIgnoreCase) { "ASC", "DESC" }; + + /// + /// Validates a caller-supplied SQL filter (WHERE) expression against an allow-list of + /// real view column names. Throws if the expression is + /// not safe to concatenate into a SQL statement. + /// + /// The raw filter expression, without the "WHERE" keyword. + /// The set of real column names for the target view. + /// The validated filter expression, or an empty string if none was provided. + internal static string ValidateFilterExpression(string? filter, IReadOnlyCollection validColumns) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return string.Empty; + } + + var trimmed = filter.Trim(); + var pos = 0; + + // Tracks whether the most recent non-whitespace token was a column reference, so we + // can reject "column(" - a bare column name immediately followed by an open paren is + // function-call syntax in SQL, not a valid column reference, regardless of whether the + // column happens to share a name with a dangerous PostgreSQL function (pg_sleep, etc). + var previousTokenWasColumn = false; + + while (pos < trimmed.Length) + { + var match = FilterTokenRegex.Match(trimmed, pos); + if (!match.Success || match.Index != pos || match.Length == 0) + { + throw new ArgumentException($"Filter expression contains an unsupported character at position {pos}.", nameof(filter)); + } + + var token = match.Value; + + if (char.IsWhiteSpace(token[0])) + { + pos += match.Length; + continue; + } + + if (token == "(") + { + if (previousTokenWasColumn) + { + throw new ArgumentException("Filter expression does not permit function calls.", nameof(filter)); + } + } + else if (token[0] == '"') + { + var identifier = token[1..^1]; + if (!validColumns.Contains(identifier, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Filter expression references an unknown column '{identifier}'.", nameof(filter)); + } + } + else if (char.IsLetter(token[0]) || token[0] == '_') + { + if (!AllowedFilterKeywords.Contains(token) && !validColumns.Contains(token, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Filter expression references an unknown column or keyword '{token}'.", nameof(filter)); + } + } + + previousTokenWasColumn = token[0] == '"' || ((char.IsLetter(token[0]) || token[0] == '_') && !AllowedFilterKeywords.Contains(token)); + + pos += match.Length; + } + + return trimmed; + } + + /// + /// Validates a caller-supplied SQL ORDER BY expression against an allow-list of real + /// view column names. Throws if the expression is not + /// safe to concatenate into a SQL statement. + /// + /// The raw order-by expression, without the "ORDER BY" keywords. + /// The set of real column names for the target view. + /// The validated order-by expression, or an empty string if none was provided. + internal static string ValidateOrderByExpression(string? orderBy, IReadOnlyCollection validColumns) + { + if (string.IsNullOrWhiteSpace(orderBy)) + { + return string.Empty; + } + + var trimmed = orderBy.Trim(); + var pos = 0; + var expectColumn = true; + + while (pos < trimmed.Length) + { + var match = OrderByTokenRegex.Match(trimmed, pos); + if (!match.Success || match.Index != pos || match.Length == 0) + { + throw new ArgumentException($"Order-by expression contains an unsupported character at position {pos}.", nameof(orderBy)); + } + + var token = match.Value; + + if (!char.IsWhiteSpace(token[0])) + { + if (token == ",") + { + if (expectColumn) + { + throw new ArgumentException("Order-by expression is missing a column name.", nameof(orderBy)); + } + expectColumn = true; + } + else if (token[0] == '"') + { + var identifier = token[1..^1]; + if (!validColumns.Contains(identifier, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Order-by expression references an unknown column '{identifier}'.", nameof(orderBy)); + } + expectColumn = false; + } + else if (!expectColumn && AllowedOrderByKeywords.Contains(token)) + { + // ASC/DESC following a column - no state change needed. + } + else if (validColumns.Contains(token, StringComparer.OrdinalIgnoreCase)) + { + expectColumn = false; + } + else + { + throw new ArgumentException($"Order-by expression references an unknown column '{token}'.", nameof(orderBy)); + } + } + + pos += match.Length; + } + + if (expectColumn) + { + throw new ArgumentException("Order-by expression is missing a column name.", nameof(orderBy)); + } + + return trimmed; + } + /// /// Validates that a string is a valid PostgreSQL identifier to prevent SQL injection /// /// The identifier to validate /// True if the identifier is valid, false otherwise - private static bool IsValidPostgreSqlIdentifier(string identifier) + internal static bool IsValidPostgreSqlIdentifier(string identifier) { if (string.IsNullOrWhiteSpace(identifier)) return false; diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs new file mode 100644 index 000000000..dceeb895f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs @@ -0,0 +1,147 @@ +using System; +using Shouldly; +using Unity.Reporting.EntityFrameworkCore.Repositories; +using Xunit; + +namespace Unity.Reporting.Application.Tests.EntityFrameworkCore.Repositories +{ + /// + /// Regression tests for AB#33409 - SQL Injection via the ViewDataRequest Filter/OrderBy + /// parameters accepted by ReportColumnsMapRepository.GetViewDataAsync/GetViewPreviewDataAsync. + /// + public class ReportColumnsMapRepositorySqlSafetyTests + { + private static readonly string[] ValidColumns = ["id", "status", "amount", "created_date", "applicant_name"]; + + [Fact] + public void ValidateFilterExpression_Should_Reject_Stacked_Statement_Injection() + { + const string maliciousFilter = "1=1; DROP TABLE \"Applications\";--"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Union_Based_Injection() + { + const string maliciousFilter = "id = 1 UNION SELECT rolpassword, 1, 1 FROM pg_authid --"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Function_Call_Injection() + { + const string maliciousFilter = "id = 1 OR pg_sleep(10) IS NOT NULL"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Column_Not_In_Allow_List() + { + const string filter = "secret_column = 'x'"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Function_Call_Via_Column_Named_Like_Dangerous_Function() + { + // A column can legitimately be named "pg_sleep" (SanitizeColumnName has no + // function-name blocklist), so the allow-list check alone isn't enough - the + // validator must also reject "identifier(" as function-call syntax. + string[] columnsIncludingDangerousName = ["id", "status", "pg_sleep"]; + const string maliciousFilter = "pg_sleep(10) IS NOT NULL"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, columnsIncludingDangerousName)); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_In_Clause_Without_Treating_It_As_A_Function_Call() + { + const string filter = "status IN ('Active', 'Pending')"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Legitimate_Filter() + { + const string filter = "status = 'Active' AND amount > 1000"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Return_Empty_For_Blank_Input() + { + ReportColumnsMapRepository.ValidateFilterExpression(null, ValidColumns).ShouldBe(string.Empty); + ReportColumnsMapRepository.ValidateFilterExpression(" ", ValidColumns).ShouldBe(string.Empty); + } + + [Fact] + public void ValidateOrderByExpression_Should_Reject_Stacked_Statement_Injection() + { + const string maliciousOrderBy = "id; DROP TABLE \"Applications\";--"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateOrderByExpression(maliciousOrderBy, ValidColumns)); + } + + [Fact] + public void ValidateOrderByExpression_Should_Reject_Column_Not_In_Allow_List() + { + const string orderBy = "secret_column DESC"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateOrderByExpression(orderBy, ValidColumns)); + } + + [Fact] + public void ValidateOrderByExpression_Should_Accept_Legitimate_OrderBy() + { + const string orderBy = "\"created_date\" DESC, id ASC"; + + var result = ReportColumnsMapRepository.ValidateOrderByExpression(orderBy, ValidColumns); + + result.ShouldBe(orderBy); + } + + [Fact] + public void ValidateOrderByExpression_Should_Return_Empty_For_Blank_Input() + { + ReportColumnsMapRepository.ValidateOrderByExpression(null, ValidColumns).ShouldBe(string.Empty); + ReportColumnsMapRepository.ValidateOrderByExpression(" ", ValidColumns).ShouldBe(string.Empty); + } + + [Theory] + [InlineData("my_view")] + [InlineData("_leading_underscore")] + [InlineData("view123")] + public void IsValidPostgreSqlIdentifier_Should_Accept_Well_Formed_Identifiers(string identifier) + { + ReportColumnsMapRepository.IsValidPostgreSqlIdentifier(identifier).ShouldBeTrue(); + } + + [Theory] + [InlineData("weird\"\"view")] + [InlineData("view; DROP TABLE \"Applications\";--")] + [InlineData("view name with spaces")] + [InlineData("123_starts_with_digit")] + [InlineData("")] + public void IsValidPostgreSqlIdentifier_Should_Reject_Malformed_Or_Malicious_Identifiers(string identifier) + { + ReportColumnsMapRepository.IsValidPostgreSqlIdentifier(identifier).ShouldBeFalse(); + } + } +} From 666bc51aec2f7537ec44d7b8161cd8504859dd9d Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Thu, 30 Jul 2026 15:28:41 -0700 Subject: [PATCH 03/18] AB#33409: More Unit Tests For SQL Injection on Reporting --- ...eportColumnsMapRepositorySqlSafetyTests.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs index dceeb895f..81ee9f5cd 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/EntityFrameworkCore/Repositories/ReportColumnsMapRepositorySqlSafetyTests.cs @@ -62,6 +62,78 @@ public void ValidateFilterExpression_Should_Reject_Function_Call_Via_Column_Name ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, columnsIncludingDangerousName)); } + [Fact] + public void ValidateFilterExpression_Should_Reject_Block_Comment_Injection() + { + const string maliciousFilter = "status = 'Active' /* comment */ OR 1=1"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Trailing_Comment_Injection() + { + const string maliciousFilter = "status = 'Active' --comment"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Unterminated_String_Literal() + { + const string maliciousFilter = "applicant_name = 'unterminated"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Backslash_Quote_Breakout_Attempt() + { + // Postgres defaults to standard_conforming_strings=on, so a backslash does not escape + // the following quote. If our tokenizer treated it as an escape (MySQL-style), the + // string literal would swallow the rest of the payload instead of ending at the first + // unescaped quote, and "OR 1=1--" would slip through unnoticed. + const string maliciousFilter = "applicant_name = 'test\\' OR 1=1--'"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Function_Call_With_Whitespace_Before_Paren() + { + // A space between the identifier and "(" is a classic bypass for naive "identifier(" + // checks - our tokenizer must not reset the "previous token was a column" state on + // whitespace. + string[] columnsIncludingDangerousName = ["id", "status", "pg_sleep"]; + const string maliciousFilter = "pg_sleep (10) IS NOT NULL"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, columnsIncludingDangerousName)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Function_Call_Via_Quoted_Identifier() + { + string[] columnsIncludingDangerousName = ["id", "status", "pg_sleep"]; + const string maliciousFilter = "\"pg_sleep\"(10) IS NOT NULL"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, columnsIncludingDangerousName)); + } + + [Fact] + public void ValidateFilterExpression_Should_Reject_Union_Injection_Regardless_Of_Keyword_Casing() + { + const string maliciousFilter = "id = 1 uNioN Select 1"; + + Should.Throw(() => + ReportColumnsMapRepository.ValidateFilterExpression(maliciousFilter, ValidColumns)); + } + [Fact] public void ValidateFilterExpression_Should_Accept_In_Clause_Without_Treating_It_As_A_Function_Call() { @@ -72,6 +144,16 @@ public void ValidateFilterExpression_Should_Accept_In_Clause_Without_Treating_It result.ShouldBe(filter); } + [Fact] + public void ValidateFilterExpression_Should_Accept_In_Clause_With_Numbers_And_No_Spacing() + { + const string filter = "id IN (1,2,3)"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + [Fact] public void ValidateFilterExpression_Should_Accept_Legitimate_Filter() { @@ -82,6 +164,98 @@ public void ValidateFilterExpression_Should_Accept_Legitimate_Filter() result.ShouldBe(filter); } + [Fact] + public void ValidateFilterExpression_Should_Accept_String_Value_That_Contains_Comment_Like_Substring() + { + // The value itself is attacker-adjacent-looking text, but it is safely contained inside + // a quoted string literal, so it must be treated as ordinary data, not SQL syntax. + const string filter = "status = 'Active; not-a-comment --'"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Escaped_Quote_In_String_Literal() + { + const string filter = "applicant_name = 'O''Brien'"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Grouped_Expression_With_Nested_Parens() + { + const string filter = "(status = 'Active' OR status = 'Pending') AND amount > 100"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Between_Operator() + { + const string filter = "amount BETWEEN 100 AND 1000"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Is_Not_Null() + { + const string filter = "created_date IS NOT NULL"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Like_Operator() + { + const string filter = "applicant_name LIKE 'Smith%'"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Lowercase_Keywords() + { + const string filter = "status = 'Active' and amount > 100"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Not_With_Grouped_Expression() + { + const string filter = "NOT (status = 'Active')"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + + [Fact] + public void ValidateFilterExpression_Should_Accept_Decimal_Number_Literal() + { + const string filter = "amount >= 100.50"; + + var result = ReportColumnsMapRepository.ValidateFilterExpression(filter, ValidColumns); + + result.ShouldBe(filter); + } + [Fact] public void ValidateFilterExpression_Should_Return_Empty_For_Blank_Input() { From 285c83f45fee54797104c2a65084566e008d821a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 31 Jul 2026 08:56:44 -0700 Subject: [PATCH 04/18] AB#33922 aria labels for merge applicant --- .../Components/ApplicantInfo/Default.cshtml | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index 1be46ee77..46b745379 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -429,13 +429,13 @@ @L["ApplicantInfoView:ApplicantInfo.UnityApplicant"].Value @@ -444,13 +444,13 @@ @L["ApplicantInfoView:ApplicantInfo.ApplicantName"].Value @@ -459,13 +459,13 @@ @L["ApplicantInfoView:ApplicantInfo.OrgName"].Value @@ -474,13 +474,13 @@ @L["ApplicantInfoView:ApplicantInfo.OrgNumber"].Value @@ -489,13 +489,13 @@ @L["ApplicantInfoView:ApplicantInfo.NonRegOrgName"].Value @@ -504,13 +504,13 @@ @L["ApplicantInfoView:ApplicantInfo.OrganizationType"].Value @@ -519,13 +519,13 @@ @L["ApplicantInfoView:ApplicantInfo.ApproxNumberOfEmployees"].Value @@ -534,13 +534,13 @@ @L["ApplicantInfoView:ApplicantInfo.OrgBookStatus"].Value @@ -549,13 +549,13 @@ @L["ApplicantInfoView:ApplicantInfo.IndigenousOrgInd"].Value @@ -564,13 +564,13 @@ @L["ApplicantInfoView:ApplicantInfo.Sector"].Value @@ -579,13 +579,13 @@ @L["ApplicantInfoView:ApplicantInfo.SubSector"].Value @@ -594,13 +594,13 @@ @L["ApplicantInfoView:ApplicantInfo.SectorSubSectorIndustryDesc"].Value @@ -609,13 +609,13 @@ @L["ApplicantInfoView:ApplicantInfo.FiscalDay"].Value @@ -624,13 +624,13 @@ @L["ApplicantInfoView:ApplicantInfo.FiscalMonth"].Value From 0e752589b1c8642c0321c02554d62882d1a76df8 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 31 Jul 2026 09:12:12 -0700 Subject: [PATCH 05/18] AB#33922 remaining missing aria-labels --- .../Pages/Components/DataGrid/EditDataRowModal.cshtml | 2 +- .../Shared/Components/RadioDefinitionWidget/Default.cshtml | 4 ++-- .../Unity.Payments.Web/Pages/AccountCoding/CreateModal.cshtml | 2 +- .../Unity.Payments.Web/Pages/AccountCoding/UpdateModal.cshtml | 2 +- .../Pages/PaymentConfigurations/Index.cshtml | 4 ++-- .../Views/Shared/Components/PaymentActionBar/Default.cshtml | 2 +- .../Pages/ReportingAdmin/Configuration/Index.cshtml | 3 ++- .../src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.cshtml | 2 +- .../Views/Shared/Components/ApplicantPayments/Default.cshtml | 1 + .../Shared/Components/ApplicantSubmissions/Default.cshtml | 1 + .../Shared/Components/ApplicantsActionBar/Default.cshtml | 2 +- .../Shared/Components/ApplicationAttachments/Default.cshtml | 2 +- .../Components/ApplicationFormConfigWidget/Default.cshtml | 3 ++- 13 files changed, 17 insertions(+), 13 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml index cec22d74f..47e466667 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml @@ -48,7 +48,7 @@ @if (!isEditable) { - + } else if (fieldType == CustomFieldType.Checkbox) { diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml index 18c5eed1d..26ec44041 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/RadioDefinitionWidget/Default.cshtml @@ -11,7 +11,7 @@ foreach (var option in Model.Options) {
- +
@@ -56,7 +56,7 @@ newOption.className = "option-container"; newOption.id = optionId; newOption.innerHTML = ` - + `; document.getElementById("radioOptions").appendChild(newOption); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/CreateModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/CreateModal.cshtml index fcda3d04e..2aa6bff48 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/CreateModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/CreateModal.cshtml @@ -14,7 +14,7 @@ Account Coding - + diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/UpdateModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/UpdateModal.cshtml index 900ec8ec4..5c42170c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/UpdateModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/AccountCoding/UpdateModal.cshtml @@ -15,7 +15,7 @@ Account Coding - +