diff --git a/src/com/fuse/api/assessments.java b/src/com/fuse/api/assessments.java index 3ca0c29d..be117bdb 100644 --- a/src/com/fuse/api/assessments.java +++ b/src/com/fuse/api/assessments.java @@ -136,6 +136,59 @@ public Response getAssessments( } } + /* + * getAllAssessments - every assessment the caller can see, open and completed + */ + @GET + @ApiOperation(value = "Gets every assessment visible to the caller, open and completed.", notes = "Unlike /queue (open assessments assigned to the caller) and /completed (completed within a date range), this returns the caller's full visible inventory in one call. Managers with unrestricted access get every assessment; other callers get the same subset the UI would show them. Intended for bulk export and migration.", response = AssessmentDTO.class, responseContainer = "List", position = 110) + @ApiResponses(value = { @ApiResponse(code = 401, message = "Not Authorized"), + @ApiResponse(code = 200, message = "All Visible Assessments Returned") }) + @Produces(MediaType.APPLICATION_JSON) + @Path("/all") + public Response getAllAssessments( + @ApiParam(value = "Authentication Header", required = true) @HeaderParam("FACTION-API-KEY") String apiKey, + @ApiParam(value = "Include base64-encoded images (default false). When false, returns links to Faction images; when true, converts images to base64 data URLs.", required = false) @QueryParam("includeBase64Images") Boolean includeBase64Images) { + + EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); + List dtos = new ArrayList<>(); + try { + User u = Support.getUser(em, apiKey); + if (u == null || !(u.getPermissions().isAssessor() || u.getPermissions().isManager() + || u.getPermissions().isAdmin())) { + return Response.status(401).entity(String.format(Support.ERROR, "Not Authorized")).build(); + } + + try { + List asmts = AssessmentQueries.getAllAssessments(em, u, AssessmentQueries.All); + for (Assessment a : asmts) { + if (Boolean.TRUE.equals(includeBase64Images)) { + AssessmentQueries.updateImages(a); + } + AssessmentDTO dto = AssessmentDTO.fromEntity(a); + + if (a.getCustomFields() != null) { + dto.setCustomFieldsFromEntity(a.getCustomFields()); + } + + dtos.add(dto); + } + } catch (Exception ex) { + ex.printStackTrace(); + return Response.status(500).entity(String.format(Support.ERROR, "Error retrieving assessments")) + .build(); + } + } finally { + em.close(); + } + + try { + ObjectMapper mapper = new ObjectMapper(); + return Response.status(200).entity(mapper.writeValueAsString(dtos)).build(); + } catch (JsonProcessingException e) { + return Response.status(500).entity(String.format(Support.ERROR, "Failed to serialize response")).build(); + } + } + /* * getAssessment - get assessment details by ID */ @@ -221,7 +274,7 @@ public Response getAssessment( * downloadReport - Download the final report for an assessment */ @GET - @ApiOperation(value = "Downloads the final report (PDF/DOCX) for an assessment", notes = "Returns the base64-encoded report as a binary download", position = 200) + @ApiOperation(value = "Downloads the final report (PDF/DOCX) for an assessment", notes = "Returns the report as a binary download. Pass type=docx or type=pdf to pick a variant; with no type the PDF is preferred and the first available variant is the fallback. Pass retest=true for the retest report.", position = 200) @ApiResponses(value = { @ApiResponse(code = 401, message = "Not Authorized"), @ApiResponse(code = 404, message = "Assessment or Report not found"), @@ -229,7 +282,9 @@ public Response getAssessment( @Path("/report/{aid}") public Response downloadReport( @ApiParam(value = "Authentication Header", required = true) @HeaderParam("FACTION-API-KEY") String apiKey, - @ApiParam(value = "Assessment ID", required = true) @PathParam("aid") Long aid) { + @ApiParam(value = "Assessment ID", required = true) @PathParam("aid") Long aid, + @ApiParam(value = "Variant to download: docx or pdf. Omit to prefer the PDF.", required = false) @QueryParam("type") String type, + @ApiParam(value = "Download the retest report instead of the final report (default false).", required = false) @QueryParam("retest") Boolean retest) { EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); try { @@ -243,64 +298,87 @@ public Response downloadReport( } Assessment assessment = AssessmentQueries.getAssessment(em, u, aid); - if (assessment == null) { - return Response.status(404).entity(String.format(Support.ERROR, "Assessment not found or access denied")).build(); - } - - FinalReport finalReport = assessment.getFinalReport(); - if (finalReport == null) { - return Response.status(404).entity(String.format(Support.ERROR, "No final report available for this assessment")).build(); - } + return invokeDownloadReport(u, assessment, aid, type, retest); - FinalReportVariant variant = finalReport.getEffectiveVariants().stream() - .filter(v -> "pdf".equals(v.getFileType())) - .findFirst() - .orElse(finalReport.getEffectiveVariants().get(0)); - - String b64Rpt = variant.getBase64Content(); - if (b64Rpt == null || b64Rpt.isEmpty()) { - return Response.status(404).entity(String.format(Support.ERROR, "Report data is empty")).build(); - } - - byte[] report; - try { - report = Base64.decodeBase64(b64Rpt.getBytes()); - } catch (Exception e) { - return Response.status(500).entity(String.format(Support.ERROR, "Failed to decode report")).build(); - } + } catch (Exception e) { + e.printStackTrace(); + return Response.status(500).entity(String.format(Support.ERROR, "Error downloading report")).build(); + } finally { + em.close(); + } + } - if (report == null || report.length == 0) { - return Response.status(500).entity(String.format(Support.ERROR, "Report is empty")).build(); - } + /* + * reportInfo - which report variants exist, without shipping the blobs + */ + @GET + @ApiOperation(value = "Lists the report variants available for an assessment", notes = "Reports the file types (docx, pdf) held for the final and retest reports so a caller can choose what to download without pulling every blob. An assessment with no report returns empty lists.", position = 205) + @ApiResponses(value = { + @ApiResponse(code = 401, message = "Not Authorized"), + @ApiResponse(code = 404, message = "Assessment not found"), + @ApiResponse(code = 200, message = "Returns the available report variants") }) + @Produces(MediaType.APPLICATION_JSON) + @Path("/report/{aid}/info") + public Response reportInfo( + @ApiParam(value = "Authentication Header", required = true) @HeaderParam("FACTION-API-KEY") String apiKey, + @ApiParam(value = "Assessment ID", required = true) @PathParam("aid") Long aid) { - String contentType; - String filename; - if ("pdf".equals(variant.getFileType())) { - contentType = "application/pdf"; - filename = "Report.pdf"; - } else { - contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - filename = "Report.docx"; + EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); + try { + User u = Support.getUser(em, apiKey); + if (u == null || !(u.getPermissions().isAssessor() || u.getPermissions().isManager() + || u.getPermissions().isAdmin())) { + return Response.status(401).entity(String.format(Support.ERROR, "Not Authorized")).build(); } - if (finalReport.getRetest() != null && finalReport.getRetest()) { - filename = "Retest " + filename; + Assessment assessment = AssessmentQueries.getAssessment(em, u, aid); + if (assessment == null) { + return Response.status(404) + .entity(String.format(Support.ERROR, "Assessment not found or access denied")).build(); } - String downloadFilename = assessment.getName() + " - " + assessment.getType().getType() + " " + filename; - - return buildReportResponse(report, downloadFilename); + JSONObject info = new JSONObject(); + info.put("assessmentId", aid); + info.put("final", variantTypes(assessment.getFinalReport())); + info.put("retest", variantTypes(assessment.getRetestReport())); + // Whether the PDF is password protected in FACTION 1. The password itself is + // deliberately not exposed; a consumer re-protects with its own. + info.put("finalEncrypted", assessment.getFinalReport() != null + && assessment.getFinalReport().getEncryptedReportPassword() != null); + info.put("retestEncrypted", assessment.getRetestReport() != null + && assessment.getRetestReport().getEncryptedReportPassword() != null); + return Response.status(200).entity(info.toJSONString()).build(); } catch (Exception e) { e.printStackTrace(); - return Response.status(500).entity(String.format(Support.ERROR, "Error downloading report")).build(); + return Response.status(500).entity(String.format(Support.ERROR, "Error reading report info")).build(); } finally { em.close(); } } + // The file types held by a report, empty when the report is absent. A variant with no + // content is left out so callers don't request a download that can only 404. + static JSONArray variantTypes(FinalReport report) { + JSONArray types = new JSONArray(); + if (report == null) { + return types; + } + for (FinalReportVariant variant : report.getEffectiveVariants()) { + String content = variant.getBase64Content(); + if (content != null && !content.isEmpty()) { + types.add(variant.getFileType()); + } + } + return types; + } + // Package-private method for testing — bypasses DB and uses pre-built Assessment/User Response invokeDownloadReport(User user, Assessment assessment, Long aid) { + return invokeDownloadReport(user, assessment, aid, null, null); + } + + Response invokeDownloadReport(User user, Assessment assessment, Long aid, String type, Boolean retest) { try { if (user == null) { return Response.status(401).entity(String.format(Support.ERROR, "Not Authorized")).build(); @@ -314,15 +392,19 @@ Response invokeDownloadReport(User user, Assessment assessment, Long aid) { return Response.status(404).entity(String.format(Support.ERROR, "Assessment not found or access denied")).build(); } - FinalReport finalReport = assessment.getFinalReport(); + boolean wantRetest = Boolean.TRUE.equals(retest); + FinalReport finalReport = wantRetest ? assessment.getRetestReport() : assessment.getFinalReport(); if (finalReport == null) { - return Response.status(404).entity(String.format(Support.ERROR, "No final report available for this assessment")).build(); + return Response.status(404).entity(String.format(Support.ERROR, + wantRetest ? "No retest report available for this assessment" + : "No final report available for this assessment")).build(); } - FinalReportVariant variant = finalReport.getEffectiveVariants().stream() - .filter(v -> "pdf".equals(v.getFileType())) - .findFirst() - .orElse(finalReport.getEffectiveVariants().get(0)); + FinalReportVariant variant = selectVariant(finalReport, type); + if (variant == null) { + return Response.status(404).entity(String.format(Support.ERROR, + "No '" + type + "' variant available for this report")).build(); + } String b64Rpt = variant.getBase64Content(); if (b64Rpt == null || b64Rpt.isEmpty()) { @@ -330,21 +412,34 @@ Response invokeDownloadReport(User user, Assessment assessment, Long aid) { } byte[] report; - report = Base64.decodeBase64(b64Rpt.getBytes()); + try { + report = Base64.decodeBase64(b64Rpt.getBytes()); + } catch (Exception e) { + return Response.status(500).entity(String.format(Support.ERROR, "Failed to decode report")).build(); + } if (report == null || report.length == 0) { return Response.status(500).entity(String.format(Support.ERROR, "Report is empty")).build(); } - String filename = "pdf".equals(variant.getFileType()) ? "Report.pdf" : "Report.docx"; + String contentType; + String filename; + if ("pdf".equals(variant.getFileType())) { + contentType = "application/pdf"; + filename = "Report.pdf"; + } else { + contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + filename = "Report.docx"; + } - if (finalReport.getRetest() != null && finalReport.getRetest()) { + if (wantRetest || (finalReport.getRetest() != null && finalReport.getRetest())) { filename = "Retest " + filename; } - String downloadFilename = assessment.getName() + " - " + assessment.getType().getType() + " " + filename; + String assessmentType = assessment.getType() == null ? "" : assessment.getType().getType() + " "; + String downloadFilename = assessment.getName() + " - " + assessmentType + filename; - return buildReportResponse(report, downloadFilename); + return buildReportResponse(report, downloadFilename, contentType); } catch (Exception e) { e.printStackTrace(); @@ -352,8 +447,35 @@ Response invokeDownloadReport(User user, Assessment assessment, Long aid) { } } + /** + * Picks the variant to serve. A named type must match exactly — falling back to some + * other format would hand the caller a file it did not ask for, which a migration + * would then mis-file. With no type the PDF wins, then whatever the report has. + */ + static FinalReportVariant selectVariant(FinalReport report, String type) { + List variants = report.getEffectiveVariants(); + if (variants == null || variants.isEmpty()) { + return null; + } + if (type != null && !type.trim().isEmpty()) { + String wanted = type.trim().toLowerCase(); + return variants.stream() + .filter(v -> wanted.equals(v.getFileType())) + .findFirst() + .orElse(null); + } + return variants.stream() + .filter(v -> "pdf".equals(v.getFileType())) + .findFirst() + .orElse(variants.get(0)); + } + Response buildReportResponse(byte[] report, String filename) { - return Response.ok(report) + return buildReportResponse(report, filename, MediaType.APPLICATION_OCTET_STREAM); + } + + Response buildReportResponse(byte[] report, String filename, String contentType) { + return Response.ok(report, contentType) .header("Content-Disposition", "attachment; filename=\"" + sanitizeFilename(filename) + "\"") .header("Cache-Control", "no-cache, no-store, must-revalidate") .header("Pragma", "no-cache") diff --git a/src/com/fuse/api/dto/AssessmentDTO.java b/src/com/fuse/api/dto/AssessmentDTO.java index ab4462b2..5ed3aa92 100644 --- a/src/com/fuse/api/dto/AssessmentDTO.java +++ b/src/com/fuse/api/dto/AssessmentDTO.java @@ -178,8 +178,14 @@ public static AssessmentDTO fromEntity(Assessment assessment) { dto.setCampaignId(assessment.getCampaign().getId()); } - dto.setStart(""+assessment.getStart().getTime()); - dto.setEnd(""+assessment.getEnd().getTime()); + // Guarded: a bulk listing sweeps up drafts that never had dates set, and an + // unguarded getTime() would fail the whole page over one of them. + if (assessment.getStart() != null) { + dto.setStart("" + assessment.getStart().getTime()); + } + if (assessment.getEnd() != null) { + dto.setEnd("" + assessment.getEnd().getTime()); + } dto.setCompleted(assessment.getCompleted()); dto.setStatus(assessment.getStatus()); if(assessment.getNotebook() == null || assessment.getNotebook().size() == 0) { diff --git a/src/com/fuse/api/dto/VulnerabilityDTO.java b/src/com/fuse/api/dto/VulnerabilityDTO.java index 77e74a18..cd69874c 100644 --- a/src/com/fuse/api/dto/VulnerabilityDTO.java +++ b/src/com/fuse/api/dto/VulnerabilityDTO.java @@ -80,6 +80,27 @@ public class VulnerabilityDTO { @JsonProperty("Created") private String created; + + @JsonProperty("Status") + private String status; + + @JsonProperty("Opened") + private String opened; + + @JsonProperty("Closed") + private String closed; + + @JsonProperty("DevClosed") + private String devClosed; + + @JsonProperty("StagingClosed") + private String stagingClosed; + + @JsonProperty("Category") + private String category; + + @JsonProperty("CategoryId") + private Long categoryId; @JsonProperty("CustomFields") private List customFields = new ArrayList<>(); @@ -131,16 +152,40 @@ public static VulnerabilityDTO fromEntity(Vulnerability vuln) { dto.setSection(""); } - dto.setAssessmentId(vuln.getAssessmentId()); + // 0 means "not attached to an assessment"; leaving it null keeps it out of the + // payload rather than pointing consumers at assessment 0. + if (vuln.getAssessmentId() > 0) { + dto.setAssessmentId(vuln.getAssessmentId()); + } // Convert created date to string (timestamp) if (vuln.getCreated() != null) { dto.setCreated(String.valueOf(vuln.getCreated().getTime())); } - + + // Lifecycle: the status and the dates that back it. Migrations and external + // trackers need these to reproduce a finding's state, not just its severity. + dto.setStatus(vuln.getStatus()); + dto.setOpened(epoch(vuln.getOpened())); + dto.setClosed(epoch(vuln.getClosed())); + dto.setDevClosed(epoch(vuln.getDevClosed())); + dto.setStagingClosed(epoch(vuln.getStagingClosed())); + + if (vuln.getCategory() != null) { + dto.setCategory(vuln.getCategory().getName()); + dto.setCategoryId(vuln.getCategory().getId()); + } + return dto; } + /** + * Dates cross the wire as epoch-millisecond strings, matching {@code Created}. + */ + private static String epoch(java.util.Date date) { + return date == null ? null : String.valueOf(date.getTime()); + } + /** * Convert severity number to string representation */ @@ -361,4 +406,60 @@ public String getCreated() { public void setCreated(String created) { this.created = created; } -} \ No newline at end of file + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getOpened() { + return opened; + } + + public void setOpened(String opened) { + this.opened = opened; + } + + public String getClosed() { + return closed; + } + + public void setClosed(String closed) { + this.closed = closed; + } + + public String getDevClosed() { + return devClosed; + } + + public void setDevClosed(String devClosed) { + this.devClosed = devClosed; + } + + public String getStagingClosed() { + return stagingClosed; + } + + public void setStagingClosed(String stagingClosed) { + this.stagingClosed = stagingClosed; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } +} diff --git a/src/com/fuse/api/users.java b/src/com/fuse/api/users.java index 02d87878..f3a1afa1 100644 --- a/src/com/fuse/api/users.java +++ b/src/com/fuse/api/users.java @@ -8,13 +8,18 @@ import javax.servlet.http.HttpServletRequest; import javax.ws.rs.Consumes; import javax.ws.rs.FormParam; +import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.Produces; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + import com.fuse.api.util.Support; import com.fuse.dao.HibHelper; import com.fuse.dao.PasswordReset; @@ -35,6 +40,46 @@ @Path("/users") public class users { + @GET + @ApiOperation(value = "Lists every Faction user.", notes = "Returns the directory — id, username, email, name, and whether the account is inactive —" + + " so that an integration can resolve the user ids that appear on assessments and vulnerabilities." + + " Requires an admin or manager API key. Password hashes and tokens are never included.", position = 5) + @ApiResponses(value = { @ApiResponse(code = 401, message = "Not Authorized"), + @ApiResponse(code = 200, message = "Request Successfull") }) + @Produces(MediaType.APPLICATION_JSON) + @Path("/all") + public Response getAllUsers( + @ApiParam(value = "Authentication Header", required = true) @HeaderParam("FACTION-API-KEY") String apiKey) { + + EntityManager em = HibHelper.getInstance().getEMF().createEntityManager(); + try { + User caller = Support.getUser(em, apiKey); + if (caller == null || caller.getPermissions() == null + || !(caller.getPermissions().isAdmin() || caller.getPermissions().isManager())) { + return Support.autherror(); + } + + List users = em.createQuery("from User").getResultList(); + JSONArray array = new JSONArray(); + for (User u : users) { + JSONObject obj = new JSONObject(); + obj.put("id", u.getId()); + obj.put("username", u.getUsername()); + obj.put("email", u.getEmail()); + obj.put("firstName", u.getFname()); + obj.put("lastName", u.getLname()); + obj.put("inactive", u.isInActive()); + array.add(obj); + } + return Response.status(200).entity(array.toJSONString()).build(); + } catch (Exception ex) { + ex.printStackTrace(); + return Response.status(500).entity(String.format(Support.ERROR, "Error retrieving users")).build(); + } finally { + em.close(); + } + } + @POST @ApiOperation(value = "Add a user to Faction.", notes = "This call will give you the ability to create a user in Faction. If the user already exists then" + " an error will be returned. You can choose to send the user an email confirmation link or create " diff --git a/src/com/fuse/dao/Vulnerability.java b/src/com/fuse/dao/Vulnerability.java index abb0449f..f83c9f2b 100644 --- a/src/com/fuse/dao/Vulnerability.java +++ b/src/com/fuse/dao/Vulnerability.java @@ -138,8 +138,14 @@ public void setRecommendation(String recommendation) { recommendation = FSUtils.sanitizeHTML(recommendation); this.recommendation = recommendation; } + /** + * 0 when the vulnerability is not attached to an assessment. The field is a nullable + * Long behind a primitive getter, so returning it raw unboxes a null and throws — + * which took down every read path that touched an unattached finding. Callers already + * treat 0 as "no assessment" (see AppBootstrapListener). + */ public long getAssessmentId() { - return assessmentId; + return assessmentId == null ? 0L : assessmentId; } public void setAssessmentId(long assessmentId) { this.assessmentId = assessmentId; diff --git a/test/com/fuse/api/MigrationExportAPITest.java b/test/com/fuse/api/MigrationExportAPITest.java new file mode 100644 index 00000000..ba070a10 --- /dev/null +++ b/test/com/fuse/api/MigrationExportAPITest.java @@ -0,0 +1,320 @@ +package com.fuse.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import org.apache.commons.codec.binary.Base64; +import org.json.simple.JSONArray; +import org.junit.Before; +import org.junit.Test; + +import javax.ws.rs.core.Response; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fuse.api.dto.VulnerabilityDTO; +import com.fuse.dao.Assessment; +import com.fuse.dao.AssessmentType; +import com.fuse.dao.Category; +import com.fuse.dao.FinalReport; +import com.fuse.dao.FinalReportVariant; +import com.fuse.dao.Permissions; +import com.fuse.dao.RiskLevel; +import com.fuse.dao.User; +import com.fuse.dao.Vulnerability; + +/** + * Covers the API surface a bulk export or migration depends on: choosing a specific + * report variant, listing which variants exist, and the vulnerability lifecycle fields + * (status and its backing dates) that an importer needs to reproduce a finding's state. + */ +public class MigrationExportAPITest { + + private assessments api; + private User manager; + private Assessment assessment; + + @Before + public void setUp() { + api = new assessments(); + + Permissions perms = new Permissions(); + perms.setManager(true); + perms.setAssessor(true); + perms.setAdmin(true); + + manager = new User(); + manager.setId(1L); + manager.setUsername("manager"); + manager.setEmail("manager@example.com"); + manager.setPermissions(perms); + + AssessmentType type = new AssessmentType(); + type.setId(1L); + type.setType("Web Application"); + + assessment = new Assessment(); + assessment.setId(100L); + assessment.setName("Payments Portal"); + assessment.setAppId("APP-1"); + assessment.setType(type); + assessment.setStart(new Date()); + assessment.setEnd(new Date()); + assessment.setVulns(new ArrayList<>()); + } + + private static FinalReportVariant variant(String fileType, String content) { + FinalReportVariant v = new FinalReportVariant(); + v.setFileType(fileType); + v.setBase64Content(Base64.encodeBase64String(content.getBytes())); + return v; + } + + private static FinalReport reportWith(FinalReportVariant... variants) { + FinalReport report = new FinalReport(); + report.setVariants(new ArrayList<>(Arrays.asList(variants))); + return report; + } + + // ── selectVariant ──────────────────────────────────────────────────────── + + @Test + public void selectVariantPrefersPdfWhenNoTypeRequested() { + FinalReport report = reportWith(variant("docx", "DOCX BODY"), variant("pdf", "PDF BODY")); + + assertEquals("pdf", assessments.selectVariant(report, null).getFileType()); + assertEquals("pdf", assessments.selectVariant(report, "").getFileType()); + } + + @Test + public void selectVariantHonoursAnExplicitType() { + FinalReport report = reportWith(variant("docx", "DOCX BODY"), variant("pdf", "PDF BODY")); + + assertEquals("docx", assessments.selectVariant(report, "docx").getFileType()); + assertEquals("docx", assessments.selectVariant(report, " DOCX ").getFileType()); + } + + @Test + public void selectVariantReturnsNullRatherThanASubstituteFormat() { + // A caller that asked for a DOCX must not be handed a PDF — a migration would + // file it under the wrong document type. + FinalReport report = reportWith(variant("pdf", "PDF BODY")); + + assertNull(assessments.selectVariant(report, "docx")); + } + + @Test + public void selectVariantFallsBackToTheOnlyVariantWhenThereIsNoPdf() { + FinalReport report = reportWith(variant("docx", "DOCX BODY")); + + assertEquals("docx", assessments.selectVariant(report, null).getFileType()); + } + + @Test + public void selectVariantReadsLegacyReportsWithNoVariantList() { + // Pre-variant reports keep their content on the FinalReport itself. + FinalReport legacy = new FinalReport(); + legacy.setFileType("pdf"); + legacy.setBase64EncodedPdf(Base64.encodeBase64String("LEGACY".getBytes())); + + assertEquals("pdf", assessments.selectVariant(legacy, null).getFileType()); + assertEquals("pdf", assessments.selectVariant(legacy, "pdf").getFileType()); + assertNull(assessments.selectVariant(legacy, "docx")); + } + + // ── variantTypes ───────────────────────────────────────────────────────── + + @Test + public void variantTypesListsEveryStoredFormat() { + JSONArray types = assessments.variantTypes( + reportWith(variant("docx", "DOCX BODY"), variant("pdf", "PDF BODY"))); + + assertEquals(2, types.size()); + assertTrue(types.contains("docx")); + assertTrue(types.contains("pdf")); + } + + @Test + public void variantTypesIsEmptyForAMissingReport() { + assertTrue(assessments.variantTypes(null).isEmpty()); + } + + @Test + public void variantTypesSkipsVariantsWithNoContent() { + // Advertising an empty variant would send the caller after a download that can only 404. + FinalReportVariant empty = new FinalReportVariant(); + empty.setFileType("pdf"); + + JSONArray types = assessments.variantTypes(reportWith(variant("docx", "DOCX BODY"), empty)); + + assertEquals(1, types.size()); + assertTrue(types.contains("docx")); + } + + // ── downloadReport ─────────────────────────────────────────────────────── + + @Test + public void downloadReturnsTheRequestedVariantWithItsOwnContentType() { + assessment.setFinalReport(reportWith(variant("docx", "DOCX BODY"), variant("pdf", "PDF BODY"))); + + Response docx = api.invokeDownloadReport(manager, assessment, 100L, "docx", false); + assertEquals(200, docx.getStatus()); + assertEquals("DOCX BODY", new String((byte[]) docx.getEntity())); + assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document", + docx.getMediaType().toString()); + + Response pdf = api.invokeDownloadReport(manager, assessment, 100L, "pdf", false); + assertEquals(200, pdf.getStatus()); + assertEquals("PDF BODY", new String((byte[]) pdf.getEntity())); + assertEquals("application/pdf", pdf.getMediaType().toString()); + } + + @Test + public void downloadWithNoTypeStillPrefersThePdf() { + assessment.setFinalReport(reportWith(variant("docx", "DOCX BODY"), variant("pdf", "PDF BODY"))); + + Response response = api.invokeDownloadReport(manager, assessment, 100L); + + assertEquals(200, response.getStatus()); + assertEquals("PDF BODY", new String((byte[]) response.getEntity())); + } + + @Test + public void downloadReturnsTheRetestReportWhenAsked() { + assessment.setFinalReport(reportWith(variant("pdf", "FINAL BODY"))); + assessment.setRetestReport(reportWith(variant("pdf", "RETEST BODY"))); + + Response response = api.invokeDownloadReport(manager, assessment, 100L, "pdf", true); + + assertEquals(200, response.getStatus()); + assertEquals("RETEST BODY", new String((byte[]) response.getEntity())); + String disposition = response.getHeaderString("Content-Disposition"); + assertTrue(disposition, disposition.contains("Retest Report.pdf")); + } + + @Test + public void downloadIs404WhenTheRequestedTypeIsAbsent() { + assessment.setFinalReport(reportWith(variant("pdf", "PDF BODY"))); + + Response response = api.invokeDownloadReport(manager, assessment, 100L, "docx", false); + + assertEquals(404, response.getStatus()); + } + + @Test + public void downloadIs404WhenThereIsNoRetestReport() { + assessment.setFinalReport(reportWith(variant("pdf", "PDF BODY"))); + + Response response = api.invokeDownloadReport(manager, assessment, 100L, null, true); + + assertEquals(404, response.getStatus()); + } + + @Test + public void downloadSurvivesAnAssessmentWithNoType() { + // Drafts imported from elsewhere can be missing a type; the filename should degrade, + // not throw. + assessment.setType(null); + assessment.setFinalReport(reportWith(variant("pdf", "PDF BODY"))); + + Response response = api.invokeDownloadReport(manager, assessment, 100L, "pdf", false); + + assertEquals(200, response.getStatus()); + assertTrue(response.getHeaderString("Content-Disposition").contains("Payments Portal - Report.pdf")); + } + + // ── VulnerabilityDTO lifecycle fields ──────────────────────────────────── + + @Test + public void vulnerabilityDtoCarriesStatusDatesAndCategory() throws Exception { + Category category = new Category(); + category.setId(7L); + category.setName("Injection"); + + Date opened = new Date(1_700_000_000_000L); + Date closed = new Date(1_700_086_400_000L); + Date devClosed = new Date(1_700_003_600_000L); + Date stagingClosed = new Date(1_700_007_200_000L); + + Vulnerability vuln = new Vulnerability(); + vuln.setId(42L); + vuln.setName("SQL Injection"); + vuln.setOverall(5L); + vuln.setImpact(5L); + vuln.setLikelyhood(4L); + vuln.setStatus(Vulnerability.StatusClosed); + vuln.setOpened(opened); + vuln.setClosed(closed); + vuln.setDevClosed(devClosed); + vuln.setStagingClosed(stagingClosed); + vuln.setCategory(category); + vuln.setAssessmentId(100L); + vuln.setLevels(riskLevels()); + + VulnerabilityDTO dto = VulnerabilityDTO.fromEntity(vuln); + + assertEquals("Closed", dto.getStatus()); + assertEquals(String.valueOf(opened.getTime()), dto.getOpened()); + assertEquals(String.valueOf(closed.getTime()), dto.getClosed()); + assertEquals(String.valueOf(devClosed.getTime()), dto.getDevClosed()); + assertEquals(String.valueOf(stagingClosed.getTime()), dto.getStagingClosed()); + assertEquals("Injection", dto.getCategory()); + assertEquals(Long.valueOf(7L), dto.getCategoryId()); + assertEquals(Long.valueOf(100L), dto.getAssessmentId()); + + // The names are what an importer maps against, so they must survive serialization. + String json = new ObjectMapper().writeValueAsString(dto); + assertTrue(json, json.contains("\"Status\":\"Closed\"")); + assertTrue(json, json.contains("\"Category\":\"Injection\"")); + assertTrue(json, json.contains("\"Opened\":\"" + opened.getTime() + "\"")); + } + + @Test + public void vulnerabilityDtoOmitsLifecycleFieldsThatAreNotSet() throws Exception { + Vulnerability vuln = new Vulnerability(); + vuln.setId(43L); + vuln.setName("Open Finding"); + vuln.setOverall(3L); + vuln.setStatus(Vulnerability.StatusOpen); + vuln.setOpened(new Date(1_700_000_000_000L)); + vuln.setLevels(riskLevels()); + + VulnerabilityDTO dto = VulnerabilityDTO.fromEntity(vuln); + + assertEquals("Open", dto.getStatus()); + assertNotNull(dto.getOpened()); + assertNull(dto.getClosed()); + assertNull(dto.getCategory()); + assertNull(dto.getCategoryId()); + // Never attached to an assessment, so the field stays out of the payload + // rather than unboxing a null or reporting assessment 0. + assertNull(dto.getAssessmentId()); + + String json = new ObjectMapper().writeValueAsString(dto); + assertFalse(json, json.contains("\"Closed\"")); + assertFalse(json, json.contains("\"Category\"")); + assertFalse(json, json.contains("\"AssessmentId\"")); + } + + private static List riskLevels() { + List levels = new ArrayList<>(); + levels.add(level(5, "Critical")); + levels.add(level(4, "High")); + levels.add(level(3, "Medium")); + return levels; + } + + private static RiskLevel level(int riskId, String name) { + RiskLevel level = new RiskLevel(); + level.setRiskId(riskId); + level.setRisk(name); + return level; + } +}