diff --git a/build.gradle b/build.gradle index 7778f7d933..9532b05cd1 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'jacoco' id 'idea' id 'eclipse' - id 'org.springframework.boot' version '3.5.13' + id 'org.springframework.boot' version '3.5.14' id 'com.github.ben-manes.versions' version '0.54.0' id 'io.spring.dependency-management' version '1.1.7' id 'org.sonarqube' version '6.3.1.5724' @@ -12,7 +12,7 @@ plugins { id 'org.jetbrains.gradle.plugin.idea-ext' version '1.4.1' id 'info.solidsoft.pitest' version '1.19.0' id 'uk.gov.hmcts.java' version '0.12.68' - id 'au.com.dius.pact' version '4.6.20' + id 'au.com.dius.pact' version '4.7.0' id 'org.jsonschema2pojo' version '1.2.2' id 'org.owasp.dependencycheck' version '12.2.1' } @@ -22,7 +22,7 @@ def versions = [ ] ext { - set('springCloudVersion', '2025.0.1') + set('springCloudVersion', '2025.1.1') set('spring-framework.version', '6.2.14') set('spring-security.version', '6.5.7') set('log4j2.version', '2.24.3') @@ -32,7 +32,7 @@ ext { junitPlatform = '1.14.3' jjwt = '0.13.0' appInsightsVersion = '2.6.4' - pactProviderVersion = '4.6.20' + pactProviderVersion = '4.7.0' lombokVersion = '1.18.46' lombokBindingVersion = '0.2.0' mockito = '5.21.0' @@ -102,6 +102,22 @@ application { } sourceSets { + supportTools { + java { + srcDir('src/support-tools/java') + compileClasspath += main.output + main.compileClasspath + runtimeClasspath += main.output + main.runtimeClasspath + } + resources { + srcDir('src/support-tools/resources') + } + } + + test { + compileClasspath += sourceSets.supportTools.output + runtimeClasspath += sourceSets.supportTools.output + } + aat { java { srcDir('src/aat/java') @@ -176,6 +192,8 @@ configurations { integrationTestImplementation.extendsFrom(testImplementation) integrationTestRuntimeOnly.extendsFrom(testRuntimeOnly) integrationTestAnnotationProcessor.extendsFrom(annotationProcessor) + supportToolsCompileOnly.extendsFrom(compileOnly) + supportToolsAnnotationProcessor.extendsFrom(annotationProcessor) all.collect { configuration -> configuration.exclude group: 'org.apache.logging.log4j', module: 'log4j-to-slf4j' @@ -244,7 +262,7 @@ dependencies { implementation group: 'org.springframework.boot', name: 'spring-boot-starter-jdbc' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-oauth2-resource-server' implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-openfeign' - implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-loadbalancer', version: '4.3.1' + implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-loadbalancer', version: '4.3.2' implementation group: 'org.springframework.hateoas', name: 'spring-hateoas' implementation group: 'org.springframework.plugin', name: 'spring-plugin-core' implementation group: 'org.springframework.retry', name: 'spring-retry' @@ -265,7 +283,7 @@ dependencies { implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '8.0.0' implementation group: 'com.auth0', name: 'java-jwt', version: '4.5.1' - implementation group: 'com.google.guava', name: 'guava', version: '33.5.0-jre' + implementation group: 'com.google.guava', name: 'guava', version: '33.6.0-jre' implementation group: 'com.github.ben-manes.caffeine', name: 'caffeine', version: '3.2.3' implementation group: 'com.microsoft.azure', name: 'applicationinsights-logging-logback', version: appInsightsVersion implementation group: 'com.microsoft.azure', name: 'applicationinsights-spring-boot-starter', version: appInsightsVersion @@ -288,6 +306,7 @@ dependencies { implementation group: 'org.jooq', name: 'jool-java-8', version: '0.9.15' implementation group: 'org.mapstruct', name: 'mapstruct', version: '1.6.3' implementation group: 'pl.jalokim.propertiestojson', name: 'java-properties-to-json', version: '5.3.0' + implementation group: 'org.apache.poi', name: 'poi-ooxml', version: '5.4.0' annotationProcessor group: 'org.mapstruct', name: 'mapstruct-processor', version: '1.6.3' testAnnotationProcessor group: 'org.mapstruct', name: 'mapstruct-processor', version: '1.6.3' @@ -297,14 +316,14 @@ dependencies { testImplementation libraries.junit5 testImplementation libs.mockito mockitoAgent(libs.mockito) { - transitive = false; + transitive = false } testImplementation group: 'com.opentable.components', name: 'otj-pg-embedded', version: '1.1.1' testImplementation group: 'com.xebialabs.restito', name: 'restito', version: '1.1.2' testImplementation group: 'io.rest-assured', name: 'rest-assured', version: '5.5.7' testImplementation group: 'org.assertj', name: 'assertj-vavr', version: '0.5.0' testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test' - testImplementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-contract-stub-runner', version: '4.3.1' + testImplementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-contract-stub-runner', version: '4.3.3' testImplementation group: 'org.testcontainers', name: 'testcontainers', version: testContainersVersion testImplementation group: 'org.testcontainers', name: 'postgresql', version: testContainersVersion testImplementation group: 'org.testcontainers', name: 'elasticsearch', version: testContainersVersion @@ -313,7 +332,7 @@ dependencies { testImplementation group: 'com.github.hmcts', name: 'ccd-test-definitions', version: ccdTestDefinitionVersion testImplementation group: 'com.github.hmcts', name: 'befta-fw', version: beftaFwVersion testImplementation group: 'com.github.hmcts', name: 'fortify-client', version: '1.4.10', classifier: 'all' - testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock:4.3.1' + testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock:4.3.3' contractTestImplementation group: 'au.com.dius.pact.provider', name: 'junit5', version: pactProviderVersion contractTestImplementation group: 'au.com.dius.pact.provider', name: 'spring', version: pactProviderVersion @@ -625,7 +644,7 @@ jacocoTestReport { logger.lifecycle("Checking coverage results: ${report}") def parser = new groovy.xml.XmlParser() - parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false) def results = parser.parse(report) @@ -718,13 +737,23 @@ task fortifyScan(type: JavaExec) { jvmArgs = ['--add-opens=java.base/java.lang.reflect=ALL-UNNAMED'] } +tasks.register('definitionSpreadsheetHarness', JavaExec) { + group = "verification" + description = "Runs the definition spreadsheet harness via main()." + dependsOn supportToolsClasses + mainClass = "uk.gov.hmcts.ccd.tools.DefinitionSpreadsheetHarness" + classpath = sourceSets.supportTools.runtimeClasspath +} + idea { module { - // config to allow InteliJ to mark test source and resource files correctly to help linting tools + // config to allow IntelliJ to mark test source and resource files correctly to help linting tools testSources.from(project.sourceSets.aat.java.srcDirs) testSources.from(project.sourceSets.contractTest.java.srcDirs) + testSources.from(project.sourceSets.supportTools.java.srcDirs) testResources.from(project.sourceSets.aat.resources.srcDirs) testResources.from(project.sourceSets.contractTest.resources.srcDirs) + testResources.from(project.sourceSets.supportTools.resources.srcDirs) } } @@ -758,7 +787,7 @@ task smoke(type: JavaExec) { finalizedBy { generateCucumberReports { - doLast{ + doLast { delete "${rootDir}/BEFTA Report for Smoke Tests/" new File("${rootDir}/BEFTA Report for Smoke Tests").mkdirs() file("${rootDir}/target/cucumber/cucumber-html-reports").renameTo(file("${rootDir}/BEFTA Report for Smoke Tests")) @@ -798,7 +827,7 @@ task functional(type: JavaExec) { finalizedBy { generateCucumberReports.enabled = true generateCucumberReports { - doLast{ + doLast { delete "${rootDir}/BEFTA Report for Functional Tests/" new File("${rootDir}/BEFTA Report for Functional Tests").mkdirs() file("${rootDir}/target/cucumber/cucumber-html-reports").renameTo(file("${rootDir}/BEFTA Report for Functional Tests")) @@ -896,6 +925,13 @@ void configRemoteRunTask(Task execTask, String env) { } } +tasks.named('definitionSpreadsheetHarness', JavaExec) { + systemProperty 'definition.file', project.findProperty('definition.file') + systemProperty 'roles', project.findProperty('roles') + systemProperty 'target.fields', project.findProperty('target.fields') + systemProperty 'event.id', project.findProperty('event.id') +} + void loadEnvSecrets(String env) { def azCmd = ['az', 'keyvault', 'secret', 'show', '--vault-name', "ccd-${env}", '-o', 'tsv', '--query', 'value', '--name', 'data-store-remote-env'] if (!project.file("./.${env}-remote-env").exists()) { diff --git a/scripts/run-definition-spreadsheet-harness.sh b/scripts/run-definition-spreadsheet-harness.sh new file mode 100755 index 0000000000..0a14d6e09d --- /dev/null +++ b/scripts/run-definition-spreadsheet-harness.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Runs the definition spreadsheet harness with the provided arguments. +# +# Usage: +# run-definition-spreadsheet-harness.sh +# +# and can be comma separated lists. +# +# Example: +# ./scripts/run-definition-spreadsheet-harness.sh /ccd-appeal-config-preview-pr3017.xlsx caseworker-ia-admofficer isFeePaymentEnabled,sponsorEmailAdminJ,sponsorMobileNumberAdminJ,sponsorAddress editAppealAfterSubmit; +# +# Process summary: +# 1) Reads CaseEventToFields, AuthorisationCaseField, RoleToAccessProfiles from the XLSX. +# 2) Resolves roles to access profiles. +# 3) Evaluates read access for each field and matching case type for the event. +# +# Exit code: +# Non-zero on Gradle task failure (invalid input/configuration or non-returned decisions). +# +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" + +if [ "$#" -ne 4 ]; then + exit 1 +fi + +definition_file="$1" +roles="$2" +target_fields="$3" +event_id="$4" + +cd "${repo_root}" + +./gradlew definitionSpreadsheetHarness \ + -Pdefinition.file="$definition_file" \ + -Proles="$roles" \ + -Ptarget.fields="$target_fields" \ + -Pevent.id="$event_id" diff --git a/src/support-tools/java/uk/gov/hmcts/ccd/tools/DefinitionSpreadsheetHarness.java b/src/support-tools/java/uk/gov/hmcts/ccd/tools/DefinitionSpreadsheetHarness.java new file mode 100644 index 0000000000..3095c03007 --- /dev/null +++ b/src/support-tools/java/uk/gov/hmcts/ccd/tools/DefinitionSpreadsheetHarness.java @@ -0,0 +1,577 @@ +package uk.gov.hmcts.ccd.tools; + +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import uk.gov.hmcts.ccd.domain.model.casedataaccesscontrol.AccessProfile; +import uk.gov.hmcts.ccd.domain.model.definition.AccessControlList; +import uk.gov.hmcts.ccd.domain.service.common.AccessControlService; + +/** + * Harness that inspects a definition spreadsheet and reports whether + * specific fields are readable for the supplied roles on a supplied event. + * If readable, the field will be returned. + * + *

Required system properties (parameters):

+ *
    + *
  • {@code -Ddefinition.file}: absolute or relative path to the definition XLSX.
  • + *
  • {@code -Droles}: comma-separated list of roles ("idam:" prefix for a role is allowed).
  • + *
  • {@code -Dtarget.fields}: comma-separated list of case field IDs to check.
  • + *
  • {@code -Devent.id}: page event id.
  • + *
+ *

+ * Example of command line arguments to run from IntelliJ: + * -ea + * -Dtarget.fields=isFeePaymentEnabled,sponsorEmailAdminJ,sponsorMobileNumberAdminJ,sponsorAddress + * -Ddefinition.file=/ccd-appeal-config-preview-pr3017.xlsx + * -Droles=caseworker-ia-admofficer,hmcts-admin + * -Devent.id=editAppealAfterSubmit + * + *

Process:

+ *
    + *
  1. Load the {@code CaseEventToFields}, {@code AuthorisationCaseField}, and + * {@code RoleToAccessProfiles} sheets and detect header rows.
  2. + *
  3. Translate input roles into access profiles via {@code RoleToAccessProfiles}.
  4. + *
  5. For each target field, find matching case types for the supplied event.
  6. + *
  7. Evaluate read access for each {@code (fieldId, caseTypeId)} pair from + * {@code AuthorisationCaseField} CRUD.
  8. + *
  9. Emit one decision per evaluated pair and fail if any decision is not returned.
  10. + *
+ * + *

Exit codes:

+ *
    + *
  • {@code 0}: all decisions returned.
  • + *
  • {@code 1}: invalid/missing input or spreadsheet structure.
  • + *
  • {@code 2}: at least one field decision is not returned.
  • + *
+ * Success: field -> Returned + * Failure: field -> Not returned: No read access + */ +@Slf4j +public class DefinitionSpreadsheetHarness { + + private static final String ACCESS_PROFILE_COLUMN = "accessprofile"; + private static final String ACCESS_PROFILES_COLUMN = "accessprofiles"; + private static final String AUTHORISATION_CASE_FIELD_SHEET = "AuthorisationCaseField"; + private static final String CASE_EVENT_ID_COLUMN = "caseeventid"; + private static final String CASE_EVENT_TO_FIELDS_SHEET = "CaseEventToFields"; + private static final String CASE_FIELD_ID_COLUMN = "casefieldid"; + private static final String CASE_TYPE_ID_COLUMN = "casetypeid"; + private static final String EVENT_ID_PROPERTY = "event.id"; + private static final String IDAM_PREFIX = "idam:"; + private static final String ROLE_NAME_COLUMN = "rolename"; + private static final String ROLES_PROPERTY = "roles"; + private static final String ROLES_TO_ACCESS_PROFILE_SHEET = "RoleToAccessProfiles"; + private static final String SPREADSHEET_PATH_PROPERTY = "definition.file"; + private static final String TARGET_FIELDS_PROPERTY = "target.fields"; + + public static void main(String[] args) { + try { + List failures = runFromSystemProperties(); + if (failures.isEmpty()) { + log.info("All target fields are returned for the supplied roles."); + } else { + log.error("Some fields are not returned for the supplied roles (count={}): {}", + failures.size(), + failures.stream().map(FieldDecision::fieldId).distinct().collect(Collectors.joining(", "))); + failures.forEach(failure -> log.error(failure.format())); + System.exit(2); + } + } catch (IllegalArgumentException error) { + log.error(error.getMessage()); + System.exit(1); + } catch (Exception error) { + log.error("Unexpected failure running definition spreadsheet harness", error); + System.exit(1); + } + } + + static List run(Path spreadsheetPath, + Set roles, + String eventId, + List fieldIds) throws Exception { + if (roles == null || roles.isEmpty()) { + throw new IllegalArgumentException("No valid roles provided in -D" + ROLES_PROPERTY); + } + if (fieldIds == null || fieldIds.isEmpty()) { + throw new IllegalArgumentException("No valid target fields provided in -D" + TARGET_FIELDS_PROPERTY); + } + return evaluateSpreadsheet(spreadsheetPath, roles, eventId, fieldIds); + } + + private static List runFromSystemProperties() throws Exception { + String spreadsheetPath = requireProperty(SPREADSHEET_PATH_PROPERTY,"path-to-xlsx"); + String rolesCsv = requireProperty(ROLES_PROPERTY,"role1,role2"); + String fieldsCsv = requireProperty(TARGET_FIELDS_PROPERTY,"field1,field2"); + String eventId = requireProperty(EVENT_ID_PROPERTY,"eventId"); + + Set roles = parseRoles(rolesCsv); + List targetFields = parseFields(fieldsCsv); + if (roles.isEmpty()) { + throw new IllegalArgumentException("No valid roles provided in -D" + ROLES_PROPERTY); + } + if (targetFields.isEmpty()) { + throw new IllegalArgumentException("No valid target fields provided in -D" + TARGET_FIELDS_PROPERTY); + } + + List decisions = run(Path.of(spreadsheetPath), roles, eventId, targetFields); + + decisions.forEach(decision -> log.info(decision.format())); + + return decisions.stream() + .filter(decision -> !decision.isReturned()) + .collect(Collectors.toList()); + } + + private static String requireProperty(String key, String missingPropertyValue) { + String value = System.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing -D" + key + "=" + missingPropertyValue); + } + return value; + } + + private static Set parseRoles(String rolesCsv) { + Set roles = new HashSet<>(); + for (String role : rolesCsv.split(",")) { + String trimmed = normalizeRole(role); + if (!trimmed.isEmpty()) { + roles.add(trimmed); + } + } + return roles; + } + + private static List parseFields(String fieldsCsv) { + List fields = new ArrayList<>(); + for (String field : fieldsCsv.split(",")) { + String trimmed = field.trim(); + if (!trimmed.isEmpty()) { + fields.add(trimmed); + } + } + return fields; + } + + /** + * Evaluates the spreadsheet against the provided inputs. + * + * @param spreadsheetPath path to the XLSX definition file + * @param roles role names to translate into access profiles + * @param eventId event ID to match in {@code CaseEventToFields} + * @param fieldIds case field IDs to evaluate + */ + private static List evaluateSpreadsheet(Path spreadsheetPath, + Set roles, + String eventId, + List fieldIds) throws Exception { + log.info("Evaluating spreadsheet: {}", spreadsheetPath); + log.info("Event ID: {}", eventId); + log.info("Roles: {}", String.join(", ", roles)); + log.info("Target fields: {}", String.join(", ", fieldIds)); + try (InputStream inputStream = openSpreadsheetInputStream(spreadsheetPath); + Workbook workbook = WorkbookFactory.create(inputStream)) { + + List> eventToFields = tryReadSheet(workbook, CASE_EVENT_TO_FIELDS_SHEET); + List> authCaseFields = tryReadSheet(workbook, AUTHORISATION_CASE_FIELD_SHEET); + List> rolesToAccessProfiles = tryReadSheet(workbook, ROLES_TO_ACCESS_PROFILE_SHEET); + Set accessProfiles = translateRolesToAccessProfiles(roles, rolesToAccessProfiles); + log.info("Rows loaded: CaseEventToFields={}, AuthorisationCaseField={}", + eventToFields.size(), authCaseFields.size()); + log.info("Rows loaded: RolesToAccessProfile={}, accessProfiles={}", + rolesToAccessProfiles.size(), String.join(", ", accessProfiles)); + if (!rolesToAccessProfiles.isEmpty()) { + log.info("RoleToAccessProfiles headers: {}", + String.join(", ", rolesToAccessProfiles.getFirst().keySet())); + } + if (!eventToFields.isEmpty()) { + log.info("CaseEventToFields headers: {}", + String.join(", ", eventToFields.getFirst().keySet())); + log.info("CaseEventToFields sample {} values: {}", + CASE_EVENT_ID_COLUMN, + eventToFields.stream() + .map(row -> row.get(CASE_EVENT_ID_COLUMN)) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .limit(5) + .collect(Collectors.joining(", "))); + } + if (!authCaseFields.isEmpty()) { + log.info("AuthorisationCaseField headers: {}", + String.join(", ", authCaseFields.getFirst().keySet())); + } + + List decisions = new ArrayList<>(); + for (String fieldId : fieldIds) { + List caseTypesForEventField = findCaseTypesForEventField(fieldId, eventId, eventToFields); + log.info("field {} - caseTypesForEventField: {}", fieldId, caseTypesForEventField); + if (caseTypesForEventField.isEmpty()) { + decisions.add(FieldDecision.notReturned(fieldId, + "No CaseEventToFields row found for event/field")); + continue; + } + for (String caseTypeId : caseTypesForEventField) { + decisions.add(evaluateField(fieldId, caseTypeId, accessProfiles, authCaseFields)); + } + } + log.info("Decisions evaluated: {}", decisions.size()); + return decisions; + } + } + + private static InputStream openSpreadsheetInputStream(Path spreadsheetPath) throws IOException { + if (spreadsheetPath == null) { + throw new IllegalArgumentException("Spreadsheet path must not be null"); + } + if (!Files.isRegularFile(spreadsheetPath)) { + throw new NoSuchFileException(spreadsheetPath.toString()); + } + return Files.newInputStream(spreadsheetPath); + } + + /** + * Determines whether a field is viewable for one specific case type. + * + * @param fieldId case field ID to check + * @param caseTypeId case type ID + * @param accessProfiles translated access profiles for the input roles + * @param authCaseFields rows from {@code AuthorisationCaseField} + */ + private static FieldDecision evaluateField(String fieldId, + String caseTypeId, + Set accessProfiles, + List> authCaseFields) { + log.info("Checking case type: {}", caseTypeId); + List> caseTypeFieldRows = authCaseFields.stream() + .filter(row -> equalsIgnoreCase(rowValue(row, CASE_TYPE_ID_COLUMN), caseTypeId)) + .filter(row -> equalsIgnoreCase(rowValue(row, CASE_FIELD_ID_COLUMN), fieldId)) + .collect(Collectors.toList()); + log.info("AuthorisationCaseField rows for caseType/field: {}", caseTypeFieldRows.size()); + if (caseTypeFieldRows.isEmpty()) { + log.info("No matching AuthorisationCaseField rows for caseType={} field={}", caseTypeId, fieldId); + return FieldDecision.notReturned(fieldId, + "No matching AuthorisationCaseField rows for case type: " + caseTypeId); + } + + boolean viewable = canAccessCaseViewFieldWithCriteria(caseTypeFieldRows, accessProfiles); + log.info("Derived CaseViewField visibility from CRUD: {}", viewable); + if (viewable) { + log.info("Viewable for accessProfiles {} in case type {}", + String.join(", ", accessProfiles), caseTypeId); + return FieldDecision.returned(fieldId, caseTypeId); + } + log.info("No read access for accessProfiles {} in case type {}", String.join(", ", accessProfiles), + caseTypeId); + return FieldDecision.notReturned(fieldId, + "No read access in AuthorisationCaseField for supplied roles in case type: " + caseTypeId); + } + + /** + * Finds case types linked to the supplied field and event. + * + * @param fieldId case field ID to check + * @param eventId event ID to match in {@code CaseEventToFields} + * @param eventToFields rows from {@code CaseEventToFields} + */ + private static List findCaseTypesForEventField(String fieldId, + String eventId, + List> eventToFields) { + log.info("Evaluating field: {} (eventId={})", fieldId, eventId); + long totalEventToFields = eventToFields.size(); + List> eventIdMatches = eventToFields.stream() + .filter(row -> equalsIgnoreCase(row.get(CASE_EVENT_ID_COLUMN), eventId)) + .toList(); + List> eventAndFieldMatches = eventIdMatches.stream() + .filter(row -> equalsIgnoreCase(row.get(CASE_FIELD_ID_COLUMN), fieldId)) + .toList(); + List caseTypesForEventField = eventAndFieldMatches.stream() + .map(row -> nullSafeTrim(row.get(CASE_TYPE_ID_COLUMN))) + .filter(value -> !value.isEmpty()) + .distinct() + .collect(Collectors.toList()); + log.info("CaseEventToFields rows: total={}, eventIdMatch={}, eventId+fieldMatch={}, caseTypes={}", + totalEventToFields, eventIdMatches.size(), eventAndFieldMatches.size(), + String.join(", ", caseTypesForEventField)); + return caseTypesForEventField; + } + + private static boolean canAccessCaseViewFieldWithCriteria(List> accessControlRows, + Set accessProfiles) { + log.info("Evaluating ACLs for accessProfiles: {}", String.join(", ", accessProfiles)); + log.info("AccessControl rows count: {}", accessControlRows.size()); + List accessControlLists = accessControlRows.stream() + .map(DefinitionSpreadsheetHarness::toAccessControlList) + .collect(Collectors.toList()); + for (AccessControlList acl : accessControlLists) { + log.info("ACL: accessProfile={}, create={}, read={}, update={}, delete={}", + acl.getAccessProfile(), acl.isCreate(), acl.isRead(), acl.isUpdate(), acl.isDelete()); + } + Set profileSet = accessProfiles.stream() + .map(AccessProfile::new) + .collect(Collectors.toSet()); + boolean hasAccess = AccessControlService.hasAccessControlList(profileSet, + accessControlLists, + AccessControlService.CAN_READ); + log.info("ACL read access result: {}", hasAccess); + return hasAccess; + } + + private static AccessControlList toAccessControlList(Map row) { + AccessControlList accessControlList = new AccessControlList(); + accessControlList.setAccessProfile(nullSafeTrim(rowValue(row, ACCESS_PROFILE_COLUMN))); + String crud = rowValue(row, "crud"); + if (crud != null) { + String upper = crud.toUpperCase(Locale.ROOT); + accessControlList.setCreate(upper.contains("C")); + accessControlList.setRead(upper.contains("R")); + accessControlList.setUpdate(upper.contains("U")); + accessControlList.setDelete(upper.contains("D")); + } + return accessControlList; + } + + private static List> tryReadSheet(Workbook workbook, String sheetName) { + log.info("Reading sheet: {}", sheetName); + Sheet sheet = workbook.getSheet(sheetName); + if (sheet == null) { + throw new IllegalArgumentException("Missing sheet: " + sheetName); + } + + DataFormatter formatter = new DataFormatter(); + Row headerRow = sheet.getRow(sheet.getFirstRowNum()); + if (headerRow == null) { + throw new IllegalArgumentException("Missing header row in sheet: " + sheetName); + } else { + return readSheet(sheet, sheetName, headerRow, formatter); + } + } + + private static List> readSheet(Sheet sheet, String sheetName, Row headerRow, + DataFormatter formatter) { + + Map headersByIndex = readHeaders(headerRow, formatter); + log.info("Initial header row index for {}: {}", sheetName, headerRow.getRowNum()); + if (ROLES_TO_ACCESS_PROFILE_SHEET.equals(sheetName)) { + if (isRoleToAccessProfilesHeader(headersByIndex)) { + log.info("Using initial RoleToAccessProfiles header row at index {}", headerRow.getRowNum()); + } else { + Row foundHeaderRow = findHeaderRow(sheet, headerRow.getRowNum() + 1, formatter, + DefinitionSpreadsheetHarness::isRoleToAccessProfilesHeader); + if (foundHeaderRow != null) { + headerRow = foundHeaderRow; + headersByIndex = readHeaders(headerRow, formatter); + log.info("Adjusted RoleToAccessProfiles header row to index {}", headerRow.getRowNum()); + } + } + } + if (CASE_EVENT_TO_FIELDS_SHEET.equals(sheetName)) { + if (isCaseEventToFieldsHeader(headersByIndex)) { + log.info("Using initial CaseEventToFields header row at index {}", headerRow.getRowNum()); + } else { + Row foundHeaderRow = findHeaderRow(sheet, headerRow.getRowNum() + 1, formatter, + DefinitionSpreadsheetHarness::isCaseEventToFieldsHeader); + if (foundHeaderRow != null) { + headerRow = foundHeaderRow; + headersByIndex = readHeaders(headerRow, formatter); + log.info("Adjusted CaseEventToFields header row to index {}", headerRow.getRowNum()); + } + } + } + if (AUTHORISATION_CASE_FIELD_SHEET.equals(sheetName)) { + if (isAuthorisationCaseFieldHeader(headersByIndex)) { + log.info("Using initial AuthorisationCaseField header row at index {}", headerRow.getRowNum()); + } else { + Row foundHeaderRow = findHeaderRow(sheet, headerRow.getRowNum() + 1, formatter, + DefinitionSpreadsheetHarness::isAuthorisationCaseFieldHeader); + if (foundHeaderRow != null) { + headerRow = foundHeaderRow; + headersByIndex = readHeaders(headerRow, formatter); + log.info("Adjusted AuthorisationCaseField header row to index {}", headerRow.getRowNum()); + } + } + } + if (ROLES_TO_ACCESS_PROFILE_SHEET.equals(sheetName) && !isRoleToAccessProfilesHeader(headersByIndex)) { + throw new IllegalArgumentException("Header row not found for sheet: " + sheetName); + } + if (CASE_EVENT_TO_FIELDS_SHEET.equals(sheetName) && !isCaseEventToFieldsHeader(headersByIndex)) { + throw new IllegalArgumentException("Header row not found for sheet: " + sheetName); + } + if (AUTHORISATION_CASE_FIELD_SHEET.equals(sheetName) && !isAuthorisationCaseFieldHeader(headersByIndex)) { + throw new IllegalArgumentException("Header row not found for sheet: " + sheetName); + } + log.info("Using header row index for {}: {}", sheetName, headerRow.getRowNum()); + + List> rows = new ArrayList<>(); + for (int i = headerRow.getRowNum() + 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + Map rowData = new HashMap<>(); + boolean hasValue = false; + for (Map.Entry entry : headersByIndex.entrySet()) { + String value = formatter.formatCellValue(row.getCell(entry.getKey())).trim(); + if (!value.isEmpty()) { + hasValue = true; + } + rowData.put(entry.getValue(), value); + } + if (hasValue) { + rows.add(rowData); + } + } + log.info("Loaded {} data rows from sheet {}", rows.size(), sheetName); + return rows; + } + + private static boolean equalsIgnoreCase(String left, String right) { + if (left == null || right == null) { + return false; + } + return left.trim().equalsIgnoreCase(right.trim()); + } + + private static String nullSafeTrim(String value) { + return value == null ? "" : value.trim(); + } + + private static Map readHeaders(Row headerRow, DataFormatter formatter) { + Map headersByIndex = new HashMap<>(); + headerRow.forEach(cell -> { + String header = formatter.formatCellValue(cell).trim(); + if (!header.isEmpty()) { + headersByIndex.put(cell.getColumnIndex(), header.toLowerCase(Locale.ROOT)); + } + }); + return headersByIndex; + } + + private static boolean isRoleToAccessProfilesHeader(Map headersByIndex) { + return headersByIndex.containsValue(ROLE_NAME_COLUMN) + && headersByIndex.containsValue(ACCESS_PROFILES_COLUMN); + } + + private static boolean isCaseEventToFieldsHeader(Map headersByIndex) { + return headersByIndex.containsValue(CASE_EVENT_ID_COLUMN) + && headersByIndex.containsValue(CASE_FIELD_ID_COLUMN) + && headersByIndex.containsValue(CASE_TYPE_ID_COLUMN); + } + + private static boolean isAuthorisationCaseFieldHeader(Map headersByIndex) { + boolean hasCaseType = headersByIndex.containsValue(CASE_TYPE_ID_COLUMN); + boolean hasCaseField = headersByIndex.containsValue(CASE_FIELD_ID_COLUMN); + boolean hasAccessProfile = headersByIndex.containsValue(ACCESS_PROFILE_COLUMN); + boolean hasCrud = headersByIndex.containsValue("crud"); + return hasCaseType && hasCaseField && hasAccessProfile && hasCrud; + } + + private static Row findHeaderRow(Sheet sheet, + int startRow, + DataFormatter formatter, + java.util.function.Predicate> matcher) { + for (int i = startRow; i <= sheet.getLastRowNum(); i++) { + Row candidateRow = sheet.getRow(i); + if (candidateRow == null) { + continue; + } + Map candidateHeaders = readHeaders(candidateRow, formatter); + if (matcher.test(candidateHeaders)) { + return candidateRow; + } + } + return null; + } + + /** + * Maps role names to access profile names using the {@code RoleToAccessProfiles} sheet. + * + * @param roles input role names (normalized) + * @param rolesToAccessProfiles sheet rows with role/profile mappings + */ + private static Set translateRolesToAccessProfiles(Set roles, + List> rolesToAccessProfiles) { + Set accessProfiles = new HashSet<>(roles); + log.info("Translating roles to access profiles from sheet: {}", + ROLES_TO_ACCESS_PROFILE_SHEET); + log.info("Seeded direct access profiles from supplied roles: {}", String.join(", ", accessProfiles)); + for (Map row : rolesToAccessProfiles) { + String rawRoleName = rowValue(row, ROLE_NAME_COLUMN); + String roleName = normalizeRole(rawRoleName); + if (roles.contains(roleName)) { + log.info("Matched role: raw='{}', normalized='{}'", rawRoleName, roleName); + String profilesValue = nullSafeTrim(rowValue(row, ACCESS_PROFILES_COLUMN)); + if (!profilesValue.isEmpty()) { + for (String profile : profilesValue.split("[,;]")) { + String trimmed = profile.trim(); + if (!trimmed.isEmpty()) { + accessProfiles.add(trimmed); + log.info("Added access profile: {}", trimmed); + } + } + } else { + log.info("Matched role '{}' but accessProfiles is empty", roleName); + } + } + } + log.info("Resolved access profiles for roles {} -> {}", String.join(", ", roles), + String.join(", ", accessProfiles)); + return accessProfiles; + } + + private static String normalizeRole(String role) { + if (role == null) { + return ""; + } + String trimmed = role.trim(); + if (trimmed.toLowerCase(Locale.ROOT).startsWith(IDAM_PREFIX)) { + return trimmed.substring(IDAM_PREFIX.length()).trim(); + } + return trimmed; + } + + private static String rowValue(Map row, String... keys) { + for (String key : keys) { + String value = row.get(key); + if (value != null) { + return value; + } + } + return null; + } + + record FieldDecision(String fieldId, boolean returned, String details) { + static FieldDecision returned(String fieldId, String caseTypeId) { + return new FieldDecision(fieldId, true, "Returned (case type: " + caseTypeId + ")"); + } + + static FieldDecision notReturned(String fieldId, String reason) { + return new FieldDecision(fieldId, false, "Not returned: " + reason); + } + + boolean isReturned() { + return returned; + } + + String format() { + return fieldId + " -> " + details; + } + } +} diff --git a/src/support-tools/resources/ccd-appeal-config-preview-pr3017.xlsx b/src/support-tools/resources/ccd-appeal-config-preview-pr3017.xlsx new file mode 100644 index 0000000000..c8afab04ca Binary files /dev/null and b/src/support-tools/resources/ccd-appeal-config-preview-pr3017.xlsx differ diff --git a/src/support-tools/resources/first-row-headers.xlsx b/src/support-tools/resources/first-row-headers.xlsx new file mode 100644 index 0000000000..4b1336596c Binary files /dev/null and b/src/support-tools/resources/first-row-headers.xlsx differ diff --git a/src/support-tools/resources/invalid-definition.xlsx b/src/support-tools/resources/invalid-definition.xlsx new file mode 100644 index 0000000000..bbce99d348 Binary files /dev/null and b/src/support-tools/resources/invalid-definition.xlsx differ diff --git a/src/test/java/uk/gov/hmcts/ccd/tools/DefinitionSpreadsheetHarnessTest.java b/src/test/java/uk/gov/hmcts/ccd/tools/DefinitionSpreadsheetHarnessTest.java new file mode 100644 index 0000000000..5e864e5a39 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/tools/DefinitionSpreadsheetHarnessTest.java @@ -0,0 +1,137 @@ +package uk.gov.hmcts.ccd.tools; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class DefinitionSpreadsheetHarnessTest { + + private static final String SUCCESS_ROLE = "caseworker-ia-admofficer"; + private static final String FAILURE_ROLE = "caseworker-ia-admofficer2"; + private static final String EVENT = "editAppealAfterSubmit"; + private static final String ASYLUM = "Asylum"; + private static final List TARGET_FIELDS = List.of( + "isFeePaymentEnabled", + "sponsorEmailAdminJ", + "sponsorMobileNumberAdminJ", + "sponsorAddress" + ); + private static final String FIRST_ROW_HEADERS_DEFINITION_SPREADSHEET = "first-row-headers.xlsx"; + private static final String INVALID_DEFINITION_SPREADSHEET = "invalid-definition.xlsx"; + private static final String VALID_DEFINITION_SPREADSHEET = "ccd-appeal-config-preview-pr3017.xlsx"; + + private static final Path KNOWN_SPREADSHEET = testResourcePath(VALID_DEFINITION_SPREADSHEET); + + private static Path testResourcePath(String name) { + try { + var resource = DefinitionSpreadsheetHarnessTest.class.getResource("/" + name); + if (resource == null) { + throw new IllegalStateException("Missing test resource: " + name); + } + return Path.of(resource.toURI()); + } catch (Exception e) { + throw new RuntimeException("Failed to resolve test resource: " + name, e); + } + } + + @Test + void shouldReturnNoFailuresForKnownAppealSpreadsheetInputs() throws Exception { + assumeTrue(Files.isRegularFile(KNOWN_SPREADSHEET), + "Skipping: expected spreadsheet not found at " + KNOWN_SPREADSHEET); + + List decisions = + DefinitionSpreadsheetHarness.run(KNOWN_SPREADSHEET, Set.of(SUCCESS_ROLE), EVENT, TARGET_FIELDS); + + assertThat(decisions) + .extracting(DefinitionSpreadsheetHarness.FieldDecision::fieldId) + .containsExactlyElementsOf(TARGET_FIELDS); + + assertThat(decisions) + .allSatisfy(decision -> { + assertThat(decision.isReturned()).isTrue(); + assertThat(decision.details()).contains("case type: " + ASYLUM); + }); + } + + @Test + void shouldReturnFailuresForKnownAppealSpreadsheetInputs() throws Exception { + assumeTrue(Files.isRegularFile(KNOWN_SPREADSHEET), + "Skipping: expected spreadsheet not found at " + KNOWN_SPREADSHEET); + + List decisions = + DefinitionSpreadsheetHarness.run(KNOWN_SPREADSHEET, Set.of(FAILURE_ROLE), EVENT, TARGET_FIELDS); + + assertThat(decisions).hasSize(TARGET_FIELDS.size()); + assertThat(decisions) + .extracting(DefinitionSpreadsheetHarness.FieldDecision::fieldId) + .containsExactlyElementsOf(TARGET_FIELDS); + assertThat(decisions) + .allSatisfy(decision -> { + assertThat(decision.isReturned()).isFalse(); + assertThat(decision.details()) + .contains("No read access in AuthorisationCaseField for supplied roles") + .contains("case type: " + ASYLUM); + }); + } + + @Test + void shouldFailWhenCaseEventToFieldsHeaderRowCannotBeFound() throws Exception { + Path invalidSpreadsheet = testResourcePath(INVALID_DEFINITION_SPREADSHEET); + + assertThatThrownBy(() -> DefinitionSpreadsheetHarness.run( + invalidSpreadsheet, + Set.of(SUCCESS_ROLE), + EVENT, + TARGET_FIELDS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Header row not found for sheet: CaseEventToFields"); + } + + @Test + void shouldReadHeadersWhenTheyAreOnFirstRow() throws Exception { + Path spreadsheet = testResourcePath(FIRST_ROW_HEADERS_DEFINITION_SPREADSHEET); + List decisions = + DefinitionSpreadsheetHarness.run( + spreadsheet, + Set.of(SUCCESS_ROLE), + EVENT, + List.of("isFeePaymentEnabled") + ); + + assertThat(decisions).hasSize(1); + assertThat(decisions.get(0).isReturned()).isTrue(); + assertThat(decisions.get(0).details()).contains("case type: " + ASYLUM); + } + + @Test + void shouldFailWhenNoValidRolesProvided() { + assertThatThrownBy(() -> DefinitionSpreadsheetHarness.run( + KNOWN_SPREADSHEET, + Set.of(), + EVENT, + TARGET_FIELDS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No valid roles provided in -Droles"); + } + + @Test + void shouldFailWhenNoValidTargetFieldsProvided() { + assertThatThrownBy(() -> DefinitionSpreadsheetHarness.run( + KNOWN_SPREADSHEET, + Set.of(SUCCESS_ROLE), + EVENT, + List.of() + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No valid target fields provided in -Dtarget.fields"); + } +}