From bd0e11635fa3173e890ff1678a301318d51f255f Mon Sep 17 00:00:00 2001 From: "B. Seeger" Date: Fri, 13 Oct 2023 15:30:56 -0400 Subject: [PATCH] Allow multiple flows w/in one session (#390) * Adds ability for use to work through multiple flows at once, storing each flow's submission data in the session. * Move UserFileRepositoryService into FormFlowController * Add user files to model in screen controller * Add flow as a path variable for download endpoints * Updates SubmissionRepositoryServer.save() to return a new submission object, instead of just the id. * Adds journey test for file uploads that cross flows. Co-authored-by: Chibuisi Enyia Co-Authored-By: Alex Gonzalez; agonzalez@codeforamerica.org --- README.md | 133 +++++++----- .../java/formflow/library/FileController.java | 134 ++++++------ .../formflow/library/FormFlowController.java | 152 +++++++++++-- .../java/formflow/library/PdfController.java | 6 +- .../formflow/library/ScreenController.java | 87 ++++---- .../data/SubmissionRepositoryService.java | 36 +-- .../data/UserFileRepositoryService.java | 4 +- .../SessionContinuityInterceptor.java | 7 +- .../formflow/library/utils/UserFileMap.java | 64 ++++++ .../templates/fragments/fileUploader.html | 12 +- .../controllers/FileControllerTest.java | 205 +++++++++++------- .../controllers/NoOpVirusScannerTest.java | 23 +- .../controllers/PdfControllerTest.java | 105 ++++++--- .../ScreenControllerJourneyTest.java | 88 ++++++++ .../controllers/ScreenControllerTest.java | 111 +++++++++- ...loadBlockedIfVirusScanUnreachableTest.java | 11 +- .../library/file/UploadJourneyTests.java | 95 ++++++-- .../library/file/UploadUnitTests.java | 2 +- .../framework/AfterSaveActionTest.java | 2 +- .../framework/BeforeDisplayActionTest.java | 8 +- .../framework/BeforeSaveActionTest.java | 10 +- .../framework/ConditionalNavigationTest.java | 22 ++ .../framework/CrossValidationTest.java | 25 ++- .../library/framework/OnPostActionTest.java | 15 +- .../library/inputs/OtherTestFlow.java | 160 ++++++++++++++ .../{UploadFlow.java => UploadFlowA.java} | 2 +- .../formflow/library/inputs/UploadFlowB.java | 10 + .../SubmissionRepositoryServiceTest.java | 14 +- .../utilities/AbstractBasePageTest.java | 1 - .../utilities/AbstractMockMvcTest.java | 84 ++++--- .../resources/flows-config/test-flow.yaml | 25 ++- .../flows-config/test-upload-flow.yaml | 9 +- .../templates/otherTestFlow/inputs.html | 101 +++++++++ .../otherTestFlow/subflowAddItem.html | 89 ++++++++ .../otherTestFlow/subflowAddItemPage2.html | 85 ++++++++ .../templates/otherTestFlow/success.html | 26 +++ .../templates/otherTestFlow/test.html | 21 ++ .../resources/templates/testFlow/inputs.html | 3 +- .../docUploadJourney.html | 0 .../docUploadUnit.html | 0 .../uploadFlowB/docUploadJourney.html | 34 +++ .../templates/uploadFlowB/docUploadUnit.html | 40 ++++ src/test/resources/testA.jpeg | Bin 0 -> 51665 bytes src/test/resources/testB.jpeg | Bin 0 -> 51665 bytes 44 files changed, 1638 insertions(+), 423 deletions(-) create mode 100644 src/main/java/formflow/library/utils/UserFileMap.java create mode 100644 src/test/java/formflow/library/controllers/ScreenControllerJourneyTest.java create mode 100644 src/test/java/formflow/library/inputs/OtherTestFlow.java rename src/test/java/formflow/library/inputs/{UploadFlow.java => UploadFlowA.java} (79%) create mode 100644 src/test/java/formflow/library/inputs/UploadFlowB.java create mode 100644 src/test/resources/templates/otherTestFlow/inputs.html create mode 100644 src/test/resources/templates/otherTestFlow/subflowAddItem.html create mode 100644 src/test/resources/templates/otherTestFlow/subflowAddItemPage2.html create mode 100644 src/test/resources/templates/otherTestFlow/success.html create mode 100644 src/test/resources/templates/otherTestFlow/test.html rename src/test/resources/templates/{uploadFlow => uploadFlowA}/docUploadJourney.html (100%) rename src/test/resources/templates/{uploadFlow => uploadFlowA}/docUploadUnit.html (100%) create mode 100644 src/test/resources/templates/uploadFlowB/docUploadJourney.html create mode 100644 src/test/resources/templates/uploadFlowB/docUploadUnit.html create mode 100644 src/test/resources/testA.jpeg create mode 100644 src/test/resources/testB.jpeg diff --git a/README.md b/README.md index 9720c7dfb..df15bedbb 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,6 @@ Table of Contents * [Configuration Details](#configuration-details) * [Environment Variables](#environment-variables) * [Application Configuration](#application-configuration) - * [Actuator Endpoints](#actuator-endpoints) - * [flows-config.yaml file](#flows-configyaml-file) * [Flow and Subflow Configuration](#flow-and-subflow-configuration) * [Screens](#screens) * [Defining Subflows](#defining-subflows) @@ -81,9 +79,9 @@ Table of Contents * [How to contribute](#how-to-contribute) * [Maintainer information](#maintainer-information) -A Spring Boot Java library that provides a framework for developing **form flow** based applications. -The intention is to speed up the creation of web applications that are a series of forms that -collect input from users. +A Spring Boot Java library that provides a framework for developing **form flow** based +applications. The intention is to speed up the creation of web applications that are a series of +forms that collect input from users. The library includes tooling for: @@ -242,8 +240,8 @@ to add another. #### Delete Confirmation Screen -This screen appears when a user selects `delete` on an iteration listed on the review screen. It asks -the user to confirm their deletion before submitting the actual deletion request to the server. +This screen appears when a user selects `delete` on an iteration listed on the review screen. It +asks the user to confirm their deletion before submitting the actual deletion request to the server. This page is not technically part of the subflow and as such, does not need to be denoted with `subflow: subflowName` in the `flows-config.yaml`. @@ -302,7 +300,8 @@ form flow configuration file. They are generally used to determine template or p Conditions are Java objects that implement the `Condition` [interface](https://github.com/codeforamerica/form-flow/blob/main/src/main/java/formflow/library/config/submission/Condition.java) -. As conditions are called with the Submission object, the instance variable `inputData` is available to them. +. As conditions are called with the Submission object, the instance variable `inputData` is +available to them. Here is a simple condition that looks at data in the submission to see if the email provided is a Google address. @@ -462,11 +461,10 @@ see [Hibernate's documentation.](https://docs.jboss.org/hibernate/stable/validat Note that our implementation does not make a field required, unless `@NotEmpty`, `@NotBlank`, or `@NotNull` is used. If a validation annotation such as `@Email` is used, it will not -actually validate the annotated input unless a user actually enters a value for that input. If you use -`@Email` and `@NotBlank` together, that causes both validations to run even if the user did not -enter a value, -validating both that they need to enter a value due to `@NotBlank` and because the blank value needs -to be a validly formatted email address due to `@Email`. +actually validate the annotated input unless a user actually enters a value for that input. If you +use `@Email` and `@NotBlank` together, that causes both validations to run even if the user did not +enter a value, validating both that they need to enter a value due to `@NotBlank` and because the +blank value needs to be a validly formatted email address due to `@Email`. ### Custom Annotations @@ -1183,8 +1181,7 @@ The address fragment has two required parameters, `validate` and `inputName`. - `validate` is a boolean value that determines whether the address should be validated by Smarty - `inputName` is the name that will be associated with all of the above inputs by being used as a prefix in their input's name. For example, if the `inputName` is `homeAddress` then the - corresponding - inputs will + corresponding inputs will be `homeAddressStreetAddress1`, `homeAddressStreetAddress2`, `homeAddressCity`, `homeAddressState`, and `homeAddressZipCode`. @@ -1194,12 +1191,9 @@ parameters: `streetAddressHelpText`, `streetAddress2HelpText`, `cityHelpText`, ` These will pass helper text to each specific field. Please note that when using the address fragment you will need to create corresponding fields in -your -flow inputs class for each of the above-mentioned inputs created by the fragment. For example, if -your -address fragments input name is `mailingAddress`, then you will need to create the following fields -in -your flow inputs class: +your flow inputs class for each of the above-mentioned inputs created by the fragment. For example, +if your address fragments input name is `mailingAddress`, then you will need to create the following +fields in your flow inputs class: ``` String mailingAddressStreetAddress1; @@ -2204,8 +2198,7 @@ starting a session. If no HttpSession has been established or a session lacks an appropriate Submission id, a client will be returned to the index page of an application. -⚠️ __It is set to be the last interceptor run by any -application using Form Flow Library.__ +⚠️ __It is set to be the last interceptor run by any application using Form Flow library.__ #### Configuration @@ -2328,7 +2321,7 @@ the [Spring.io documentation](https://docs.spring.io/spring-boot/docs/current/re It is expected that this file will be located within the application that is using this form flow library. -There are a few properties that the Form Flow Library will look for in the `application.yaml` +There are a few properties that the Form Flow library will look for in the `application.yaml` file. ```yaml @@ -2354,18 +2347,20 @@ form-flow: We are moving towards using a [custom theme](https://codeforamerica.github.io/uswds/dist/) of the [US Web Design System (USWDS)](https://designsystem.digital.gov/). Enabling this -property will set template resolution to use USWDS styling in place of Honeycrisp. The USWDS -template location will become the default for your application, pulling USWDS styles, templates and fragments -in place of Honeycrisp ones. The USWDS file path will be `/resources/cfa-uswds-templates/`. You can +property will set template resolution to use USWDS styling in place of Honeycrisp. The USWDS +template location will become the default for your application, pulling USWDS styles, templates and +fragments +in place of Honeycrisp ones. The USWDS file path will be `/resources/cfa-uswds-templates/`. You can override templates and fragments in this path by placing a file with the same name and path in your -application. For example, placing a file at `/resources/cfa-uswds-templates/fragments/example.html` +application. For example, placing a file at `/resources/cfa-uswds-templates/fragments/example.html` would override the fragment with the same name in the USWDS fragments folder. -You can view all the USWDS templates and fragments in the [USWDS templates folder](https://github.com/codeforamerica/form-flow/tree/main/src/main/resources/cfa-uswds-templates). +You can view all the USWDS templates and fragments in +the [USWDS templates folder](https://github.com/codeforamerica/form-flow/tree/main/src/main/resources/cfa-uswds-templates). -| Property | Default | Description | -|--------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `form-flow.design-system.name` | none | Can use `cfa-uswds` to enable the CfA USWDS design system assets and templates. Otherwise Honeycrisp assets and templates are used. | +| Property | Default | Description | +|--------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------| +| `form-flow.design-system.name` | none | Can use `cfa-uswds` to enable the CfA USWDS design system assets and templates. Otherwise Honeycrisp assets and templates are used. | | #### File upload properties @@ -2424,7 +2419,7 @@ Spring Boot provides a module, called [`spring-boot-starter-actuator`](https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints), that will expose endpoints that will allow you to monitor and interact with your application. -**While these are very powerful, they can also reveal sensitive information about your +**⚠️ While these are very powerful, they can also reveal sensitive information about your application. They are a huge security concern.** It's best to disable them in production and demo environments, or just leave the `health` and @@ -2452,27 +2447,39 @@ actuator endpoints. * [Production-ready Features](https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints) * [Actuator API](https://docs.spring.io/spring-boot/docs/3.1.2/actuator-api/htmlsingle) -### flows-config.yaml file - ### Flow and Subflow Configuration -Flows are defined in a file specified in the `application.yaml` file. The library will look for -the `form-flow.path` property. If that property is not set, the default file it will look for is -named `flows-config.yaml`. +#### flows-config.yaml file + +The `flows-config.yaml` file contains the core flow through screens of the application. +It will contain a list of screens in your application as well as any conditions or actions +relating to them. It will also detail out subflow information as well as landmark pages. + +The system will, by default, look for a file with the name `flows-config.yaml` +in `src/main/resources`. You can have the library load a file with a different name by including +the property `form-flow.path` in the `application.yaml` file. The library will then load the +specified file instead and pull the flow configuration from there. -To configure a flow, create a `flow-config.yaml` in your app at `src/main/resources`. +#### flows-config.yaml basic configuration + +To configure a flow, create a `flows-config.yaml` in your app at `src/main/resources`. You can define multiple flows by [separating them with `---`](https://docs.spring.io/spring-boot/docs/1.2.0.M1/reference/html/boot-features-external-config.html#boot-features-external-config-multi-profile-yaml) . -At it's base a flow as defined in yaml has a name, a flow object, and a collection of screens, their -next screens, any conditions for navigation between those screens, and optionally one or more -subflows. +In the yaml file, every flow described will have: -#### form-flow.yaml basic configuration +* a `name` +* a `flow`, which is essentially the set of screens in the application. Each screen could + have: + * next screens + * [conditions](#conditions) to apply to next screen flow + * [actions](#actions) to apply to data +* a `subflows` section, if any of the screens are part of a subflow +* a [`landmarks`](#landmarks) section -A basic flow configuration could look like this: +A basic form flow configuration file might look like this: ```yaml name: exampleFlow @@ -2481,6 +2488,7 @@ flow: nextScreens: - name: secondScreen secondScreen: + beforeSaveAction: cleanData nextScreens: - name: thirdScreen - name: otherScreen @@ -2493,14 +2501,37 @@ flow: - name: success success: nextScreens: null +landmarks: + firstScreen: firstScreen ___ name: someOtherFlow flow: otherFlowScreen: ``` -You can have autocomplete and validation for flows-config by connecting your IntelliJ to the -flows-config-schema.json [as described here](#connect-flows-config-schema). +You can have IntelliJ do autocomplete and validation for fields in the `flows-config.yaml` file by +configuring IntelliJ to use +the `flows-config-schema.json`, [as described here](#connect-flows-config-schema). + +#### Multiple Flows + +The Form Flow library is able to accommodate multiple form flows in one application. For example, +one could create a signup form in one flow and use a separate flow for collecting documentation from +a user at a later time. +The [Form Flow Starter App](https://github.com/codeforamerica/form-flow-starter-app) has two +separate flows. + +It's not recommended to allow users to work through more than one flow at once. The design of the +Form Flow library is that a user would complete one flow before working with a separate flow. + +However, it is not always possible to keep the flows separate and in cases where users do cross from +one flow to another, we will keep the data for both flows. We do this by creating a +`Submission` for each flow the user has visited. Regardless of whether the user completed +any one flow, the `Submission` for each flow will be stored in the database. + +**⚠️ If your screen flow has users crossing flows, you need to ensure that you provide a reentry +point +to go back to the other flow. Otherwise, the flows may not get completed properly.** ### Screens @@ -2531,7 +2562,7 @@ What do you need to do to create a subflow? (e.g. Ubi.java in the starter app) - Define `screen` templates in `resources/templates/` -Example `flow-config.yaml` with a docs subflow +Example `flows-config.yaml` with a docs subflow ```yaml name: docFlow @@ -2604,7 +2635,8 @@ There are spots in the templates where the `T` operator is used. ### Logging -Form Flow adds the following attributes to the [Mapped Diagnostic Context](https://logback.qos.ch/manual/mdc.html): +Form Flow adds the following attributes to +the [Mapped Diagnostic Context](https://logback.qos.ch/manual/mdc.html): | Attribute | Description | |---------------|----------------------------------------------------------------------------------------------------------------------------| @@ -2614,10 +2646,11 @@ Form Flow adds the following attributes to the [Mapped Diagnostic Context](https | submissionId | The ID of the Submission object - see https://github.com/codeforamerica/form-flow#submission-object | | xForwardedFor | The X-Forwarded-For request header - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For | -These attributes can be displayed in the logs by configuring the log format as described in the above document. For an example that exposes the entire Mapped Diagnostic Context (MDC) in JSON-formatted logs, +These attributes can be displayed in the logs by configuring the log format as described in the +above document. For an example that exposes the entire Mapped Diagnostic Context (MDC) in +JSON-formatted logs, see https://github.com/codeforamerica/form-flow-starter-app/blob/main/src/main/resources/logback-spring.xml. - ### Library Details ### Publishing diff --git a/src/main/java/formflow/library/FileController.java b/src/main/java/formflow/library/FileController.java index 9bd407d4f..07ba12b8e 100644 --- a/src/main/java/formflow/library/FileController.java +++ b/src/main/java/formflow/library/FileController.java @@ -1,5 +1,6 @@ package formflow.library; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.io.Files; import formflow.library.config.FlowConfiguration; import formflow.library.data.Submission; @@ -10,11 +11,11 @@ import formflow.library.file.CloudFileRepository; import formflow.library.file.FileValidationService; import formflow.library.file.FileVirusScanner; +import formflow.library.utils.UserFileMap; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -50,7 +51,7 @@ @EnableAutoConfiguration @Slf4j public class FileController extends FormFlowController { - private final UserFileRepositoryService userFileRepositoryService; + private final CloudFileRepository cloudFileRepository; private final Boolean blockIfClammitUnreachable; private final FileVirusScanner fileVirusScanner; @@ -59,6 +60,8 @@ public class FileController extends FormFlowController { private final String SESSION_USERFILES_KEY = "userFiles"; private final Integer maxFiles; + private final ObjectMapper objectMapper = new ObjectMapper(); + public FileController( UserFileRepositoryService userFileRepositoryService, CloudFileRepository cloudFileRepository, @@ -69,8 +72,7 @@ public FileController( FileValidationService fileValidationService, @Value("${form-flow.uploads.max-files:20}") Integer maxFiles, @Value("${form-flow.uploads.virus-scanning.block-if-unreachable:false}") boolean blockIfClammitUnreachable) { - super(submissionRepositoryService, flowConfigurations); - this.userFileRepositoryService = userFileRepositoryService; + super(submissionRepositoryService, userFileRepositoryService, flowConfigurations); this.cloudFileRepository = cloudFileRepository; this.messageSource = messageSource; this.fileValidationService = fileValidationService; @@ -88,7 +90,7 @@ public FileController( * @param thumbDataUrl The thumbnail URL generated from the upload * @param httpSession The current HTTP session * @return ON SUCCESS: ResponseEntity with a body containing the id of a file. body. - *

ON FAILURE: RepsonseEntity with an error message and a status code.

+ *

ON FAILURE: ResponseEntity with an error message and a status code.

*/ @PostMapping(value = "/file-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.OK) @@ -108,12 +110,12 @@ public ResponseEntity upload( String.format("Could not find flow with name %s in your application's flow configuration.", flow)); } - Submission submission = submissionRepositoryService.findOrCreate(httpSession); + Submission submission = findOrCreateSubmission(httpSession, flow); UUID userFileId = UUID.randomUUID(); if (submission.getId() == null) { submission.setFlow(flow); - saveToRepository(submission); - httpSession.setAttribute("id", submission.getId()); + submission = saveToRepository(submission); + setSubmissionInSession(httpSession, submission, flow); } if (!fileValidationService.isAcceptedMimeType(file)) { @@ -174,36 +176,21 @@ public ResponseEntity upload( .virusScanned(wasScannedForVirus) .build(); - UUID newFileId = userFileRepositoryService.save(uploadedFile); - log.info("Created new file with id: " + newFileId); - - //TODO: change userFiles special string to constant to be referenced in thymeleaf - HashMap>> dzFilesMap; - HashMap> userFileMap; - HashMap fileInfo = UserFile.createFileInfo(uploadedFile, thumbDataUrl); + uploadedFile = userFileRepositoryService.save(uploadedFile); + log.info("Created new file with id: " + uploadedFile.getFileId()); + UserFileMap userFileMap = null; if (httpSession.getAttribute(SESSION_USERFILES_KEY) == null) { - // no dropzone data exists at all yet, let's create space for the session map as well - // as for the current file being uploaded - dzFilesMap = new HashMap<>(); - userFileMap = new HashMap<>(); + userFileMap = new UserFileMap(); } else { - dzFilesMap = (HashMap>>) httpSession.getAttribute(SESSION_USERFILES_KEY); - if (dzFilesMap.containsKey(inputName)) { - userFileMap = dzFilesMap.get(inputName); - // Double check that files in session cookie are in db - userFileMap.entrySet().removeIf(e -> userFileRepositoryService.findById(e.getKey()).isEmpty()); - } else { - // a map for this inputName dropzone instance does not exist yet, let's create it so we can add files to it - userFileMap = new HashMap<>(); - } + userFileMap = objectMapper.readValue((String) httpSession.getAttribute(SESSION_USERFILES_KEY), + UserFileMap.class); } - userFileMap.put(newFileId, fileInfo); - dzFilesMap.put(inputName, userFileMap); - httpSession.setAttribute(SESSION_USERFILES_KEY, dzFilesMap); + userFileMap.addUserFileToMap(flow, inputName, uploadedFile, thumbDataUrl); + httpSession.setAttribute(SESSION_USERFILES_KEY, objectMapper.writeValueAsString(userFileMap)); - return ResponseEntity.status(HttpStatus.OK).contentType(MediaType.TEXT_PLAIN).body(newFileId.toString()); + return ResponseEntity.status(HttpStatus.OK).contentType(MediaType.TEXT_PLAIN).body(uploadedFile.getFileId().toString()); } catch (Exception e) { if (e instanceof ResponseStatusException) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); @@ -218,55 +205,59 @@ public ResponseEntity upload( * @param fileId The id of an uploaded file that should be deleted * @param returnPath The path to the page that they came from * @param dropZoneInstanceName The drop zone instance used to get the user file name + * @param flow The name of the current (active) flow * @param httpSession The current HTTP session * @return ON SUCCESS: Returns a RedirectView to the returnPath *

ON FAILURE: Returns a RedirectView to the 'error' page

*/ @PostMapping("/file-delete") - RedirectView delete( + public RedirectView delete( @RequestParam("id") UUID fileId, @RequestParam("returnPath") String returnPath, @RequestParam("inputName") String dropZoneInstanceName, + @RequestParam("flow") String flow, HttpSession httpSession, HttpServletRequest request ) { try { log.info("POST delete (url: {}): fileId: {} inputName: {}", request.getRequestURI().toLowerCase(), fileId, dropZoneInstanceName); - UUID submissionId = (UUID) httpSession.getAttribute("id"); - Optional maybeSubmission = submissionRepositoryService.findById(submissionId); - if (maybeSubmission.isEmpty()) { - log.error(String.format("Submission %s does not exist", submissionId.toString())); + Submission submission = getSubmissionFromSession(httpSession, flow); + if (submission == null) { + log.error("Submission does not exist for file '{}', not deleting file", fileId); return new RedirectView("/error"); } Optional maybeFile = userFileRepositoryService.findById(fileId); if (maybeFile.isEmpty()) { - log.error(String.format("File with id %s may have already been deleted", fileId)); + log.error("File with id '{}' not found. It may have already been deleted?", fileId); return new RedirectView("/error"); } UserFile file = maybeFile.get(); - if (!submissionId.equals(file.getSubmission().getId())) { - log.error(String.format("Submission %s does not match file %s's submission id %s", submissionId, fileId, - file.getSubmission().getId())); + if (!submission.getId().equals(file.getSubmission().getId())) { + log.error( + String.format( + "Submission %s does not match file %s's submission id %s", + submission.getId(), + fileId, + file.getSubmission().getId())); return new RedirectView("/error"); } log.info("Delete file {} from cloud storage", fileId); cloudFileRepository.delete(file.getRepositoryPath()); userFileRepositoryService.deleteById(file.getFileId()); - HashMap>> dzFilesMap = - (HashMap>>) httpSession.getAttribute(SESSION_USERFILES_KEY); - HashMap> userFileMap = dzFilesMap.get(dropZoneInstanceName); - userFileMap.remove(fileId); - if (userFileMap.isEmpty()) { - dzFilesMap.remove(dropZoneInstanceName); + UserFileMap userFileMap = objectMapper.readValue((String) httpSession.getAttribute(SESSION_USERFILES_KEY), + UserFileMap.class); + if (userFileMap == null) { + log.error("User file map not set in session. Unable to update file information"); + throw new IndexOutOfBoundsException("Session does not contain user file mapping."); } - - httpSession.setAttribute(SESSION_USERFILES_KEY, dzFilesMap); + userFileMap.removeUserFileFromMap(flow, fileId); + httpSession.setAttribute(SESSION_USERFILES_KEY, objectMapper.writeValueAsString(userFileMap)); return new RedirectView(returnPath); } catch (Exception e) { @@ -276,25 +267,37 @@ RedirectView delete( } /** - * @param httpSession The current HTTP session * @param submissionId The submissionId of the file to be downloaded * @param fileId The UUID of the file to be downloaded. + * @param flow The name of the current (active) flow + * @param httpSession The current HTTP session + * @param request The HttpServletRequest * @return ON SUCCESS: ResponseEntity with a response body that includes the file. *

ON FAILURE: A ResponseEntity returns an HTTP error code

*/ - @GetMapping("/file-download/{submissionId}/{fileId}") + @GetMapping("/file-download/{flow}/{submissionId}/{fileId}") public ResponseEntity downloadSingleFile( - HttpSession httpSession, @PathVariable String submissionId, @PathVariable String fileId, + @PathVariable String flow, + HttpSession httpSession, HttpServletRequest request ) { log.info("GET downloadSingleFile (url: {}): submissionId: {} fileId {}", request.getRequestURI().toLowerCase(), submissionId, fileId); - if (!submissionId.equals(httpSession.getAttribute("id").toString())) { + + if (!submissionId.equals(getSubmissionIdForFlow(httpSession, flow).toString())) { + log.error("There was an attempt to download a file with submission ID '{}', " + + "which does not match the submission of the file being downloaded.", submissionId); return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } + Submission submission = getSubmissionFromSession(httpSession, flow); + if (submission == null) { + log.error("Submission does not exist for that file"); + return ResponseEntity.notFound().build(); + } + Optional maybeFile = userFileRepositoryService.findById(UUID.fromString(fileId)); if (maybeFile.isEmpty()) { log.error(String.format("Could not find the file with id: %s.", fileId)); @@ -302,8 +305,7 @@ public ResponseEntity downloadSingleFile( } UserFile file = maybeFile.get(); - - if (!httpSession.getAttribute("id").toString().equals(file.getSubmission().getId().toString())) { + if (!submissionId.equals(file.getSubmission().getId().toString())) { log.error(String.format("Attempt to download file with submission ID %s but session ID %s does not match", file.getSubmission().getId(), httpSession.getAttribute("id"))); return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); @@ -333,31 +335,37 @@ public ResponseEntity downloadSingleFile( } /** - * @param httpSession The current HTTP session. * @param submissionId The submissionId of the all the files that you would like to download. + * @param httpSession The current HTTP session. + * @param flow The name of the current (active) flow + * @param request The HttpServletRequest * @return ON SUCCESS: ResponseEntity with a zip file containing all the files in a submission. *

ON FAILURE: ResponseEntity with a HTTP error message

*/ - @GetMapping("/file-download/{submissionId}") - ResponseEntity downloadAllFiles( - HttpSession httpSession, + @GetMapping("/file-download/{flow}/{submissionId}") + public ResponseEntity downloadAllFiles( @PathVariable String submissionId, + @PathVariable String flow, + HttpSession httpSession, HttpServletRequest request ) { log.info("GET downloadAllFiles (url: {}): submissionId: {}", request.getRequestURI().toLowerCase(), submissionId); - if (!httpSession.getAttribute("id").toString().equals(submissionId)) { + + // first check to see if the ID in the session for the current flow is equal to the + // requested submissionId in the URL path + if (!submissionId.equals(getSubmissionIdForFlow(httpSession, flow).toString())) { log.error( "Attempted to download files belonging to submission " + submissionId + " but session id " + httpSession.getAttribute( "id") + " does not match."); return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - Optional maybeSubmission = submissionRepositoryService.findById(UUID.fromString(submissionId)); - if (maybeSubmission.isEmpty()) { + // now check to see if the submission itself exists + Submission submission = getSubmissionFromSession(httpSession, flow); + if (submission == null) { log.error(String.format("The Submission %s was not found.", submissionId)); return ResponseEntity.notFound().build(); } - Submission submission = maybeSubmission.get(); List userFiles = userFileRepositoryService.findAllBySubmission(submission); diff --git a/src/main/java/formflow/library/FormFlowController.java b/src/main/java/formflow/library/FormFlowController.java index a9f27def4..7fc311196 100644 --- a/src/main/java/formflow/library/FormFlowController.java +++ b/src/main/java/formflow/library/FormFlowController.java @@ -3,41 +3,56 @@ import formflow.library.config.FlowConfiguration; import formflow.library.data.Submission; import formflow.library.data.SubmissionRepositoryService; +import formflow.library.data.UserFileRepositoryService; +import jakarta.servlet.http.HttpSession; import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.Map; +import java.util.HashMap; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +@Slf4j public abstract class FormFlowController { protected final SubmissionRepositoryService submissionRepositoryService; + protected final UserFileRepositoryService userFileRepositoryService; + protected final List flowConfigurations; - FormFlowController(SubmissionRepositoryService submissionRepositoryService, List flowConfigurations) { + public static final String SUBMISSION_MAP_NAME = "submissionMap"; + + FormFlowController(SubmissionRepositoryService submissionRepositoryService, UserFileRepositoryService userFileRepositoryService, + List flowConfigurations) { this.submissionRepositoryService = submissionRepositoryService; + this.userFileRepositoryService = userFileRepositoryService; this.flowConfigurations = flowConfigurations; } - protected void saveToRepository(Submission submission) { - submissionRepositoryService.removeFlowCSRF(submission); - submissionRepositoryService.save(submission); + protected Submission saveToRepository(Submission submission) { + return saveToRepository(submission, null); } - protected void saveToRepository(Submission submission, String subflowName) { + protected Submission saveToRepository(Submission submission, String subflowName) { submissionRepositoryService.removeFlowCSRF(submission); - submissionRepositoryService.removeSubflowCSRF(submission, subflowName); - submissionRepositoryService.save(submission); + if (subflowName != null && !subflowName.isBlank()) { + submissionRepositoryService.removeSubflowCSRF(submission, subflowName); + } + return submissionRepositoryService.save(submission); } protected FlowConfiguration getFlowConfigurationByName(String flow) { - List flowConfigurationList = flowConfigurations.stream().filter( - flowConfiguration -> flowConfiguration.getName().equals(flow)).toList(); - - if (flowConfigurationList.isEmpty()) { - throwNotFoundError(flow, null, String.format("Could not find flow %s in your applications flow configuration file.", flow)); - } - - return flowConfigurationList.get(0); + List flowConfigurationList = flowConfigurations.stream().filter( + flowConfiguration -> flowConfiguration.getName().equals(flow)).toList(); + + if (flowConfigurationList.isEmpty()) { + throwNotFoundError(flow, null, String.format("Could not find flow %s in your applications flow configuration file.", flow)); + } + + return flowConfigurationList.get(0); } protected Boolean doesFlowExist(String flow) { @@ -46,9 +61,110 @@ protected Boolean doesFlowExist(String flow) { ); } - protected static void throwNotFoundError(String flow,String screen, String message) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, String.format("There was a problem with the request (flow: %s, screen: %s): %s", - flow, screen, message)); + protected static void throwNotFoundError(String flow, String screen, String message) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + String.format("There was a problem with the request (flow: %s, screen: %s): %s", + flow, screen, message)); + + } + + /** + * If the submission information exists in the HttpSession, find it in the db. If a submission is not found, create a new one. + * + * @param httpSession The HttpSession to look in for information + * @param flow The name of the flow to retrieve data about + * @return Submission A Submission object from the database or a new one if one was not found + */ + public Submission findOrCreateSubmission(HttpSession httpSession, String flow) { + Submission submission = null; + try { + submission = getSubmissionFromSession(httpSession, flow); + } catch (ResponseStatusException ignored) { + // it's okay if it doesn't exist already + } + + if (submission == null) { + log.info("Submission not found in session for flow '{}', creating one.", flow); + submission = new Submission(); + } + return submission; + } + + /** + * Returns the UUID of the Submission associated with the given flow. + * + * @param session The HttpSession the user is in + * @param flow The flow to look up the submission ID for + * @return The submission id if it exists for the given flow, else null + */ + public static UUID getSubmissionIdForFlow(HttpSession session, String flow) { + if (session == null) { + throwNotFoundError(flow, null, String.format("Session is null, unable to retrieve submission id for flow '%s'.", flow)); + } + + Map submissionMap = (Map) session.getAttribute(SUBMISSION_MAP_NAME); + if (submissionMap == null) { + throwNotFoundError(flow, null, String.format("There was no submission map present in the session for flow '%s'.", flow)); + } + + return submissionMap.get(flow); + } + + /** + * This method will return a Submission that was referenced the HttpSession for a particular flow, if one exists. If the session + * or now Submission exists, a null will be returned. + * + * @param session the HttpSession data will be looked for in + * @param flow the current flow to retrieve the Submission for + * @return Submission for the flow, if one exists, else null + */ + protected Submission getSubmissionFromSession(HttpSession session, String flow) { + if (session == null) { + throwNotFoundError(flow, null, String.format("Session is null, unable to retrieve submission for flow '%s'.", flow)); + } + + Map submissionMap = (Map) session.getAttribute(SUBMISSION_MAP_NAME); + if (submissionMap == null) { + throwNotFoundError(flow, null, String.format("There was no submission map present in the session for flow '%s'.", flow)); + } + + UUID id = submissionMap.get(flow); + if (id != null) { + Optional maybeSubmission = submissionRepositoryService.findById(id); + if (maybeSubmission.isPresent()) { + return maybeSubmission.get(); + } + throwNotFoundError(flow, null, String.format("No submission was found in the database with id '%s'.", id)); + } + + throwNotFoundError(flow, null, String.format("No ID was present in the session map for flow '%s'", flow)); + return null; + } + + /* + * A method that will store the Submission ID, based on flow, in the HttpSession provided. + * + * @param session The HttpSession to store information in + * @param submission The Submission whose information will be stored + * @param flow A string containing the name of the flow to store the Submission data for + */ + protected void setSubmissionInSession(HttpSession session, Submission submission, String flow) { + if (session == null) { + log.error( + "Unable to put the submission ID ('{}') into the session for the flow '{}'. Session is null.", + submission != null ? submission.getId() : null, + flow); + return; + } + + Map submissionMap = (Map) session.getAttribute(SUBMISSION_MAP_NAME); + UUID id = submission != null ? submission.getId() : null; + + if (submissionMap == null) { + submissionMap = new HashMap<>(); + } + submissionMap.put(flow, id); + session.setAttribute(SUBMISSION_MAP_NAME, submissionMap); } } diff --git a/src/main/java/formflow/library/PdfController.java b/src/main/java/formflow/library/PdfController.java index b100842fb..41a299bbb 100644 --- a/src/main/java/formflow/library/PdfController.java +++ b/src/main/java/formflow/library/PdfController.java @@ -3,6 +3,7 @@ import formflow.library.config.FlowConfiguration; import formflow.library.data.Submission; import formflow.library.data.SubmissionRepositoryService; +import formflow.library.data.UserFileRepositoryService; import formflow.library.pdf.PdfService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -34,8 +35,9 @@ public class PdfController extends FormFlowController { public PdfController(MessageSource messageSource, PdfService pdfService, SubmissionRepositoryService submissionRepositoryService, + UserFileRepositoryService userFileRepositoryService, List flowConfigurations) { - super(submissionRepositoryService, flowConfigurations); + super(submissionRepositoryService, userFileRepositoryService, flowConfigurations); this.messageSource = messageSource; this.pdfService = pdfService; } @@ -54,7 +56,7 @@ ResponseEntity downloadPdf( } Optional maybeSubmission = submissionRepositoryService.findById(UUID.fromString(submissionId)); - if (httpSession.getAttribute("id").toString().equals(submissionId) && maybeSubmission.isPresent()) { + if (getSubmissionIdForFlow(httpSession, flow).toString().equals(submissionId) && maybeSubmission.isPresent()) { log.info("Downloading PDF with submission_id: " + submissionId); Submission submission = maybeSubmission.get(); HttpHeaders headers = new HttpHeaders(); diff --git a/src/main/java/formflow/library/ScreenController.java b/src/main/java/formflow/library/ScreenController.java index 3c036e229..96b9117bc 100644 --- a/src/main/java/formflow/library/ScreenController.java +++ b/src/main/java/formflow/library/ScreenController.java @@ -12,6 +12,7 @@ import formflow.library.data.FormSubmission; import formflow.library.data.Submission; import formflow.library.data.SubmissionRepositoryService; +import formflow.library.data.UserFileRepositoryService; import formflow.library.file.FileValidationService; import formflow.library.inputs.UnvalidatedField; import jakarta.servlet.http.HttpServletRequest; @@ -58,13 +59,14 @@ public class ScreenController extends FormFlowController { public ScreenController( List flowConfigurations, + UserFileRepositoryService userFileRepositoryService, SubmissionRepositoryService submissionRepositoryService, ValidationService validationService, AddressValidationService addressValidationService, ConditionManager conditionManager, ActionManager actionManager, FileValidationService fileValidationService) { - super(submissionRepositoryService, flowConfigurations); + super(submissionRepositoryService, userFileRepositoryService, flowConfigurations); this.validationService = validationService; this.addressValidationService = addressValidationService; this.conditionManager = conditionManager; @@ -94,8 +96,8 @@ ModelAndView getScreen( log.info("GET getScreen (url: {}): flow: {}, screen: {}", request.getRequestURI().toLowerCase(), flow, screen); // this will ensure that the screen and flow actually exist ScreenNavigationConfiguration currentScreen = getScreenConfig(flow, screen); + Submission submission = findOrCreateSubmission(httpSession, flow); - Submission submission = submissionRepositoryService.findOrCreate(httpSession); if ((submission.getUrlParams() != null) && (!submission.getUrlParams().isEmpty())) { submission.mergeUrlParamsWithData(query_params); } else { @@ -103,8 +105,8 @@ ModelAndView getScreen( } submission.setFlow(flow); - saveToRepository(submission); - httpSession.setAttribute("id", submission.getId()); + submission = saveToRepository(submission); + setSubmissionInSession(httpSession, submission, flow); if (uuid != null) { actionManager.handleBeforeDisplayAction(currentScreen, submission, uuid); @@ -153,8 +155,7 @@ ModelAndView postScreen( log.info("POST postScreen (url: {}): flow: {}, screen: {}", request.getRequestURI().toLowerCase(), flow, screen); // Checks if screen and flow exist var currentScreen = getScreenConfig(flow, screen); - - Submission submission = submissionRepositoryService.findOrCreate(httpSession); + Submission submission = findOrCreateSubmission(httpSession, flow); FormSubmission formSubmission = new FormSubmission(formData); actionManager.handleOnPostAction(currentScreen, formSubmission, submission); @@ -189,8 +190,8 @@ ModelAndView postScreen( } actionManager.handleBeforeSaveAction(currentScreen, submission); - saveToRepository(submission); - httpSession.setAttribute("id", submission.getId()); + submission = saveToRepository(submission); + setSubmissionInSession(httpSession, submission, flow); actionManager.handleAfterSaveAction(currentScreen, submission); return new ModelAndView(String.format("redirect:/flow/%s/%s/navigation", flow, screen)); @@ -221,15 +222,14 @@ ModelAndView getSubflowScreen( uuid); // Checks if screen and flow exist var currentScreen = getScreenConfig(flow, screen); - Optional maybeSubmission = submissionRepositoryService.findById((UUID) httpSession.getAttribute("id")); + Submission submission = getSubmissionFromSession(httpSession, flow); - if (maybeSubmission.isEmpty()) { + if (submission == null) { // we have issues! We should not get here, really. log.error("There is no submission associated with request!"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } - Submission submission = maybeSubmission.get(); actionManager.handleBeforeDisplayAction(currentScreen, submission, uuid); Map model = createModel(flow, screen, httpSession, submission, uuid); model.put("formAction", String.format("/flow/%s/%s/%s", flow, screen, uuid)); @@ -274,8 +274,8 @@ ModelAndView updateOrCreateIteration( boolean isNewIteration = uuid.equalsIgnoreCase("new"); String iterationUuid = isNewIteration ? UUID.randomUUID().toString() : uuid; FormSubmission formSubmission = new FormSubmission(formData); - Submission submission = submissionRepositoryService.findOrCreate(httpSession); String subflowName = currentScreen.getSubflow(); + Submission submission = findOrCreateSubmission(httpSession, flow); actionManager.handleOnPostAction(currentScreen, formSubmission, submission, iterationUuid); @@ -301,7 +301,8 @@ ModelAndView updateOrCreateIteration( handleAddressValidation(submission, formSubmission); - if (httpSession.getAttribute("id") != null) { + if (submission.getId() != null) { + // if we are not working with a new submission, make sure to update any existing data // have we submitted any data to the subflow yet? if (!submission.getInputData().containsKey(subflowName)) { submission.getInputData().put(subflowName, new ArrayList>()); @@ -350,8 +351,8 @@ ModelAndView updateOrCreateIteration( } actionManager.handleBeforeSaveAction(currentScreen, submission, iterationUuid); - saveToRepository(submission, subflowName); - httpSession.setAttribute("id", submission.getId()); + submission = saveToRepository(submission, subflowName); + setSubmissionInSession(httpSession, submission, flow); actionManager.handleAfterSaveAction(currentScreen, submission, iterationUuid); String nextScreen = getNextScreenName(submission, currentScreen, iterationUuid); String viewString = isNextScreenInSubflow(flow, submission, currentScreen, iterationUuid) ? @@ -387,11 +388,9 @@ ModelAndView deleteConfirmation( // Checks to see if flow exists String deleteConfirmationScreen = getFlowConfigurationByName(flow) .getSubflows().get(subflow).getDeleteConfirmationScreen(); - UUID id = (UUID) httpSession.getAttribute("id"); - Optional submissionOptional = submissionRepositoryService.findById(id); + Submission submission = getSubmissionFromSession(httpSession, flow); - if (submissionOptional.isPresent()) { - Submission submission = submissionOptional.get(); + if (submission != null) { var existingInputData = submission.getInputData(); var subflowArr = (ArrayList>) existingInputData.get(subflow); var entryToDelete = subflowArr.stream().filter(entry -> entry.get("uuid").equals(uuid)).findFirst(); @@ -427,32 +426,31 @@ ModelAndView deleteSubflowIteration( // Checks to make sure flow exists String subflowEntryScreen = getFlowConfigurationByName(flow).getSubflows().get(subflow) .getEntryScreen(); - UUID id = (UUID) httpSession.getAttribute("id"); - Optional submissionOptional = submissionRepositoryService.findById(id); - if (submissionOptional.isPresent()) { - Submission submission = submissionOptional.get(); - var existingInputData = submission.getInputData(); - if (existingInputData.containsKey(subflow)) { - var subflowArr = (ArrayList>) existingInputData.get(subflow); - Optional> entryToDelete = subflowArr.stream() - .filter(entry -> entry.get("uuid").equals(uuid)).findFirst(); - entryToDelete.ifPresent(subflowArr::remove); - if (!subflowArr.isEmpty()) { - existingInputData.put(subflow, subflowArr); - submission.setInputData(existingInputData); - saveToRepository(submission, subflow); - } else { - existingInputData.remove(subflow); - submission.setInputData(existingInputData); - saveToRepository(submission, subflow); - return new ModelAndView("redirect:/flow/%s/%s".formatted(flow, subflowEntryScreen)); - } + Submission submission = getSubmissionFromSession(httpSession, flow); + if (submission == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + var existingInputData = submission.getInputData(); + if (existingInputData.containsKey(subflow)) { + var subflowArr = (ArrayList>) existingInputData.get(subflow); + Optional> entryToDelete = subflowArr.stream() + .filter(entry -> entry.get("uuid").equals(uuid)).findFirst(); + entryToDelete.ifPresent(subflowArr::remove); + if (!subflowArr.isEmpty()) { + existingInputData.put(subflow, subflowArr); + submission.setInputData(existingInputData); + submission = saveToRepository(submission, subflow); } else { + existingInputData.remove(subflow); + submission.setInputData(existingInputData); + submission = saveToRepository(submission, subflow); return new ModelAndView("redirect:/flow/%s/%s".formatted(flow, subflowEntryScreen)); } } else { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + return new ModelAndView("redirect:/flow/%s/%s".formatted(flow, subflowEntryScreen)); } + String reviewScreen = getFlowConfigurationByName(flow).getSubflows().get(subflow) .getReviewScreen(); return new ModelAndView(String.format("redirect:/flow/%s/" + reviewScreen, flow)); @@ -474,10 +472,14 @@ ModelAndView navigation( HttpServletRequest request ) { log.info("GET navigation (url: {}): flow: {}, screen: {}", request.getRequestURI().toLowerCase(), flow, screen); - log.info("Current submission ID is :" + httpSession.getAttribute("id") + " and current Session ID is :" + httpSession.getId()); // Checks if the screen and flow exist var currentScreen = getScreenConfig(flow, screen); - String nextScreen = getNextScreenName(submissionRepositoryService.findOrCreate(httpSession), currentScreen, null); + Submission submission = getSubmissionFromSession(httpSession, flow); + if (submission == null) { + throwNotFoundError(flow, screen, + String.format("Submission not found in session for flow '{}', when navigating to '{}'", flow, screen)); + } + String nextScreen = getNextScreenName(submission, currentScreen, null); log.info("navigation: flow: " + flow + ", nextScreen: " + nextScreen); return new ModelAndView(new RedirectView("/flow/%s/%s".formatted(flow, nextScreen))); @@ -624,6 +626,7 @@ private Map createModel(String flow, String screen, HttpSession model.put("inputData", submission.getInputData()); model.put("errorMessages", httpSession.getAttribute("errorMessages")); model.put("fieldData", submission.getInputData()); + model.put("userFiles", userFileRepositoryService.findAllBySubmission(submission)); if (subflowName != null) { if (uuid != null && !uuid.isBlank()) { model.put("fieldData", submission.getSubflowEntryByUuid(subflowName, uuid)); diff --git a/src/main/java/formflow/library/data/SubmissionRepositoryService.java b/src/main/java/formflow/library/data/SubmissionRepositoryService.java index 09471b15d..8e35a31d8 100644 --- a/src/main/java/formflow/library/data/SubmissionRepositoryService.java +++ b/src/main/java/formflow/library/data/SubmissionRepositoryService.java @@ -1,6 +1,5 @@ package formflow.library.data; -import jakarta.servlet.http.HttpSession; import java.util.ArrayList; import java.util.Map; import java.util.Optional; @@ -33,14 +32,14 @@ public SubmissionRepositoryService(SubmissionRepository repository, SubmissionEn * @param submission the submission to save, not null * @return UUID of the saved submission */ - public UUID save(Submission submission) { + public Submission save(Submission submission) { var newRecord = submission.getId() == null; - UUID id = repository.save(encryptionService.encrypt(submission)).getId(); + Submission savedSubmission = repository.save(encryptionService.encrypt(submission)); if (newRecord) { - log.info("created submission id: " + id); + log.info("created submission id: " + savedSubmission.getId()); } - submission.setId(id); - return id; + // straight from the db will be encrypted, so decrypt first. + return encryptionService.decrypt(savedSubmission); } /** @@ -81,29 +80,4 @@ public void removeSubflowCSRF(Submission submission, String subflowName) { } } } - - /** - * If the submission exists in the session, find it in the db. If not or can't be found, create a new one. - * - * @param httpSession submission - * @return Submission - */ - public Submission findOrCreate(HttpSession httpSession) { - var id = (UUID) httpSession.getAttribute("id"); - if (id != null) { - Optional submissionOptional = findById(id); - if (submissionOptional.isEmpty()) { - log.error("findOrCreate could not find submission: " + id); - Submission newSubmission = new Submission(); - log.info("findOrCreate created new submission: " + newSubmission.getId()); - return newSubmission; - } else { - return submissionOptional.get(); - } - } else { - Submission newSubmission = new Submission(); - log.info("findOrCreate got no submission id from session, so created new submission: " + newSubmission.getId()); - return newSubmission; - } - } } diff --git a/src/main/java/formflow/library/data/UserFileRepositoryService.java b/src/main/java/formflow/library/data/UserFileRepositoryService.java index 55add6beb..61cc6bc16 100644 --- a/src/main/java/formflow/library/data/UserFileRepositoryService.java +++ b/src/main/java/formflow/library/data/UserFileRepositoryService.java @@ -27,8 +27,8 @@ public UserFileRepositoryService(UserFileRepository repository) { * @param userFile the uploadedFile to save, not null * @return UUID of the file */ - public UUID save(UserFile userFile) { - return repository.save(userFile).getFileId(); + public UserFile save(UserFile userFile) { + return repository.save(userFile); } /** diff --git a/src/main/java/formflow/library/interceptors/SessionContinuityInterceptor.java b/src/main/java/formflow/library/interceptors/SessionContinuityInterceptor.java index 36cfa24cb..a06d12d1c 100644 --- a/src/main/java/formflow/library/interceptors/SessionContinuityInterceptor.java +++ b/src/main/java/formflow/library/interceptors/SessionContinuityInterceptor.java @@ -1,5 +1,6 @@ package formflow.library.interceptors; +import formflow.library.FormFlowController; import formflow.library.config.FlowConfiguration; import formflow.library.exceptions.LandmarkNotSetException; import jakarta.servlet.http.HttpServletRequest; @@ -41,7 +42,8 @@ public SessionContinuityInterceptor(List flowConfigurations) * @param handler chosen handler to execute, for type and/or instance evaluation * @return Boolean True - allows the request to proceed to the ScreenController, False - stops the request from reaching the * Screen Controller. - * @throws IOException - thrown in the event that an input or output exception occurs when this method does a redirect. + * @throws IOException - thrown in the event that an input or output exception occurs when this method does a + * redirect. * @throws LandmarkNotSetException - thrown in the event that a landmark(s) screen is misconfigured */ @Override @@ -91,7 +93,8 @@ public boolean preHandle(HttpServletRequest request, @NotNull HttpServletRespons return false; } - if (session.getAttribute("id") == null) { + if (FormFlowController.getSubmissionIdForFlow(session, parsedUrl.get("flow")) == null && + !parsedUrl.get("screen").equals(firstScreen)) { log.error("A submission ID was not found in the session for request to {}. Redirecting to landing page.", request.getRequestURI()); response.sendRedirect(REDIRECT_URL); diff --git a/src/main/java/formflow/library/utils/UserFileMap.java b/src/main/java/formflow/library/utils/UserFileMap.java new file mode 100644 index 000000000..05c2bac02 --- /dev/null +++ b/src/main/java/formflow/library/utils/UserFileMap.java @@ -0,0 +1,64 @@ +package formflow.library.utils; + +import formflow.library.data.UserFile; +import java.util.Map; +import java.util.HashMap; +import java.util.UUID; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * A class to contain the file mapping for the client side rendering of DropZone files. + *

+ * Warning: This class will be serialized and sent to the client side. Do not include sensitive information in here that would + * then be shared with the client. Only include information that can be shared. + *

+ */ +@Slf4j +@Getter +public class UserFileMap { + + // flow -> inputName -> fileId -> file info + private Map>>> userFileMap; + + public UserFileMap() { + userFileMap = new HashMap<>(); + } + + public void addUserFileToMap(String flow, String inputName, UserFile userFile, String thumbDataUrl) { + Map fileInfo = UserFile.createFileInfo(userFile, thumbDataUrl); + + if (!userFileMap.containsKey(flow)) { + userFileMap.put(flow, new HashMap<>()); + } + + if (!userFileMap.get(flow).containsKey(inputName)) { + userFileMap.get(flow).put(inputName, new HashMap<>()); + } + + userFileMap.get(flow).get(inputName).put(userFile.getFileId(), fileInfo); + } + + public void removeUserFileFromMap(String flow, UUID fileId) { + + if (userFileMap.get(flow) == null) { + log.warn("Unable to remove fileId '{}' from flow '{}'. Flow does not exist", + fileId.toString(), flow); + throw new IndexOutOfBoundsException( + String.format("Flow '%s' does not exist", flow) + ); + } + + log.debug("Removing fileId '{}' from user file list (flow '{}'", fileId, flow); + userFileMap.get(flow).forEach((inputField, files) -> { + files.entrySet().removeIf(e -> e.getKey().equals(fileId)); + }); + + // clean up a few things, if that was the last file listed under an inputName or flow name + userFileMap.get(flow).entrySet().removeIf(e -> e.getValue().isEmpty()); + + if (userFileMap.get(flow).isEmpty()) { + userFileMap.remove(flow); + } + } +} diff --git a/src/main/resources/templates/fragments/fileUploader.html b/src/main/resources/templates/fragments/fileUploader.html index 5b3fce361..d197eea90 100644 --- a/src/main/resources/templates/fragments/fileUploader.html +++ b/src/main/resources/templates/fragments/fileUploader.html @@ -61,7 +61,7 @@ window['myDropZone' + [[${inputName}]]] = null; window['userFileIds' + [[${inputName}]]] = []; window['cancelledFiles' + [[${inputName}]]] = []; - var userFiles = [[${session.userFiles}]] + var userFiles = [[${session.userFiles}]] != null ? JSON.parse([[${session.userFiles}]]) : null; var thumbnailWidthFromAppYml = [[${@environment.getProperty('form-flow.uploads.thumbnail-width')}]]; var thumbnailHeightFromAppYml = [[${@environment.getProperty('form-flow.uploads.thumbnail-height')}]]; var thumbnailWidth = thumbnailWidthFromAppYml ? thumbnailWidthFromAppYml : '64'; @@ -214,11 +214,11 @@ }); } - function sendDeleteXhrRequest(id) { + function sendDeleteXhrRequest(id, flow) { var xhrRequest = new XMLHttpRequest(); xhrRequest.open('POST', '/file-delete?' + id + '&returnPath=' + window.location.pathname + '&inputName=' - + [[${inputName}]], true); + + [[${inputName}]] + '&flow=' + flow, true); xhrRequest.withCredentials = false; xhrRequest.setRequestHeader("Accept", "*/*"); xhrRequest.setRequestHeader("X-Requested-With", "XMLHttpRequest"); @@ -271,7 +271,8 @@ init: function () { window[dropzonePrefix + [[${inputName}]]] = this; var dzInstance = [[${inputName}]] - var documents = userFiles !== null ? userFiles[dzInstance] : []; + var flow = [[${flow}]] + var documents = userFiles?.userFileMap?.[flow]?.[dzInstance] || []; if (documents != null && Object.entries(documents).length > this.options.maxFiles) { toggleMaxFileMessage('on'); @@ -343,7 +344,6 @@ }); this.on('maxfilesexceeded', function () { - console.log("maxfilesexceeded was emitted"); toggleMaxFileMessage('on'); }); @@ -360,7 +360,7 @@ [[#{general.files.confirm-delete}]] + fileName + '. ' + [[#{general.files.confirm-delete-ok}]]) if (confirmation) { - sendDeleteXhrRequest(id); + sendDeleteXhrRequest(id, [[${flow}]]); removeFileFromDropzone(file, id); } } diff --git a/src/test/java/formflow/library/controllers/FileControllerTest.java b/src/test/java/formflow/library/controllers/FileControllerTest.java index c8b223021..719c8673b 100644 --- a/src/test/java/formflow/library/controllers/FileControllerTest.java +++ b/src/test/java/formflow/library/controllers/FileControllerTest.java @@ -11,6 +11,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import formflow.library.FileController; import formflow.library.data.Submission; import formflow.library.data.SubmissionRepositoryService; @@ -20,13 +22,13 @@ import formflow.library.file.CloudFile; import formflow.library.file.CloudFileRepository; import formflow.library.utilities.AbstractMockMvcTest; +import formflow.library.utils.UserFileMap; import java.nio.file.Files; import java.nio.file.Paths; import java.sql.Date; import java.time.Instant; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -82,8 +84,18 @@ public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); mockMvc = MockMvcBuilders.standaloneSetup(fileController).build(); submission = Submission.builder().id(submissionUUID).build(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); + when(clammitVirusScanner.virusDetected(any())).thenReturn(false); + when(submissionRepositoryService.save(any())).thenReturn(submission); + // Set the file ID on the UserFile since Mockito won't actually set one (it just returns what we tell it to) + // It does not call the actual save method which is what sets the ID + when(userFileRepositoryService.save(any())).thenAnswer(invocation -> { + UserFile userFile = invocation.getArgument(0); + userFile.setFileId(fileId); + return userFile; + }); + + setFlowInfoInSession(session, "testFlow", submission.getId()); super.setUp(); } @@ -103,12 +115,11 @@ void shouldReturn404IfFlowDoesNotExist() throws Exception { @Test public void fileUploadEndpointHitsCloudFileRepositoryAndAddsUserFileToSession() throws Exception { - when(userFileRepositoryService.save(any())).thenReturn(fileId); + when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); doNothing().when(cloudFileRepository).upload(any(), any()); // the "name" param has to match what the endpoint expects: "file" MockMultipartFile testImage = new MockMultipartFile("file", "someImage.jpg", MediaType.IMAGE_JPEG_VALUE, "test".getBytes()); - session = new MockHttpSession(); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-upload") .file(testImage) @@ -121,25 +132,20 @@ public void fileUploadEndpointHitsCloudFileRepositoryAndAddsUserFileToSession() .andExpect(content().string(fileId.toString())); verify(cloudFileRepository, times(1)).upload(any(), any()); - UserFile testUserFile = new UserFile( - fileId, - new Submission(), - Date.from(Instant.now()), - "coolFile.jpg", - "pathToS3", - ".pdf", - Float.valueOf("10"), - false - ); - HashMap>> testDzInstanceMap = new HashMap<>(); - HashMap> userFiles = new HashMap<>(); - userFiles.put(1L, UserFile.createFileInfo(testUserFile, "thumbnail")); - testDzInstanceMap.put("dropZoneTestInstance", userFiles); - session = new MockHttpSession(); - session.setAttribute("id", fileId); - session.setAttribute("userFiles", testDzInstanceMap); - - assertThat(session.getAttribute("userFiles")).isEqualTo(testDzInstanceMap); + + ObjectMapper objectMapper = new ObjectMapper(); + UserFileMap userFileMap = objectMapper.readValue(session.getAttribute("userFiles").toString(), UserFileMap.class); + + // get the DZ Instance Map from the session and make sure the file info looks okay + assertThat(userFileMap.getUserFileMap().size()).isEqualTo(1); + assertThat(userFileMap.getUserFileMap().get("testFlow").size()).isEqualTo(1); + + UUID theNewFileId = (UUID) userFileMap.getUserFileMap().get("testFlow").get("dropZoneTestInstance").keySet().toArray()[0]; + Map fileData = userFileMap.getUserFileMap().get("testFlow").get("dropZoneTestInstance").get(theNewFileId); + assertThat(fileData.get("originalFilename")).isEqualTo("someImage.jpg"); + assertThat(fileData.get("filesize")).isEqualTo("4.0"); + assertThat(fileData.get("thumbnailUrl")).isEqualTo("base64string"); + assertThat(fileData.get("type")).isEqualTo(MediaType.IMAGE_JPEG_VALUE); } @Test @@ -150,6 +156,7 @@ void shouldShowFileContainsVirusErrorIfClammitScanFindsVirus() throws Exception MediaType.IMAGE_JPEG_VALUE, "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*".getBytes()); when(clammitVirusScanner.virusDetected(testVirusFile)).thenReturn(true); + when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-upload") .file(testVirusFile) @@ -164,13 +171,13 @@ void shouldShowFileContainsVirusErrorIfClammitScanFindsVirus() throws Exception @Test void shouldAllowUploadIfBlockIfUnreachableIsSetToFalse() throws Exception { - when(userFileRepositoryService.save(any())).thenReturn(fileId); doNothing().when(cloudFileRepository).upload(any(), any()); MockMultipartFile testImage = new MockMultipartFile("file", "someImage.jpg", MediaType.IMAGE_JPEG_VALUE, "test".getBytes()); when(clammitVirusScanner.virusDetected(testImage)).thenThrow( new WebClientResponseException(500, "Failed!", null, null, null)); + when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-upload") .file(testImage) @@ -189,8 +196,8 @@ void shouldAllowUploadIfBlockIfUnreachableIsSetToFalse() throws Exception { void shouldSetFalseIfVirusScannerDidNotRun() throws Exception { MockMultipartFile testImage = new MockMultipartFile("file", "someImage.jpg", MediaType.IMAGE_JPEG_VALUE, "test".getBytes()); - when(clammitVirusScanner.virusDetected(testImage)).thenThrow(new WebClientResponseException(500, "Failed!", null, null, null)); - when(userFileRepositoryService.save(any())).thenReturn(fileId); + when(clammitVirusScanner.virusDetected(testImage)).thenThrow( + new WebClientResponseException(500, "Failed!", null, null, null)); doNothing().when(cloudFileRepository).upload(any(), any()); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-upload") @@ -207,7 +214,7 @@ void shouldSetFalseIfVirusScannerDidNotRun() throws Exception { } @Test - void shouldReturn13IfUploadedFileViolatesMaxFileSizeConstraint() throws Exception { + void shouldReturn413IfUploadedFileViolatesMaxFileSizeConstraint() throws Exception { MockMultipartFile testImage = new MockMultipartFile("file", "testFileSizeImage.jpg", MediaType.IMAGE_JPEG_VALUE, new byte[(int) (FileUtils.ONE_MB + 1)]); @@ -225,6 +232,7 @@ void shouldReturn13IfUploadedFileViolatesMaxFileSizeConstraint() throws Exceptio @Test void shouldReturn4xxIfUploadFileViolatesMaxFilesConstraint() throws Exception { when(userFileRepositoryService.countBySubmission(submission)).thenReturn(10L); + when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-upload") .file(new MockMultipartFile("file", "testFileSizeImage.jpg", MediaType.IMAGE_JPEG_VALUE, new byte[10])) @@ -242,34 +250,33 @@ void shouldReturn4xxIfUploadFileViolatesMaxFilesConstraint() throws Exception { public class Delete { String dzWidgetInputName = "coolDzWidget"; + UserFile testUserFile = UserFile.builder() + .fileId(fileId) + .submission(submission) + .createdAt(Date.from(Instant.now())) + .originalName("coolFile.jpg") + .repositoryPath("pathToS3") + .mimeType(".pdf") + .filesize(Float.valueOf("10")) + .virusScanned(false) + .build(); @BeforeEach - void setUp() { - UUID submissionUUID_1 = UUID.randomUUID(); - UUID submissionUUID_2 = UUID.randomUUID(); - submission = Submission.builder().id(submissionUUID_1).build(); - UserFile testUserFile = UserFile.builder().submission(submission).build(); - when(submissionRepositoryService.findById(submissionUUID_1)).thenReturn(Optional.ofNullable(submission)); - when(submissionRepositoryService.findById(submissionUUID_2)).thenReturn(Optional.ofNullable(submission)); + void setUp() throws JsonProcessingException { + submission = Submission.builder().id(UUID.randomUUID()).build(); + testUserFile.setSubmission(submission); + + when(submissionRepositoryService.findById(any())).thenReturn(Optional.ofNullable(submission)); when(userFileRepositoryService.findById(fileId)).thenReturn(Optional.ofNullable(testUserFile)); doNothing().when(cloudFileRepository).delete(any()); - HashMap>> dzWidgets = new HashMap<>(); - HashMap> userFiles = new HashMap<>(); - userFiles.put(fileId, UserFile.createFileInfo( - new UserFile( - fileId, - new Submission(), - Date.from(Instant.now()), - "coolFile.jpg", - "pathToS3", - ".pdf", - Float.valueOf("10"), - false - ), "thumbnail")); - dzWidgets.put(dzWidgetInputName, userFiles); + session = new MockHttpSession(); - session.setAttribute("id", submission.getId()); - session.setAttribute("userFiles", dzWidgets); + setFlowInfoInSession(session, "testFlow", submission.getId()); + + UserFileMap userFileMap = new UserFileMap(); + userFileMap.addUserFileToMap("testFlow", dzWidgetInputName, testUserFile, "thumbnail"); + ObjectMapper objectMapper = new ObjectMapper(); + session.setAttribute("userFiles", objectMapper.writeValueAsString(userFileMap)); } @Test @@ -277,6 +284,7 @@ void endpointErrorsWhenSessionDoesntExist() throws Exception { mockMvc.perform(MockMvcRequestBuilders.multipart("/file-delete") .param("returnPath", "foo") .param("inputName", dzWidgetInputName) + .param("flow", "testFlow") .param("id", fileId.toString())) .andExpect(status().is(HttpStatus.FOUND.value())).andExpect(redirectedUrl("/error")); } @@ -286,6 +294,7 @@ void endpointErrorsWhenFileNotFoundInDb() throws Exception { mockMvc.perform(MockMvcRequestBuilders.multipart("/file-delete") .param("returnPath", "foo") .param("inputName", dzWidgetInputName) + .param("flow", "testFlow") .param("id", UUID.randomUUID().toString()) .session(session)) .andExpect(status().is(HttpStatus.FOUND.value())).andExpect(redirectedUrl("/error")); @@ -295,27 +304,32 @@ void endpointErrorsWhenFileNotFoundInDb() throws Exception { void endpointErrorsWhenIdOnRequestDoesntMatchIdInDb() throws Exception { UUID submissionUUID = UUID.randomUUID(); submission = Submission.builder().id(submissionUUID).build(); - session.setAttribute("id", submissionUUID); + when(submissionRepositoryService.findById(submissionUUID)).thenReturn(Optional.ofNullable(submission)); + setFlowInfoInSession(session, "testFlow", submission.getId()); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-delete") .param("returnPath", "foo") .param("inputName", dzWidgetInputName) .param("id", fileId.toString()) + .param("flow", "testFlow") .session(session)) .andExpect(status().is(HttpStatus.FOUND.value())).andExpect(redirectedUrl("/error")); } @Test public void endpointDeletesFromCloudRepositoryDbAndSession() throws Exception { + when(userFileRepositoryService.findById(fileId)).thenReturn(Optional.ofNullable(testUserFile)); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-delete") .param("returnPath", "foo") .param("inputName", dzWidgetInputName) .param("id", fileId.toString()) + .param("flow", "testFlow") .session(session)) .andExpect(status().is(HttpStatus.FOUND.value())); - verify(cloudFileRepository, times(1)).delete(any()); verify(userFileRepositoryService, times(1)).deleteById(any()); - assertThat(session.getAttribute("userFiles")).isEqualTo(new HashMap<>()); + ObjectMapper objectMapper = new ObjectMapper(); + UserFileMap userFileMap = objectMapper.readValue(session.getAttribute("userFiles").toString(), UserFileMap.class); + assertThat(userFileMap.getUserFileMap().size()).isEqualTo(0); } } @@ -324,79 +338,101 @@ public class Download { @Test void shouldReturnForbiddenStatusIfSessionIdDoesNotMatchSubmissionIdForSingleFileEndpoint() throws Exception { - session.setAttribute("id", UUID.randomUUID()); + setFlowInfoInSession(session, "testFlow", UUID.randomUUID()); UserFile userFile = UserFile.builder().submission(submission).build(); when(userFileRepositoryService.findById(fileId)).thenReturn(Optional.ofNullable(userFile)); - mockMvc.perform(MockMvcRequestBuilders.get("/file-download/{submissionId}/{fileId}", submission.getId().toString(), fileId) - .session(session)) + mockMvc.perform( + MockMvcRequestBuilders + .get("/file-download/{flow}/{submissionId}/{fileId}", "testFlow", submission.getId().toString(), fileId) + .session(session)) .andExpect(status().is(HttpStatus.FORBIDDEN.value())); } @Test void shouldReturnForbiddenIfAFilesSubmissionIdDoesNotMatchSubmissionIdOnTheUserFile() throws Exception { Submission differentSubmissionIdFromUserFile = Submission.builder().id(UUID.randomUUID()).build(); - session.setAttribute("id", submission.getId()); + setFlowInfoInSession(session, "testFlow", submission.getId()); + UserFile userFile = UserFile.builder().submission(differentSubmissionIdFromUserFile) .fileId(fileId).build(); when(userFileRepositoryService.findById(fileId)).thenReturn(Optional.ofNullable(userFile)); - mockMvc.perform(MockMvcRequestBuilders.get("/file-download/{submissionId}/{fileId}", submission.getId().toString(), fileId) - .session(session)) + when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.of(submission)); + mockMvc.perform( + MockMvcRequestBuilders + .get("/file-download/{flow}/{submissionId}/{fileId}", "testFlow", submission.getId().toString(), fileId) + .session(session)) .andExpect(status().is(HttpStatus.FORBIDDEN.value())); } @Test void shouldReturnForbiddenStatusIfSessionIdDoesNotMatchSubmissionIdForMultiFileEndpoint() throws Exception { - session.setAttribute("id", UUID.randomUUID()); + setFlowInfoInSession(session, "testFlow", UUID.randomUUID()); when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.ofNullable(submission)); - mockMvc.perform(MockMvcRequestBuilders.get("/file-download/{submissionId}", submission.getId().toString()) - .session(session)) + + mockMvc.perform( + MockMvcRequestBuilders + .get("/file-download/{flow}/{submissionId}", "testFlow", submission.getId().toString()) + .session(session)) .andExpect(status().is(HttpStatus.FORBIDDEN.value())); } @Test void shouldReturnNotFoundIfSubmissionCanNotBeFoundForMultiFileEndpoint() throws Exception { - session.setAttribute("id", submission.getId()); UUID differentSubmissionId = UUID.randomUUID(); - when(submissionRepositoryService.findById(differentSubmissionId)).thenReturn(Optional.empty()); - mockMvc.perform(MockMvcRequestBuilders.get("/file-download/{submissionId}", submission.getId().toString()) - .session(session)) + setFlowInfoInSession(session, "testFlow", differentSubmissionId); + + mockMvc.perform( + MockMvcRequestBuilders + .get("/file-download/{submissionId}", differentSubmissionId.toString()) + .param("flow", "testFlow") + .session(session)) .andExpect(status().is(404)); } @Test void shouldReturnNotFoundIfSubmissionDoesNotContainAnyFiles() throws Exception { - session.setAttribute("id", submission.getId()); - + setFlowInfoInSession(session, "testFlow", submission.getId()); when(userFileRepositoryService.findAllBySubmission(submission)).thenReturn(Collections.emptyList()); when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.ofNullable(submission)); - mockMvc.perform(MockMvcRequestBuilders.get("/file-download/{submissionId}", submission.getId().toString()) - .session(session)) + + mockMvc.perform( + MockMvcRequestBuilders + .get("/file-download/{submissionId}", submission.getId().toString()) + .param("flow", "testFlow") + .session(session)) .andExpect(status().is(404)); } @Test void singleFileEndpointShouldReturnNotFoundIfNoUserFileIsFoundForAGivenFileId() throws Exception { - session.setAttribute("id", submission.getId()); + setFlowInfoInSession(session, "testFlow", submission.getId()); when(userFileRepositoryService.findById(fileId)).thenReturn(Optional.empty()); - mockMvc.perform(MockMvcRequestBuilders.get("/file-download/{submissionId}/{fileId}", submission.getId().toString(), fileId) - .session(session)) + mockMvc.perform( + MockMvcRequestBuilders + .get("/file-download/{flow}/{submissionId}/{fileId}", "testFlow", submission.getId().toString(), fileId) + .session(session)) .andExpect(status().is(404)); } @Test void singleFileEndpointShouldReturnTheSameFileBytesAsTheCloudFileRepository() throws Exception { - session.setAttribute("id", submission.getId()); + setFlowInfoInSession(session, "testFlow", submission.getId()); byte[] testFileBytes = "foo".getBytes(); long fileSize = testFileBytes.length; CloudFile testcloudFile = new CloudFile(fileSize, testFileBytes); - UserFile testUserFile = UserFile.builder().originalName("testFileName").mimeType("image/jpeg").repositoryPath("testPath") + UserFile testUserFile = UserFile.builder() + .originalName("testFileName") + .mimeType("image/jpeg") + .repositoryPath("testPath") .submission(submission) .build(); when(userFileRepositoryService.findById(fileId)).thenReturn(Optional.ofNullable(testUserFile)); when(cloudFileRepository.get("testPath")).thenReturn(testcloudFile); + when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); MvcResult mvcResult = mockMvc.perform( - MockMvcRequestBuilders.get("/file-download/{submissionId}/{fileId}", submission.getId().toString(), fileId) + MockMvcRequestBuilders + .get("/file-download/{flow}/{submissionId}/{fileId}", "testFlow", submission.getId().toString(), fileId) .session(session)) .andExpect(MockMvcResultMatchers.request().asyncStarted()) .andReturn(); @@ -411,7 +447,7 @@ void singleFileEndpointShouldReturnTheSameFileBytesAsTheCloudFileRepository() th @Test void multiFileEndpointShouldReturnZipOfUserFilesReturnedByTheCloudFileRepository() throws Exception { - session.setAttribute("id", submission.getId()); + setFlowInfoInSession(session, "testFlow", submission.getId()); byte[] firstTestFileBytes = Files.readAllBytes(Paths.get("src/test/resources/test.png")); byte[] secondTestFileBytes = Files.readAllBytes(Paths.get("src/test/resources/test-platypus.gif")); long firstTestFileSize = firstTestFileBytes.length; @@ -419,11 +455,17 @@ void multiFileEndpointShouldReturnZipOfUserFilesReturnedByTheCloudFileRepository CloudFile firstTestcloudFile = new CloudFile(firstTestFileSize, firstTestFileBytes); CloudFile secondTestcloudFile = new CloudFile(secondTestFileSize, secondTestFileBytes); - UserFile firstTestUserFile = UserFile.builder().originalName("test.png").mimeType("image/png") + UserFile firstTestUserFile = UserFile.builder() + .originalName("test.png") + .mimeType("image/png") .repositoryPath("testPath") .filesize((float) firstTestFileSize) - .submission(submission).build(); - UserFile secondTestUserFile = UserFile.builder().originalName("test-platypus.gif").mimeType("image/gif") + .submission(submission) + .build(); + + UserFile secondTestUserFile = UserFile.builder() + .originalName("test-platypus.gif") + .mimeType("image/gif") .repositoryPath("testPath2") .filesize((float) secondTestFileSize) .submission(submission).build(); @@ -436,7 +478,8 @@ void multiFileEndpointShouldReturnZipOfUserFilesReturnedByTheCloudFileRepository when(cloudFileRepository.get("testPath2")).thenReturn(secondTestcloudFile); MvcResult mvcResult = mockMvc.perform( - MockMvcRequestBuilders.get("/file-download/{submissionId}", submission.getId().toString()) + MockMvcRequestBuilders + .get("/file-download/{flow}/{submissionId}", "testFlow", submission.getId().toString()) .session(session)) .andExpect(MockMvcResultMatchers.request().asyncStarted()) .andReturn(); diff --git a/src/test/java/formflow/library/controllers/NoOpVirusScannerTest.java b/src/test/java/formflow/library/controllers/NoOpVirusScannerTest.java index ca3d62bda..c60e2f62a 100644 --- a/src/test/java/formflow/library/controllers/NoOpVirusScannerTest.java +++ b/src/test/java/formflow/library/controllers/NoOpVirusScannerTest.java @@ -1,5 +1,6 @@ package formflow.library.controllers; +import static formflow.library.FormFlowController.SUBMISSION_MAP_NAME; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -8,8 +9,12 @@ import formflow.library.FileController; import formflow.library.data.Submission; import formflow.library.data.SubmissionRepositoryService; +import formflow.library.data.UserFile; import formflow.library.data.UserFileRepositoryService; import formflow.library.utilities.AbstractMockMvcTest; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,6 +33,7 @@ "form-flow.uploads.virus-scanning.enabled=false", }) public class NoOpVirusScannerTest extends AbstractMockMvcTest { + private MockMvc mockMvc; private UUID fileUuid; @MockBean @@ -44,8 +50,21 @@ public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); mockMvc = MockMvcBuilders.standaloneSetup(fileController).build(); Submission submission = Submission.builder().id(submissionUUID).build(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); - when(userFileRepositoryService.save(any())).thenReturn(fileUuid); + + setFlowInfoInSession(session, "testFlow", submission.getId()); + + UserFile userFile = UserFile.builder() + .submission(submission) + .fileId(fileUuid) + .virusScanned(false) + .originalName("testFile.jpg") + .filesize(Float.valueOf("10.0")) + .mimeType(MediaType.IMAGE_JPEG_VALUE) + .repositoryPath("/foo") + .build(); + + when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); + when(userFileRepositoryService.save(any())).thenReturn(userFile); super.setUp(); } diff --git a/src/test/java/formflow/library/controllers/PdfControllerTest.java b/src/test/java/formflow/library/controllers/PdfControllerTest.java index a4063360d..77952efa1 100644 --- a/src/test/java/formflow/library/controllers/PdfControllerTest.java +++ b/src/test/java/formflow/library/controllers/PdfControllerTest.java @@ -1,7 +1,6 @@ package formflow.library.controllers; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -14,6 +13,7 @@ import formflow.library.config.FlowConfiguration; import formflow.library.data.Submission; import formflow.library.data.SubmissionRepositoryService; +import formflow.library.data.UserFileRepositoryService; import formflow.library.pdf.PdfService; import formflow.library.utilities.AbstractMockMvcTest; import java.util.List; @@ -25,7 +25,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.setup.MockMvcBuilders; public class PdfControllerTest extends AbstractMockMvcTest { @@ -36,56 +37,108 @@ public class PdfControllerTest extends AbstractMockMvcTest { @MockBean private SubmissionRepositoryService submissionRepositoryService; + + @MockBean + private UserFileRepositoryService userFileRepositoryService; private byte[] filledPdfByteArray; + private final String flowName = "testFlow"; + private final String otherFlowName = "otherTestFlow"; + + private final UUID submissionId = UUID.randomUUID(); + @Override @BeforeEach public void setUp() throws Exception { - String flow = "ubi"; FlowConfiguration flowConfiguration = new FlowConfiguration(); - flowConfiguration.setName(flow); - List flowConfigurations = List.of(flowConfiguration); - PdfController pdfController = new PdfController(messageSource, pdfService, submissionRepositoryService, flowConfigurations); + FlowConfiguration flowConfigurationOther = new FlowConfiguration(); + flowConfiguration.setName(flowName); + flowConfigurationOther.setName(otherFlowName); + List flowConfigurations = List.of( + flowConfiguration, flowConfigurationOther + ); + + PdfController pdfController = new PdfController(messageSource, pdfService, submissionRepositoryService, + userFileRepositoryService, flowConfigurations); mockMvc = MockMvcBuilders.standaloneSetup(pdfController).build(); + submission = Submission.builder() - .id(UUID.randomUUID()) - .flow(flow) + .id(submissionId) + .flow(flowName) .build(); filledPdfByteArray = new byte[20]; + + setFlowInfoInSession(session, + flowName, submission.getId() + ); + when(pdfService.getFilledOutPDF(submission)).thenReturn(filledPdfByteArray); - when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); + when(submissionRepositoryService.findById(submissionId)).thenReturn(Optional.of(submission)); super.setUp(); } @Test void shouldReturn404WhenFlowDoesNotExist() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.get("/download/{flow}/{submissionId}", "flowThatDoesNotExist", "submissionId")) - .andExpect(status().isNotFound()); + getPdfFile(submission, "flowThatDoesNotExist", status().isNotFound(), false); } @Test - void getDownloadGeneratesAndReturnsFilledFlattenedPdf() throws Exception { - session.setAttribute("id", submission.getId()); - MvcResult result = mockMvc.perform(get("/download/ubi/" + submission.getId()).session(session)) - .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=%s".formatted(pdfService.generatePdfName(submission)))) - .andExpect(status().is2xxSuccessful()) - .andReturn(); + public void getDownloadGeneratesAndReturnsFilledFlattenedPdf() throws Exception { + MvcResult result = getPdfFile(submission, flowName, status().is2xxSuccessful(), true); assertThat(result.getResponse().getContentAsByteArray()).isEqualTo(filledPdfByteArray); - verify(pdfService, times(1)).getFilledOutPDF(submission); } @Test - void shouldNotAllowDownloadingAPdfWithADifferentSubmissionIdThanTheActiveSession() throws Exception { - session.setAttribute("id", UUID.randomUUID()); + public void shouldNotAllowDownloadingAPdfWithADifferentSubmissionIdThanTheActiveSession() throws Exception { + // first test with bogus id + setFlowInfoInSession(session, flowName, UUID.randomUUID()); + getPdfFile(submission, flowName, status().is4xxClientError(), false); + + // now test with legitimate id + setFlowInfoInSession(session, flowName, submission.getId()); + getPdfFile(submission, flowName, status().is2xxSuccessful(), false); + } + + @Test + public void shouldReturnCorrectFileWhenMultipleFlowsExist() throws Exception { + UUID otherSubmissionId = UUID.randomUUID(); + Submission otherSubmission = Submission.builder() + .id(otherSubmissionId) + .flow(otherFlowName) + .build(); + byte[] otherByteArray = new byte[45]; + + setFlowInfoInSession(session, + flowName, submission.getId(), + otherFlowName, otherSubmission.getId() + ); + + when(pdfService.getFilledOutPDF(otherSubmission)).thenReturn(otherByteArray); + when(submissionRepositoryService.findById(otherSubmissionId)).thenReturn(Optional.of(otherSubmission)); + + MvcResult result = getPdfFile(submission, flowName, status().is2xxSuccessful(), true); + assertThat(result.getResponse().getContentAsByteArray()).isEqualTo(filledPdfByteArray); + + MvcResult otherResult = getPdfFile(otherSubmission, otherFlowName, status().is2xxSuccessful(), true); + assertThat(otherResult.getResponse().getContentAsByteArray()).isEqualTo(otherByteArray); + + verify(pdfService, times(1)).getFilledOutPDF(submission); + } - mockMvc.perform(get("/download/ubi/" + submission.getId()).session(session)) - .andExpect(status().is4xxClientError()); + private MvcResult getPdfFile(Submission testSubmission, String testFlow, ResultMatcher resultMatcher, boolean expectsFile) + throws Exception { + ResultActions resultActions = mockMvc.perform( + get("/download/" + testFlow + "/" + testSubmission.getId()) + .session(session) + ) + .andExpect(resultMatcher); - session.setAttribute("id", submission.getId()); + if (expectsFile) { + resultActions.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=%s".formatted(pdfService.generatePdfName(testSubmission)))); + } - mockMvc.perform(get("/download/ubi/" + submission.getId()).session(session)) - .andExpect(status().is2xxSuccessful()); + return resultActions.andReturn(); } } diff --git a/src/test/java/formflow/library/controllers/ScreenControllerJourneyTest.java b/src/test/java/formflow/library/controllers/ScreenControllerJourneyTest.java new file mode 100644 index 000000000..979bc79a8 --- /dev/null +++ b/src/test/java/formflow/library/controllers/ScreenControllerJourneyTest.java @@ -0,0 +1,88 @@ +package formflow.library.controllers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +import formflow.library.utilities.AbstractBasePageTest; +import java.io.IOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = {"form-flow.path=flows-config/test-flow.yaml"}, + webEnvironment = RANDOM_PORT) +public class ScreenControllerJourneyTest extends AbstractBasePageTest { + + private final String firstFlow = "testFlow"; + + private final String secondFlow = "otherTestFlow"; + + @Override + @BeforeEach + public void setUp() throws IOException { + startingPage = "flow/" + firstFlow + "/inputs"; + super.setUp(); + } + + @Test + public void multiFlowJourneyTestDataPersists() { + // "testFlow" flow + assertThat(testPage.getTitle()).isEqualTo("Inputs Screen"); + testPage.enter("textInput", "testFlow: textInput"); + testPage.enter("areaInput", "testFlow: areaInput"); + testPage.enter("dateDay", "10"); + testPage.enter("dateMonth", "10"); + testPage.enter("dateYear", "2010"); + testPage.enter("moneyInput", "110"); + testPage.clickContinue(); + assertThat(testPage.getTitle()).isEqualTo("Test"); + + // switch to other flow "testOtherFlow" + baseUrl = "http://localhost:%s/%s".formatted(localServerPort, + "flow/" + secondFlow + "/inputs"); + driver.navigate().to(baseUrl); + + assertThat(testPage.getTitle()).isEqualTo("Inputs Screen"); + + // stop check that no values are set, as we are in other flow + assertThat(testPage.getElementText("textInput")).isEmpty(); + assertThat(testPage.getElementText("areaInput")).isEmpty(); + assertThat(testPage.getInputValue("dateDay")).isEmpty(); + + // enter some data for otherTestFlow + testPage.enter("textInput", "otherTestFlow: textInput"); + testPage.enter("areaInput", "otherTestFlow: areaInput"); + testPage.enter("dateDay", "11"); + testPage.enter("dateMonth", "11"); + testPage.enter("dateYear", "2011"); + testPage.enter("moneyInput", "111"); + testPage.clickContinue(); + assertThat(testPage.getTitle()).isEqualTo("Test"); + + baseUrl = "http://localhost:%s/%s".formatted(localServerPort, + "flow/" + firstFlow + "/inputs"); + driver.navigate().to(baseUrl); + + assertThat(testPage.getTitle()).isEqualTo("Inputs Screen"); + + assertThat(testPage.getInputValue("textInput")).isEqualTo("testFlow: textInput"); + assertThat(testPage.getElementText("areaInput")).isEqualTo("testFlow: areaInput"); + assertThat(testPage.getInputValue("dateDay")).isEqualTo("10"); + assertThat(testPage.getInputValue("dateMonth")).isEqualTo("10"); + assertThat(testPage.getInputValue("dateYear")).isEqualTo("2010"); + assertThat(testPage.getInputValue("moneyInput")).isEqualTo("110"); + + baseUrl = "http://localhost:%s/%s".formatted(localServerPort, + "flow/" + secondFlow + "/inputs"); + driver.navigate().to(baseUrl); + + assertThat(testPage.getTitle()).isEqualTo("Inputs Screen"); + + assertThat(testPage.getInputValue("textInput")).isEqualTo("otherTestFlow: textInput"); + assertThat(testPage.getElementText("areaInput")).isEqualTo("otherTestFlow: areaInput"); + assertThat(testPage.getInputValue("dateDay")).isEqualTo("11"); + assertThat(testPage.getInputValue("dateMonth")).isEqualTo("11"); + assertThat(testPage.getInputValue("dateYear")).isEqualTo("2011"); + assertThat(testPage.getInputValue("moneyInput")).isEqualTo("111"); + } +} diff --git a/src/test/java/formflow/library/controllers/ScreenControllerTest.java b/src/test/java/formflow/library/controllers/ScreenControllerTest.java index 3093f4319..70df17cdd 100644 --- a/src/test/java/formflow/library/controllers/ScreenControllerTest.java +++ b/src/test/java/formflow/library/controllers/ScreenControllerTest.java @@ -1,5 +1,7 @@ package formflow.library.controllers; +import static formflow.library.FormFlowController.SUBMISSION_MAP_NAME; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,7 +31,9 @@ import org.junit.jupiter.params.provider.CsvSource; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; @SpringBootTest(properties = {"form-flow.path=flows-config/test-flow.yaml"}) public class ScreenControllerTest extends AbstractMockMvcTest { @@ -37,22 +41,21 @@ public class ScreenControllerTest extends AbstractMockMvcTest { @MockBean private AddressValidationService addressValidationService; - @MockBean + @SpyBean private SubmissionRepositoryService submissionRepositoryService; public final String uuidPatternString = "{uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}}"; - @Override @BeforeEach public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); submission = Submission.builder().id(submissionUUID).urlParams(new HashMap<>()).inputData(new HashMap<>()).build(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); - when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); + // this setups flow info in the session to get passed along later on. + setFlowInfoInSession(session, "testFlow", submission.getId()); + super.setUp(); } - @ParameterizedTest @CsvSource({ "GET, /flow/{flow}/{screen}, flowThatDoesNotExist, screen", @@ -70,7 +73,8 @@ public void setUp() throws Exception { "GET, /flow/{flow}/{screen}/navigation, flowThatDoesNotExist, screen", "GET, /flow/{flow}/{screen}/navigation, testFlow, screenThatDoesNotExist" }) - void endpointShouldReturn404IfFlowOrScreenDoesNotExist(String method, String path, String flow, String screen) throws Exception { + void endpointShouldReturn404IfFlowOrScreenDoesNotExist(String method, String path, String flow, String screen) + throws Exception { switch (method) { case "GET" -> mockMvc.perform(get(path, flow, screen)).andExpect(status().isNotFound()); case "POST" -> mockMvc.perform(post(path, flow, screen)).andExpect(status().isNotFound()); @@ -82,6 +86,7 @@ public class UrlParameterPersistence { @Test public void passedUrlParametersShouldBeSaved() throws Exception { + when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.of(submission)); Map queryParams = new HashMap<>(); queryParams.put("lang", "en"); getWithQueryParam("test", "lang", "en"); @@ -94,6 +99,7 @@ public class SubflowParameters { @Test public void modelIncludesCurrentSubflowItem() throws Exception { + when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.of(submission)); HashMap subflowItem = new HashMap<>(); subflowItem.put("uuid", "aaa-bbb-ccc"); subflowItem.put("firstNameSubflow", "foo bar baz"); @@ -152,6 +158,99 @@ public void addressValidationShouldOnlyRunWhenSetToTrue() throws Exception { } } + @Nested + public class MultiFlowTests { + // tests that related to testing out changing flows in the middle of a flow to ensure + // that no data is lost + + @Test + public void multipleFlowsResultInMultipleSubmissionsNoDataLost() throws Exception { + // session does not have to know about the flows yet, as the flows will be + // added once the post occurs + mockMvc.perform(post("/flow/testFlow/inputs") + .session(session) + .params(new LinkedMultiValueMap<>(Map.of( + "textInput", List.of("firstFlowTextInputValue"), + "numberInput", List.of("10")))) + ); + + mockMvc.perform(post("/flow/otherTestFlow/inputs") + .session(session) + .params(new LinkedMultiValueMap<>(Map.of( + "textInput", List.of("secondFlowTextInputValue"), + "numberInput", List.of("20"), + "phoneInput", List.of("(555) 123-1234")))) + ); + + Map submissionMap = (Map) session.getAttribute(SUBMISSION_MAP_NAME); + + assertThat(submissionMap.containsKey("testFlow")).isTrue(); + assertThat(submissionMap.containsKey("otherTestFlow")).isTrue(); + assertThat(submissionMap.size()).isEqualTo(2); + + Optional testFlowSubmission = submissionRepositoryService.findById(submissionMap.get("testFlow")); + Optional otherTestFlowSubmission = submissionRepositoryService.findById(submissionMap.get("otherTestFlow")); + assertThat(testFlowSubmission.isPresent()).isTrue(); + assertThat(otherTestFlowSubmission.isPresent()).isTrue(); + assertThat(testFlowSubmission.get().getInputData().size()).isEqualTo(2); + assertThat(otherTestFlowSubmission.get().getInputData().size()).isEqualTo(3); + + assertThat(testFlowSubmission.get().getInputData().get("textInput")).isEqualTo("firstFlowTextInputValue"); + assertThat(testFlowSubmission.get().getInputData().get("numberInput")).isEqualTo("10"); + assertThat(testFlowSubmission.get().getInputData().get("phoneInput")).isEqualTo(null); + assertThat(otherTestFlowSubmission.get().getInputData().get("textInput")).isEqualTo("secondFlowTextInputValue"); + assertThat(otherTestFlowSubmission.get().getInputData().get("numberInput")).isEqualTo("20"); + assertThat(otherTestFlowSubmission.get().getInputData().get("phoneInput")).isEqualTo("(555) 123-1234"); + } + + @Test + public void multipleFlowsInSubflowsNoDataLost() throws Exception { + // session doesn't have to know about the two different flows yet + // as they will get put in session during the posts + mockMvc.perform(post("/flow/testFlow/subflowAddItem/new") + .session(session) + .params(new LinkedMultiValueMap<>(Map.of( + "firstNameSubflow", List.of("Subflow testFlow Name"), + "textInputSubflow", List.of("Subflow testFlow Text Input")))) + ); + + mockMvc.perform(post("/flow/otherTestFlow/subflowAddItem/new") + .session(session) + .params(new LinkedMultiValueMap<>(Map.of( + "numberInputSubflow", List.of("23"), + "moneyInputSubflow", List.of("10.00"), + "phoneInputSubflow", List.of("(413) 123-4567")))) + ); + + Map submissionMap = (Map) session.getAttribute(SUBMISSION_MAP_NAME); + + assertThat(submissionMap.containsKey("testFlow")).isTrue(); + assertThat(submissionMap.containsKey("otherTestFlow")).isTrue(); + assertThat(submissionMap.size()).isEqualTo(2); + + Optional testFlowSubmission = submissionRepositoryService.findById(submissionMap.get("testFlow")); + Optional otherTestFlowSubmission = submissionRepositoryService.findById(submissionMap.get("otherTestFlow")); + assertThat(testFlowSubmission.isPresent()).isTrue(); + assertThat(otherTestFlowSubmission.isPresent()).isTrue(); + + List testFlowInputData = (List) (testFlowSubmission.get().getInputData()).get("testSubflow"); + List otherTestFlowInputData = (List) (otherTestFlowSubmission.get().getInputData()).get("testSubflow"); + Map testFlowIteration = (Map) testFlowInputData.get(0); + Map otherTestFlowIteration = (Map) otherTestFlowInputData.get(0); + + assertThat(testFlowInputData.size()).isEqualTo(1); + assertThat(otherTestFlowInputData.size()).isEqualTo(1); + assertThat(testFlowIteration.size()).isEqualTo(3); + assertThat(otherTestFlowIteration.size()).isEqualTo(4); + + assertThat(testFlowIteration.get("firstNameSubflow")).isEqualTo("Subflow testFlow Name"); + assertThat(testFlowIteration.get("textInputSubflow")).isEqualTo("Subflow testFlow Text Input"); + assertThat(otherTestFlowIteration.get("numberInputSubflow")).isEqualTo("23"); + assertThat(otherTestFlowIteration.get("moneyInputSubflow")).isEqualTo("10.00"); + assertThat(otherTestFlowIteration.get("phoneInputSubflow")).isEqualTo("(413) 123-4567"); + } + } + @Test public void fieldsStillHaveValuesWhenFieldValidationFailsInSubflowNewIteration() throws Exception { var params = new HashMap>(); diff --git a/src/test/java/formflow/library/controllers/UploadBlockedIfVirusScanUnreachableTest.java b/src/test/java/formflow/library/controllers/UploadBlockedIfVirusScanUnreachableTest.java index d5dadb410..8da635292 100644 --- a/src/test/java/formflow/library/controllers/UploadBlockedIfVirusScanUnreachableTest.java +++ b/src/test/java/formflow/library/controllers/UploadBlockedIfVirusScanUnreachableTest.java @@ -31,6 +31,7 @@ "form-flow.uploads.virus-scanning.block-if-unreachable=true", }) public class UploadBlockedIfVirusScanUnreachableTest extends AbstractMockMvcTest { + private MockMvc mockMvc; @MockBean private SubmissionRepositoryService submissionRepositoryService; @@ -40,14 +41,14 @@ public class UploadBlockedIfVirusScanUnreachableTest extends AbstractMockMvcTest private FileController fileController; @MockBean private ClammitVirusScanner clammitVirusScanner; - + @Override @BeforeEach public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); mockMvc = MockMvcBuilders.standaloneSetup(fileController).build(); Submission submission = Submission.builder().id(submissionUUID).build(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); + //when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); super.setUp(); } @@ -55,7 +56,8 @@ public void setUp() throws Exception { void shouldPreventUploadAndShowAnErrorIfBlockIfUnreachableIsSetToTrue() throws Exception { MockMultipartFile testImage = new MockMultipartFile("file", "someImage.jpg", MediaType.IMAGE_JPEG_VALUE, "test".getBytes()); - when(clammitVirusScanner.virusDetected(testImage)).thenThrow(new WebClientResponseException(500, "Failed!", null, null, null)); + when(clammitVirusScanner.virusDetected(testImage)).thenThrow( + new WebClientResponseException(500, "Failed!", null, null, null)); mockMvc.perform(MockMvcRequestBuilders.multipart("/file-upload") .file(testImage) .param("flow", "testFlow") @@ -64,6 +66,7 @@ void shouldPreventUploadAndShowAnErrorIfBlockIfUnreachableIsSetToTrue() throws E .session(session) .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) .andExpect(status().is(HttpStatus.SERVICE_UNAVAILABLE.value())) - .andExpect(content().string(this.messageSource.getMessage("upload-documents.error-virus-scanner-unavailable", null, Locale.ENGLISH))); + .andExpect(content().string( + this.messageSource.getMessage("upload-documents.error-virus-scanner-unavailable", null, Locale.ENGLISH))); } } diff --git a/src/test/java/formflow/library/file/UploadJourneyTests.java b/src/test/java/formflow/library/file/UploadJourneyTests.java index 0e75b6d0e..cdc6f0209 100644 --- a/src/test/java/formflow/library/file/UploadJourneyTests.java +++ b/src/test/java/formflow/library/file/UploadJourneyTests.java @@ -5,6 +5,7 @@ import formflow.library.utilities.AbstractBasePageTest; import java.io.IOException; +import java.util.List; import java.util.Locale; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; @@ -19,10 +20,12 @@ }, webEnvironment = RANDOM_PORT) public class UploadJourneyTests extends AbstractBasePageTest { + private final String dzWidgetName = "uploadTest"; + @Override @BeforeEach public void setUp() throws IOException { - startingPage = "flow/uploadFlow/docUploadJourney"; + startingPage = "flow/uploadFlowA/docUploadJourney"; super.setUp(); } @@ -31,27 +34,27 @@ void documentUploadFlow() { assertThat(testPage.getTitle()).isEqualTo("Upload Documents"); // Test accepted file types // Extension list comes from application.yaml -- form-flow.uploads.accepted-file-types - uploadFile("test-platypus.gif", "uploadTest"); + uploadFile("test-platypus.gif", dzWidgetName); assertThat(testPage.findElementsByClass("text--error").get(0).getText()) .isEqualTo(messageSource .getMessage("upload-documents.error-invalid-file-type", null, Locale.ENGLISH) + " .jpeg, .pdf"); testPage.clickLink("remove"); - assertThat(testPage.findElementTextById("number-of-uploaded-files-uploadTest")).isEqualTo("0 files added"); + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("0 files added"); // Upload a file that is too big and assert the correct error shows - max file size in test is 1MB long largeFilesize = 21000000L; driver.executeScript( - "$('#document-upload-uploadTest').get(0).dropzone.addFile({name: 'testFile.pdf', size: " + "$('#document-upload-" + dzWidgetName + "').get(0).dropzone.addFile({name: 'testFile.pdf', size: " + largeFilesize + ", type: 'not-an-image'})"); int maxFileSize = 17; assertThat(driver.findElement(By.className("text--error")).getText()).contains(messageSource .getMessage("upload-documents.this-file-is-too-large", new Object[]{maxFileSize}, Locale.ENGLISH)); testPage.clickLink("remove"); - assertThat(testPage.findElementTextById("number-of-uploaded-files-uploadTest")).isEqualTo("0 files added"); + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("0 files added"); // Upload a password-protected file and assert the correct error shows - uploadPasswordProtectedPdf("uploadTest"); + uploadPasswordProtectedPdf(dzWidgetName); //Race condition caused by uploadPasswordProtectedPdf waits until upload file has file details added instead //of waiting until file upload is complete. @@ -64,21 +67,21 @@ void documentUploadFlow() { assertThat(testPage.findElementsByClass("text--error").get(0).getText()) .isEqualTo(messageSource.getMessage("upload-documents.error-password-protected", null, Locale.ENGLISH)); testPage.clickLink("remove"); - assertThat(testPage.findElementTextById("number-of-uploaded-files-uploadTest")).isEqualTo("0 files added"); + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("0 files added"); // Test max number of files that can be uploaded - uploadJpgFile("uploadTest"); + uploadJpgFile(dzWidgetName); // 1 assertThat(testPage.findElementTextById("number-of-uploaded-files-uploadTest")).isEqualTo("1 file added"); - uploadJpgFile("uploadTest"); // 2 - uploadJpgFile("uploadTest"); // 3 - uploadJpgFile("uploadTest"); // 4 - uploadJpgFile("uploadTest"); // 5 - uploadJpgFile("uploadTest"); // Can't upload the 6th + uploadJpgFile(dzWidgetName); // 2 + uploadJpgFile(dzWidgetName); // 3 + uploadJpgFile(dzWidgetName); // 4 + uploadJpgFile(dzWidgetName); // 5 + uploadJpgFile(dzWidgetName); // Can't upload the 6th assertThat(testPage.findElementsByClass("text--error").get(0).getText()) .isEqualTo(messageSource.getMessage("upload-documents.error-maximum-number-of-files", null, Locale.ENGLISH)); testPage.clickLink("remove"); // Assert there are no longer any error after removing the errored item - assertThat(testPage.findElementTextById("number-of-uploaded-files-uploadTest")).isEqualTo("5 files added"); + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("5 files added"); assertThat( testPage.findElementsByClass("text--error").stream().map(WebElement::getText).collect(Collectors.toList())) .allMatch(String::isEmpty); @@ -88,6 +91,68 @@ void documentUploadFlow() { driver.switchTo().alert().accept(); } assertThat(testPage.findElementsByClass("dz-remove").size()).isEqualTo(0); - assertThat(testPage.findElementTextById("number-of-uploaded-files-uploadTest")).isEqualTo("0 files added"); + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("0 files added"); + } + + @Test + void documentUploadFlowMultiFlow() { + assertThat(testPage.getTitle()).isEqualTo("Upload Documents"); + + for (int i = 0; i < 5; i++) { + uploadFile("testA.jpeg", dzWidgetName); + } + + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("5 files added"); + assertThat(testPage.findElementsByClass("text--error").stream() + .map(WebElement::getText) + .collect(Collectors.toList())) + .allMatch(String::isEmpty); + + List elementsA = testPage.findElementsByClass("filename-text-name"); + assertThat(elementsA.size()).isEqualTo(5); + elementsA.forEach( + element -> assertThat(element.getText()).isEqualTo("testA") + ); + + // switch flow from A to B and upload some files. Ensure you only see Flow B files. + baseUrl = "http://localhost:%s/%s".formatted(localServerPort, "flow/uploadFlowB/docUploadJourney"); + driver.navigate().to(baseUrl); + + for (int i = 0; i < 5; i++) { + uploadFile("testB.jpeg", dzWidgetName); + } + + assertThat(testPage.findElementTextById("number-of-uploaded-files-" + dzWidgetName)).isEqualTo("5 files added"); + assertThat(testPage.findElementsByClass("text--error").stream() + .map(WebElement::getText) + .collect(Collectors.toList())) + .allMatch(String::isEmpty); + + List elementsB = testPage.findElementsByClass("filename-text-name"); + elementsB.forEach( + element -> assertThat(element.getText()).isEqualTo("testB") + ); + + // go forward and back and ensure you only see flow B files + testPage.clickContinue(); + testPage.goBack(); + + elementsB = testPage.findElementsByClass("filename-text-name"); + assertThat(elementsB.size()).isEqualTo(5); + elementsB.forEach( + element -> { + assertThat(element.getText()).isEqualTo("testB"); + }); + + // switch back to A and ensure no B flow files are present + baseUrl = "http://localhost:%s/%s".formatted(localServerPort, "flow/uploadFlowA/docUploadJourney"); + driver.navigate().to(baseUrl); + + elementsA = testPage.findElementsByClass("filename-text-name"); + assertThat(elementsA.size()).isEqualTo(5); + elementsA.forEach( + element -> { + assertThat(element.getText()).isEqualTo("testA"); + }); } } diff --git a/src/test/java/formflow/library/file/UploadUnitTests.java b/src/test/java/formflow/library/file/UploadUnitTests.java index 9cfaa3d56..a51c481b5 100644 --- a/src/test/java/formflow/library/file/UploadUnitTests.java +++ b/src/test/java/formflow/library/file/UploadUnitTests.java @@ -17,7 +17,7 @@ public class UploadUnitTests extends AbstractBasePageTest { @Override @BeforeEach public void setUp() throws IOException { - startingPage = "flow/uploadFlow/docUploadUnit"; + startingPage = "flow/uploadFlowA/docUploadUnit"; super.setUp(); } diff --git a/src/test/java/formflow/library/framework/AfterSaveActionTest.java b/src/test/java/formflow/library/framework/AfterSaveActionTest.java index 1bf1fb2eb..12d17826a 100644 --- a/src/test/java/formflow/library/framework/AfterSaveActionTest.java +++ b/src/test/java/formflow/library/framework/AfterSaveActionTest.java @@ -50,7 +50,7 @@ public void setUp() throws Exception { mailgunEmailClient.setMailgunMessagesApi(mailgunMessagesApi); super.setUp(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); + ////when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); } diff --git a/src/test/java/formflow/library/framework/BeforeDisplayActionTest.java b/src/test/java/formflow/library/framework/BeforeDisplayActionTest.java index 778978a72..cbf303cd0 100644 --- a/src/test/java/formflow/library/framework/BeforeDisplayActionTest.java +++ b/src/test/java/formflow/library/framework/BeforeDisplayActionTest.java @@ -31,10 +31,10 @@ public class BeforeDisplayActionTest extends AbstractMockMvcTest { public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); submission = Submission.builder().id(submissionUUID).inputData(new HashMap<>()).build(); - + setFlowInfoInSession(session, "testFlow", submission.getId()); super.setUp(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); + when(submissionRepositoryService.save(any())).thenReturn(submission); } @Test @@ -58,7 +58,6 @@ void shouldSaveEncryptedSSN() throws Exception { @Test void shouldSaveEncryptedSSNInSubflow() throws Exception { String subflowUuid = UUID.randomUUID().toString(); - Map sessionAttrs = new HashMap<>(); List> subflowList = new ArrayList<>(); subflowList.add(Map.of("uuid", subflowUuid)); @@ -70,12 +69,11 @@ void shouldSaveEncryptedSSNInSubflow() throws Exception { "iterationIsComplete", true)); submission.getInputData().put("householdMembers", subflowList); - sessionAttrs.put("id", submission.getId()); // beforeSave String ssnInput = "333-33-3333"; postToUrlExpectingSuccess("/flow/testFlow/pageWithSSNInput", "/flow/testFlow/subflowReview", - Map.of("ssnInput", List.of(ssnInput)), subflowUuid, sessionAttrs); + Map.of("ssnInput", List.of(ssnInput)), subflowUuid); Map subflowEntry = submission.getSubflowEntryByUuid("householdMembers", subflowUuid); diff --git a/src/test/java/formflow/library/framework/BeforeSaveActionTest.java b/src/test/java/formflow/library/framework/BeforeSaveActionTest.java index c84c0009b..4b174e01c 100644 --- a/src/test/java/formflow/library/framework/BeforeSaveActionTest.java +++ b/src/test/java/formflow/library/framework/BeforeSaveActionTest.java @@ -37,9 +37,8 @@ public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); mockMvc = MockMvcBuilders.standaloneSetup(screenController).build(); submission = Submission.builder().id(submissionUUID).inputData(new HashMap<>()).build(); - + setFlowInfoInSession(session, "testFlow", submission.getId()); super.setUp(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); } @@ -49,7 +48,8 @@ void shouldSaveFormattedDate() throws Exception { Map.of( "dateMonth", List.of("1"), "dateDay", List.of("2"), - "dateYear", List.of("1934"))); + "dateYear", List.of("1934")) + ); assertThat(submission.getInputData().get("formattedDate")).isEqualTo("1/2/1934"); } @@ -57,7 +57,6 @@ void shouldSaveFormattedDate() throws Exception { @Test void shouldSaveTotalIncome() throws Exception { String subflowUuid = UUID.randomUUID().toString(); - Map sessionAttrs = new HashMap<>(); List> subflowList = new ArrayList<>(); subflowList.add(Map.of("uuid", subflowUuid)); @@ -69,10 +68,9 @@ void shouldSaveTotalIncome() throws Exception { "iterationIsComplete", true)); submission.getInputData().put("income", subflowList); - sessionAttrs.put("id", submission.getId()); postToUrlExpectingSuccess("/flow/testFlow/next", "/flow/testFlow/subflowReview", - Map.of("textInput", List.of("1000")), subflowUuid, sessionAttrs); + Map.of("textInput", List.of("1000")), subflowUuid); assertThat(submission.getInputData().get("totalIncome")).isEqualTo(6530.0); } diff --git a/src/test/java/formflow/library/framework/ConditionalNavigationTest.java b/src/test/java/formflow/library/framework/ConditionalNavigationTest.java index 06fe6531a..03a2d0738 100644 --- a/src/test/java/formflow/library/framework/ConditionalNavigationTest.java +++ b/src/test/java/formflow/library/framework/ConditionalNavigationTest.java @@ -1,15 +1,37 @@ package formflow.library.framework; +import static formflow.library.FormFlowController.SUBMISSION_MAP_NAME; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import formflow.library.data.Submission; +import formflow.library.data.SubmissionRepositoryService; import formflow.library.utilities.AbstractMockMvcTest; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.web.servlet.ResultActions; @SpringBootTest(properties = {"form-flow.path=flows-config/test-conditional-navigation.yaml"}) public class ConditionalNavigationTest extends AbstractMockMvcTest { + @SpyBean + private SubmissionRepositoryService submissionRepositoryService; + + @BeforeEach + public void setup() { + submission = Submission.builder().id(UUID.randomUUID()).urlParams(new HashMap<>()).inputData(new HashMap<>()).build(); + when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.of(submission)); + setFlowInfoInSession(session, + "testFlow", submission.getId() + ); + } + @Test void shouldGoToPageWhoseConditionIsSatisfied() throws Exception { continueExpectingNextPageTitle("first", "Second Page"); diff --git a/src/test/java/formflow/library/framework/CrossValidationTest.java b/src/test/java/formflow/library/framework/CrossValidationTest.java index a87c88709..a12be3d8e 100644 --- a/src/test/java/formflow/library/framework/CrossValidationTest.java +++ b/src/test/java/formflow/library/framework/CrossValidationTest.java @@ -1,5 +1,6 @@ package formflow.library.framework; +import static formflow.library.FormFlowController.SUBMISSION_MAP_NAME; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -25,8 +26,6 @@ public class CrossValidationTest extends AbstractMockMvcTest { Submission submission; - private MockMvc mockMvc; - @MockBean private SubmissionRepositoryService submissionRepositoryService; @@ -42,10 +41,10 @@ public void setUp() throws Exception { UUID submissionUUID = UUID.randomUUID(); mockMvc = MockMvcBuilders.standaloneSetup(screenController).build(); submission = Submission.builder().id(submissionUUID).inputData(new HashMap<>()).build(); - + setFlowInfoInSession(session, "testFlow", submission.getId()); super.setUp(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); + when(submissionRepositoryService.save(any())).thenReturn(submission); } @Test @@ -53,13 +52,15 @@ void shouldAcceptEmailWithPreference() throws Exception { postExpectingSuccess("contactInfoPreference", Map.of( "email", List.of("foo@bar.com"), - "howToContactYou[]", List.of("email"))); + "howToContactYou[]", List.of("email")) + ); } @Test void shouldAlsoDisplayFieldValidationMessages() throws Exception { postExpectingFailure("contactInfoPreference", - Map.of("email", List.of("malformed.com"), "howToContactYou[]", List.of("email"))); + Map.of("email", List.of("malformed.com"), "howToContactYou[]", List.of("email")) + ); assertPageHasInputError("contactInfoPreference", "email", INVALID_EMAIL_ERROR_MESSAGE); } @@ -68,7 +69,8 @@ void shouldAcceptPhoneNumberWithPreference() throws Exception { postExpectingSuccess("contactInfoPreference", Map.of( "phoneNumber", List.of("223-456-7891"), - "howToContactYou", List.of("phone"))); + "howToContactYou", List.of("phone")) + ); } @Test @@ -76,7 +78,8 @@ void shouldFailWithPhoneNumberPreferenceNoPhone() throws Exception { postExpectingFailure("contactInfoPreference", Map.of( "howToContactYou[]", List.of("", "phone"), - "phoneNumber", List.of(""))); + "phoneNumber", List.of("")) + ); assertPageHasInputError("contactInfoPreference", "phoneNumber", NO_PHONE_ERROR_MESSAGE); } @@ -86,7 +89,8 @@ void shouldFailWithEmailPreferenceNoEmail() throws Exception { postExpectingFailure("contactInfoPreference", Map.of( "howToContactYou[]", List.of("", "email"), - "email", List.of(""))); + "email", List.of("")) + ); assertPageHasInputError("contactInfoPreference", "email", NO_EMAIL_ERROR_MESSAGE); } @@ -97,7 +101,8 @@ void shouldDisplayErrorMessagesForBothPhoneAndEmailIfBothAreMissing() throws Exc Map.of( "howToContactYou[]", List.of("email", "phone"), "email", List.of(""), - "phoneNumber", List.of(""))); + "phoneNumber", List.of("")) + ); assertPageHasInputError("contactInfoPreference", "email", NO_EMAIL_ERROR_MESSAGE); assertPageHasInputError("contactInfoPreference", "phoneNumber", NO_PHONE_ERROR_MESSAGE); diff --git a/src/test/java/formflow/library/framework/OnPostActionTest.java b/src/test/java/formflow/library/framework/OnPostActionTest.java index 923b024a8..82346d932 100644 --- a/src/test/java/formflow/library/framework/OnPostActionTest.java +++ b/src/test/java/formflow/library/framework/OnPostActionTest.java @@ -1,8 +1,8 @@ package formflow.library.framework; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.any; import formflow.library.ScreenController; import formflow.library.data.Submission; @@ -18,7 +18,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @SpringBootTest(properties = {"form-flow.path=flows-config/test-on-post-action.yaml"}) @@ -26,8 +25,6 @@ public class OnPostActionTest extends AbstractMockMvcTest { Submission submission; - private MockMvc mockMvc; - @MockBean private SubmissionRepositoryService submissionRepositoryService; @@ -39,10 +36,10 @@ public void setUp() throws Exception { mockMvc = MockMvcBuilders.standaloneSetup(screenController).build(); UUID submissionUUID = UUID.randomUUID(); submission = Submission.builder().id(submissionUUID).inputData(new HashMap<>()).build(); - + setFlowInfoInSession(session, "testFlow", submission.getId()); super.setUp(); - when(submissionRepositoryService.findOrCreate(any())).thenReturn(submission); when(submissionRepositoryService.findById(any())).thenReturn(Optional.of(submission)); + when(submissionRepositoryService.save(any())).thenReturn(submission); } @Test @@ -51,7 +48,8 @@ void shouldSaveFormattedDataInNewFieldAndValidateSuccessfully() throws Exception Map.of( "dateMonth", List.of("1"), "dateDay", List.of("3"), - "dateYear", List.of("1999"))); + "dateYear", List.of("1999")) + ); assertThat(submission.getInputData().get("dateFull")).isEqualTo("1/3/1999"); } @@ -63,7 +61,8 @@ void shouldSaveFormattedDateInNewFieldAndFailValidation() throws Exception { Map.of( "dateMonth", List.of("abc"), "dateDay", List.of("1"), - "dateYear", List.of("1999"))); + "dateYear", List.of("1999")) + ); assertPageHasInputError("inputs", "dateFull", dateErrorMessage); } diff --git a/src/test/java/formflow/library/inputs/OtherTestFlow.java b/src/test/java/formflow/library/inputs/OtherTestFlow.java new file mode 100644 index 000000000..bb030d3ef --- /dev/null +++ b/src/test/java/formflow/library/inputs/OtherTestFlow.java @@ -0,0 +1,160 @@ +package formflow.library.inputs; + +import formflow.library.data.FlowInputs; +import formflow.library.data.validators.Money; +import formflow.library.data.validators.Phone; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.web.multipart.MultipartFile; + +@TestConfiguration +@SuppressWarnings("unused") +public class OtherTestFlow extends FlowInputs { + + @NotBlank(message = "{validations.make-sure-to-provide-a-first-name}") + String firstName; + + String textInput; + String areaInput; + String dateDay; + String dateMonth; + String dateYear; + @NotBlank(message = "Date may not be empty") + @Pattern(regexp = "\\d/\\d/\\d\\d\\d\\d", message = "Date must be in the format of mm/dd/yyyy") + String dateFull; + + String numberInput; + ArrayList checkboxSet; + ArrayList checkboxInput; + String radioInput; + String selectInput; + String moneyInput; + String phoneInput; + @Encrypted + String ssnInput; + @Encrypted + String ssnInputSubflow; + String stateInput; + @NotEmpty(message = "Please select at least one") + List favoriteFruitCheckbox; + + @NotBlank(message = "Don't leave this blank") + @Size(min = 2, message = "You must enter a value 2 characters or longer") + String inputWithMultipleValidations; + + @NotBlank(message = "Enter a value") + String inputWithSingleValidation; + + String householdMemberFirstName; + String householdMemberLastName; + String householdMemberRelationship; + String householdMemberRecentlyMovedToUS; + MultipartFile testFile; + String dropZoneTestInstance; + + @Positive() + String validatePositiveIfNotEmpty; + + @Email(message = "Please enter a valid email address.") + String email; + + String phoneNumber; + + ArrayList howToContactYou; + + @NotBlank + String validationOffStreetAddress1; + String validationOffStreetAddress2; + @NotBlank + String validationOffCity; + @NotBlank + String validationOffState; + @NotBlank + String validationOffZipCode; + + @NotBlank + String validationOnStreetAddress1; + String validationOnStreetAddress2; + @NotBlank + String validationOnCity; + @NotBlank + String validationOnState; + @NotBlank + String validationOnZipCode; + Boolean useValidatedValidationOn; + + // now lets test some fields in a subflow + String firstNameSubflow; + @NotBlank + String textInputSubflow; + @NotBlank + String areaInputSubflow; + @NotBlank + String dateSubflowDay; + @NotBlank + String dateSubflowMonth; + @NotBlank + String dateSubflowYear; + @NotBlank(message = "Date may not be empty") + @Pattern(regexp = "\\d/\\d/\\d\\d\\d\\d", message = "Date must be in the format of mm/dd/yyyy") + String dateSubflowFull; + + @NotBlank + @Max(value = 100) + String numberInputSubflow; + @NotEmpty + ArrayList checkboxSetSubflow; + @NotEmpty + ArrayList checkboxInputSubflow; + @NotBlank + String radioInputSubflow; + @NotBlank + String selectInputSubflow; + @NotBlank + @Money + String moneyInputSubflow; + @NotBlank + @Phone + String phoneInputSubflow; + + // now lets test some fields in the second page of a subflow + String firstNameSubflowPage2; + @NotBlank + String textInputSubflowPage2; + @NotBlank + String areaInputSubflowPage2; + @NotBlank + String dateSubflowPage2Day; + @NotBlank + String dateSubflowPage2Month; + @NotBlank + String dateSubflowPage2Year; + @NotBlank(message = "Date may not be empty") + @Pattern(regexp = "\\d/\\d/\\d\\d\\d\\d", message = "Date must be in the format of mm/dd/yyyy") + String dateSubflowPage2Full; + @NotBlank + @Max(value = 100) + String numberInputSubflowPage2; + @NotEmpty + ArrayList checkboxSetSubflowPage2; + @NotEmpty + ArrayList checkboxInputSubflowPage2; + @NotBlank + String radioInputSubflowPage2; + @NotBlank + String selectInputSubflowPage2; + @NotBlank + @Money + String moneyInputSubflowPage2; + @NotBlank + @Phone + String phoneInputSubflowPage2; +} diff --git a/src/test/java/formflow/library/inputs/UploadFlow.java b/src/test/java/formflow/library/inputs/UploadFlowA.java similarity index 79% rename from src/test/java/formflow/library/inputs/UploadFlow.java rename to src/test/java/formflow/library/inputs/UploadFlowA.java index c421811c4..03cd45766 100644 --- a/src/test/java/formflow/library/inputs/UploadFlow.java +++ b/src/test/java/formflow/library/inputs/UploadFlowA.java @@ -4,7 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration; @TestConfiguration -public class UploadFlow extends FlowInputs { +public class UploadFlowA extends FlowInputs { String uploadTest; } diff --git a/src/test/java/formflow/library/inputs/UploadFlowB.java b/src/test/java/formflow/library/inputs/UploadFlowB.java new file mode 100644 index 000000000..f4d543613 --- /dev/null +++ b/src/test/java/formflow/library/inputs/UploadFlowB.java @@ -0,0 +1,10 @@ +package formflow.library.inputs; + +import formflow.library.data.FlowInputs; +import org.springframework.boot.test.context.TestConfiguration; + +@TestConfiguration +public class UploadFlowB extends FlowInputs { + + String uploadTest; +} diff --git a/src/test/java/formflow/library/repository/SubmissionRepositoryServiceTest.java b/src/test/java/formflow/library/repository/SubmissionRepositoryServiceTest.java index 78a6ee27b..5b85fea46 100644 --- a/src/test/java/formflow/library/repository/SubmissionRepositoryServiceTest.java +++ b/src/test/java/formflow/library/repository/SubmissionRepositoryServiceTest.java @@ -36,7 +36,7 @@ void shouldSaveASubmissionWithUUID() { Submission firstSubmission = new Submission(); firstSubmission.setFlow("testFlow"); - submissionRepositoryService.save(firstSubmission); + firstSubmission = submissionRepositoryService.save(firstSubmission); assertThat(firstSubmission.getId()).isInstanceOf(UUID.class); } @@ -55,9 +55,9 @@ void shouldSaveSubmission() { .submittedAt(Date.from(timeNow)) .build(); - UUID submissionId = submissionRepositoryService.save(submission); + UUID subId = submissionRepositoryService.save(submission).getId(); - Optional savedSubmissionOptional = submissionRepositoryService.findById(submissionId); + Optional savedSubmissionOptional = submissionRepositoryService.findById(subId); Submission savedSubmission = savedSubmissionOptional.orElseThrow(); assertThat(savedSubmission.getFlow()).isEqualTo("testFlow"); assertThat(savedSubmission.getInputData()).isEqualTo(inputData); @@ -77,7 +77,7 @@ void shouldUpdateExistingSubmission() { .flow("testFlow") .submittedAt(Date.from(timeNow)) .build(); - submissionRepositoryService.save(submission); + submission = submissionRepositoryService.save(submission); var newInputData = Map.of( "newKey", "this is a new value", @@ -145,7 +145,7 @@ void findByIdShouldReturnsDecryptedField() { .submittedAt(Date.from(timeNow)) .build(); - UUID subId = submissionRepositoryService.save(submission); + UUID subId = submissionRepositoryService.save(submission).getId(); Submission dbSubmission = (submissionRepositoryService.findById(subId)).get(); assertThat(dbSubmission.getInputData().containsKey("ssnInput")).isTrue(); @@ -173,7 +173,7 @@ void saveShouldEncryptFieldInDB() { .submittedAt(Date.from(timeNow)) .build(); - UUID subId = submissionRepositoryService.save(submission); + UUID subId = submissionRepositoryService.save(submission).getId(); var query = entityManager.createQuery("SELECT s FROM Submission s WHERE s.id = :id"); query.setParameter("id", subId); @@ -208,7 +208,7 @@ void shouldSetCreatedAtAndUpdatedAtFields() { .flow("testFlow") .build(); - UUID id = submissionRepositoryService.save(submission); + UUID id = submissionRepositoryService.save(submission).getId(); Submission savedSubmission = submissionRepositoryService.findById(id).get(); assertThat(savedSubmission.getCreatedAt()).isInThePast(); diff --git a/src/test/java/formflow/library/utilities/AbstractBasePageTest.java b/src/test/java/formflow/library/utilities/AbstractBasePageTest.java index 84636cfc3..fa6d95dbc 100644 --- a/src/test/java/formflow/library/utilities/AbstractBasePageTest.java +++ b/src/test/java/formflow/library/utilities/AbstractBasePageTest.java @@ -74,7 +74,6 @@ public void takeSnapShot(String fileWithPath) { } protected void uploadFile(String filepath, String dzName) { - testPage.clickElementById("drag-and-drop-box-" + dzName); // is this needed? WebElement upload = driver.findElement(By.className("dz-hidden-input")); upload.sendKeys(TestUtils.getAbsoluteFilepathString(filepath)); await().until( diff --git a/src/test/java/formflow/library/utilities/AbstractMockMvcTest.java b/src/test/java/formflow/library/utilities/AbstractMockMvcTest.java index b231d498a..8443c3506 100644 --- a/src/test/java/formflow/library/utilities/AbstractMockMvcTest.java +++ b/src/test/java/formflow/library/utilities/AbstractMockMvcTest.java @@ -1,5 +1,6 @@ package formflow.library.utilities; +import static formflow.library.FormFlowController.SUBMISSION_MAP_NAME; import static formflow.library.utilities.TestUtils.resetSubmission; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -25,10 +26,13 @@ import java.nio.file.Path; import java.time.Clock; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -47,6 +51,7 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -70,7 +75,6 @@ public abstract class AbstractMockMvcTest { @Autowired protected MockMvc mockMvc; - protected MockHttpSession session = new MockHttpSession(); @Autowired @@ -89,6 +93,25 @@ void cleanup() { resetSubmission(); } + protected void setFlowInfoInSession(MockHttpSession mockHttpSession, Object... flowInfo) { + + if (flowInfo.length % 2 != 0) { + throw new IllegalArgumentException("Arguments should be paired flowName -> submission id (UUID)."); + } + + Iterator iterator = Arrays.stream(flowInfo).iterator(); + + Map flowMap = new HashMap<>(); + + while (iterator.hasNext()) { + String flowName = (String) iterator.next(); + UUID submissionId = (UUID) iterator.next(); + flowMap.put(flowName, submissionId); + } + + mockHttpSession.setAttribute(SUBMISSION_MAP_NAME, flowMap); + } + protected void postWithQueryParam(String pageName, String queryParam, String value) throws Exception { mockMvc.perform( @@ -99,7 +122,10 @@ protected void postWithQueryParam(String pageName, String queryParam, String val protected ResultActions getWithQueryParam(String pageName, String queryParam, String value) throws Exception { String getUrl = getUrlForPageName(pageName); - return mockMvc.perform(get(getUrl).queryParam(queryParam, value)) + return mockMvc.perform( + get(getUrl) + .queryParam(queryParam, value) + .session(session)) .andExpect(status().isOk()); } @@ -197,23 +223,20 @@ protected ResultActions postStartSubflowExpectingSuccess(String pageName) throws } // Post to a page with an arbitrary number of multi-value inputs - protected ResultActions postExpectingSuccess(String pageName, Map> params) - throws Exception { + protected ResultActions postExpectingSuccess(String pageName, Map> params) throws Exception { String postUrl = getUrlForPageName(pageName); return postToUrlExpectingSuccess(postUrl, postUrl + "/navigation", params); } // Post to a page with a single input that only accepts a single value - protected ResultActions postExpectingSuccess(String pageName, String inputName, String value) - throws Exception { + protected ResultActions postExpectingSuccess(String pageName, String inputName, String value) throws Exception { String postUrl = getUrlForPageName(pageName); var params = Map.of(inputName, List.of(value)); return postToUrlExpectingSuccess(postUrl, postUrl + "/navigation", params); } // Post to a page with a single input that accepts multiple values - protected ResultActions postExpectingSuccess(String pageName, String inputName, - List values) throws Exception { + protected ResultActions postExpectingSuccess(String pageName, String inputName, List values) throws Exception { String postUrl = getUrlForPageName(pageName); return postToUrlExpectingSuccess(postUrl, postUrl + "/navigation", Map.of(inputName, values)); } @@ -222,20 +245,21 @@ protected ResultActions postToUrlExpectingSuccess(String postUrl, String redirec Map> params) throws Exception { - return mockMvc.perform( - post(postUrl) - .with(csrf()) - .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .params(new LinkedMultiValueMap<>(params)) - ).andExpect(redirectedUrl(redirectUrl)); + MockHttpServletRequestBuilder post = post(postUrl) + .with(csrf()) + .session(session) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .params(new LinkedMultiValueMap<>(params)); + + return mockMvc.perform(post).andExpect(redirectedUrl(redirectUrl)); } protected ResultActions postToUrlExpectingSuccess(String postUrl, String redirectUrl, - Map> params, String id, Map sessionAttrs) throws + Map> params, String id) throws Exception { return mockMvc.perform( post(postUrl + '/' + id) - .sessionAttrs(sessionAttrs) + .session(session) .with(csrf()) .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) .params(new LinkedMultiValueMap<>(params)) @@ -248,6 +272,7 @@ protected ResultActions postToUrlExpectingSuccessRedirectPattern(String postUrl, return mockMvc.perform( post(postUrl) .with(csrf()) + .session(session) .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) .params(new LinkedMultiValueMap<>(params)) ).andExpect(redirectedUrlPattern(redirectUrlPattern)); @@ -345,6 +370,7 @@ protected ResultActions postExpectingFailure(String pageName, String inputName, String postUrl = getUrlForPageName(pageName); return mockMvc.perform( post(postUrl) + .session(session) .with(csrf()) .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) .param(inputName, value) @@ -360,20 +386,17 @@ protected ResultActions postExpectingFailure(String pageName, Map> params, - String redirectUrl) - throws Exception { + String redirectUrl) throws Exception { String postUrl = getUrlForPageName(pageName); - return mockMvc.perform( - post(postUrl) - .with(csrf()) - .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .params(new LinkedMultiValueMap<>(params)) - ).andExpect(redirectedUrl(getUrlForPageName(redirectUrl))); + MockHttpServletRequestBuilder post = post(postUrl) + .session(session) + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .params(new LinkedMultiValueMap<>(params)); + + return mockMvc.perform(post).andExpect(redirectedUrl(getUrlForPageName(redirectUrl))); } protected ResultActions postExpectingFailures(String pageName, Map params) @@ -535,7 +558,8 @@ protected void assertPageHasWarningMessage(String pageName, String warningMessag @NotNull protected ResultActions getPage(String pageName) throws Exception { // TODO - remove assumption that flow is named testFlow - may not always be - return mockMvc.perform(get("/flow/testFlow/" + pageName)); + MockHttpServletRequestBuilder get = get("/flow/testFlow/" + pageName).session(session); + return mockMvc.perform(get); } @NotNull @@ -560,7 +584,7 @@ private String followRedirectsForPageName(String currentPageName) throws Excepti var nextPage = "/flow/testFlow/" + currentPageName + "/navigation"; while (Objects.requireNonNull(nextPage).contains("/navigation")) { // follow redirects - nextPage = mockMvc.perform(get(nextPage)) + nextPage = mockMvc.perform(get(nextPage).session(session)) .andExpect(status().is3xxRedirection()).andReturn() .getResponse() .getRedirectedUrl(); @@ -572,7 +596,7 @@ private String followRedirectsForUrl(String currentPageUrl) throws Exception { var nextPage = currentPageUrl; while (Objects.requireNonNull(nextPage).contains("/navigation")) { // follow redirects - nextPage = mockMvc.perform(get(nextPage)) + nextPage = mockMvc.perform(get(nextPage).session(session)) .andExpect(status().is3xxRedirection()).andReturn() .getResponse() .getRedirectedUrl(); diff --git a/src/test/resources/flows-config/test-flow.yaml b/src/test/resources/flows-config/test-flow.yaml index 190792f03..90f6f4da7 100644 --- a/src/test/resources/flows-config/test-flow.yaml +++ b/src/test/resources/flows-config/test-flow.yaml @@ -48,4 +48,27 @@ subflows: entryScreen: testEntryScreen iterationStartScreen: subflowAddItem reviewScreen: testReviewScreen - deleteConfirmationScreen: testDeleteConfirmationScreen \ No newline at end of file + deleteConfirmationScreen: testDeleteConfirmationScreen +--- +name: otherTestFlow +flow: + inputs: + nextScreens: + - name: test + subflowAddItem: + subflow: testSubflow + nextScreens: + - name: subflowAddItemPage2 + subflowAddItemPage2: + subflow: testSubflow + nextScreens: + - name: test + test: + nextScreens: + - name: success +subflows: + testSubflow: + entryScreen: testEntryScreen + iterationStartScreen: subflowAddItem + reviewScreen: testReviewScreen + deleteConfirmationScreen: testDeleteConfirmationScreen diff --git a/src/test/resources/flows-config/test-upload-flow.yaml b/src/test/resources/flows-config/test-upload-flow.yaml index 271fe0238..278fb2ff6 100644 --- a/src/test/resources/flows-config/test-upload-flow.yaml +++ b/src/test/resources/flows-config/test-upload-flow.yaml @@ -1,4 +1,11 @@ -name: uploadFlow +name: uploadFlowA +flow: + docUploadUnit: + nextScreens: null + docUploadJourney: + nextScreens: null +--- +name: uploadFlowB flow: docUploadUnit: nextScreens: null diff --git a/src/test/resources/templates/otherTestFlow/inputs.html b/src/test/resources/templates/otherTestFlow/inputs.html new file mode 100644 index 000000000..aa2b7e1a7 --- /dev/null +++ b/src/test/resources/templates/otherTestFlow/inputs.html @@ -0,0 +1,101 @@ + + + + +
+
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+ + + diff --git a/src/test/resources/templates/otherTestFlow/subflowAddItem.html b/src/test/resources/templates/otherTestFlow/subflowAddItem.html new file mode 100644 index 000000000..33e9d729b --- /dev/null +++ b/src/test/resources/templates/otherTestFlow/subflowAddItem.html @@ -0,0 +1,89 @@ + + + + +
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + + diff --git a/src/test/resources/templates/otherTestFlow/subflowAddItemPage2.html b/src/test/resources/templates/otherTestFlow/subflowAddItemPage2.html new file mode 100644 index 000000000..a8aec541a --- /dev/null +++ b/src/test/resources/templates/otherTestFlow/subflowAddItemPage2.html @@ -0,0 +1,85 @@ + + + + +
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + + diff --git a/src/test/resources/templates/otherTestFlow/success.html b/src/test/resources/templates/otherTestFlow/success.html new file mode 100644 index 000000000..0b6933f78 --- /dev/null +++ b/src/test/resources/templates/otherTestFlow/success.html @@ -0,0 +1,26 @@ + + + + +
+
+
+
+
+
+ + +
+

Congratulations, you did it! 🎉

+
+ +
+
+
+
+
+ + + diff --git a/src/test/resources/templates/otherTestFlow/test.html b/src/test/resources/templates/otherTestFlow/test.html new file mode 100644 index 000000000..c53739d06 --- /dev/null +++ b/src/test/resources/templates/otherTestFlow/test.html @@ -0,0 +1,21 @@ + + + + +
+
+
+
+
+ + +
+
+
+
+ + + diff --git a/src/test/resources/templates/testFlow/inputs.html b/src/test/resources/templates/testFlow/inputs.html index 7a57cb1e3..2ab3f896d 100644 --- a/src/test/resources/templates/testFlow/inputs.html +++ b/src/test/resources/templates/testFlow/inputs.html @@ -87,7 +87,8 @@ placeholderText='State')}"/>
diff --git a/src/test/resources/templates/uploadFlow/docUploadJourney.html b/src/test/resources/templates/uploadFlowA/docUploadJourney.html similarity index 100% rename from src/test/resources/templates/uploadFlow/docUploadJourney.html rename to src/test/resources/templates/uploadFlowA/docUploadJourney.html diff --git a/src/test/resources/templates/uploadFlow/docUploadUnit.html b/src/test/resources/templates/uploadFlowA/docUploadUnit.html similarity index 100% rename from src/test/resources/templates/uploadFlow/docUploadUnit.html rename to src/test/resources/templates/uploadFlowA/docUploadUnit.html diff --git a/src/test/resources/templates/uploadFlowB/docUploadJourney.html b/src/test/resources/templates/uploadFlowB/docUploadJourney.html new file mode 100644 index 000000000..9c8cc83c9 --- /dev/null +++ b/src/test/resources/templates/uploadFlowB/docUploadJourney.html @@ -0,0 +1,34 @@ + + + + +
+
+
+
+
+
+ + + +
+ +
+ +
+
+
+
+
+
+ + + diff --git a/src/test/resources/templates/uploadFlowB/docUploadUnit.html b/src/test/resources/templates/uploadFlowB/docUploadUnit.html new file mode 100644 index 000000000..0708ca841 --- /dev/null +++ b/src/test/resources/templates/uploadFlowB/docUploadUnit.html @@ -0,0 +1,40 @@ + + + + + + + +
+
+
+
+
+
+
+
+ + + +
+ +
+ +
+
+
+
+
+
+ + + diff --git a/src/test/resources/testA.jpeg b/src/test/resources/testA.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..b299ccc670bc47d7969460214fe493103d8d0914 GIT binary patch literal 51665 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%0A^t(MbH5)N_$2#>(a)<2%rnKx$-lap}yo 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 Sdki-g@>NR-T)Af%jQ`nm)P}qO literal 0 HcmV?d00001 diff --git a/src/test/resources/testB.jpeg b/src/test/resources/testB.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..b299ccc670bc47d7969460214fe493103d8d0914 GIT binary patch literal 51665 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%0A^t(MbH5)N_$2#>(a)<2%rnKx$-lap}yo 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 Sdki-g@>NR-T)Af%jQ`nm)P}qO literal 0 HcmV?d00001