From a4f22c670fdae0564e602f100b8e07d057bd0cf8 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 3 Sep 2026 13:13:28 -0500 Subject: [PATCH] Expose what a bulk export needs from the assessment API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrating a FACTION instance elsewhere means reading the whole inventory, and three gaps made that impossible through the API: - VulnerabilityDTO carried a finding's severity but not its state. Added Status, Opened, Closed, DevClosed, StagingClosed and the category, so a consumer can reproduce a finding rather than just describe it. - Nothing listed every assessment. /queue is open assessments assigned to the caller and /completed wants a date range, so a full inventory meant guessing at the union of two partial views. GET /assessments/all returns what the caller can see, in one call. - The report download served one variant, preferring the PDF with no way to ask for anything else. It now takes type=docx|pdf and retest=true, and /report/{aid}/info lists what a report actually holds so a caller can choose before pulling a blob. A named type that is absent 404s rather than substituting another format, which would otherwise be filed as the wrong document type on the far end. GET /users/all rounds this out: the directory is how an integration resolves the user ids that appear on assessments, and there was no way to read it. Also fixes an NPE this surfaced. Vulnerability.getAssessmentId() is a primitive getter over a nullable Long, so any finding not attached to an assessment threw on read — that took down /assessments/vulns/{aid}, not just the new paths. It now returns 0, the "no assessment" value callers already test for (see AppBootstrapListener), and the DTO omits the field rather than pointing consumers at assessment 0. AssessmentDTO's start/end are guarded for the same reason: a bulk listing sweeps up drafts that never had dates set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S72uFdcjbJfhgEwwWxnfKX --- src/com/fuse/api/assessments.java | 232 ++++++++++--- src/com/fuse/api/dto/AssessmentDTO.java | 10 +- src/com/fuse/api/dto/VulnerabilityDTO.java | 107 +++++- src/com/fuse/api/users.java | 45 +++ src/com/fuse/dao/Vulnerability.java | 8 +- test/com/fuse/api/MigrationExportAPITest.java | 320 ++++++++++++++++++ 6 files changed, 661 insertions(+), 61 deletions(-) create mode 100644 test/com/fuse/api/MigrationExportAPITest.java 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; + } +}