diff --git a/src/main/java/org/dependencytrack/model/Policy.java b/src/main/java/org/dependencytrack/model/Policy.java index aeeacaf499..715ecc9cd5 100644 --- a/src/main/java/org/dependencytrack/model/Policy.java +++ b/src/main/java/org/dependencytrack/model/Policy.java @@ -142,6 +142,10 @@ public enum ViolationState { @Column(name = "INCLUDE_CHILDREN", allowsNull = "true") // New column, must allow nulls on existing data bases) private boolean includeChildren; + @Persistent + @Column(name = "ONLY_LATEST_PROJECT_VERSION", defaultValue = "false") + private boolean onlyLatestProjectVersion = false; + public long getId() { return id; } @@ -224,4 +228,12 @@ public boolean isIncludeChildren() { public void setIncludeChildren(boolean includeChildren) { this.includeChildren = includeChildren; } + + public boolean isOnlyLatestProjectVersion() { + return onlyLatestProjectVersion; + } + + public void setOnlyLatestProjectVersion(boolean onlyLatestProjectVersion) { + this.onlyLatestProjectVersion = onlyLatestProjectVersion; + } } diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 01148e4e5d..c53555db3b 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; @@ -95,7 +96,8 @@ @Persistent(name = "properties"), @Persistent(name = "tags"), @Persistent(name = "accessTeams"), - @Persistent(name = "metadata") + @Persistent(name = "metadata"), + @Persistent(name = "isLatest") }), @FetchGroup(name = "METADATA", members = { @Persistent(name = "metadata") @@ -270,6 +272,11 @@ public enum FetchGroup { @JsonSerialize(nullsUsing = BooleanDefaultTrueSerializer.class) private Boolean active; // Added in v3.6. Existing records need to be nullable on upgrade. + @Persistent + @Index(name = "PROJECT_IS_LATEST_IDX") + @Column(name = "IS_LATEST", defaultValue = "false") + private boolean isLatest = false; // Added in v4.12. + @Persistent(table = "PROJECT_ACCESS_TEAMS", defaultFetchGroup = "true") @Join(column = "PROJECT_ID") @Element(column = "TEAM_ID") @@ -513,6 +520,15 @@ public void setActive(Boolean active) { this.active = active; } + @JsonProperty("isLatest") + public boolean isLatest() { + return isLatest; + } + + public void setIsLatest(Boolean latest) { + isLatest = latest != null ? latest : false; + } + public String getBomRef() { return bomRef; } diff --git a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java index 653b5aa8be..0f0ded45c9 100644 --- a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java @@ -117,11 +117,13 @@ public Policy getPolicy(final String name) { * @param violationState the violation state * @return the created Policy */ - public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState) { + public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState, + boolean onlyLatestProjectVersion) { final Policy policy = new Policy(); policy.setName(name); policy.setOperator(operator); policy.setViolationState(violationState); + policy.setOnlyLatestProjectVersion(onlyLatestProjectVersion); return persist(policy); } diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java index 4d8ce37f88..21076fef9c 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryFilterBuilder.java @@ -115,6 +115,11 @@ ProjectQueryFilterBuilder withParent(UUID uuid){ return this; } + public ProjectQueryFilterBuilder onlyLatestVersion() { + filterCriteria.add("(isLatest == true)"); + return this; + } + String buildFilter() { return String.join(" && ", this.filterCriteria); } diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index 89a2aa6285..b462df02a4 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -71,6 +71,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; final class ProjectQueryManager extends QueryManager implements IQueryManager { @@ -267,6 +268,38 @@ public Project getProject(final String name, final String version) { return project; } + + /** + * Returns the latest version of a project by its name. + * + * @param name the name of the Project (required) + * @return a Project object representing the latest version, or null if not found + */ + @Override + public Project getLatestProjectVersion(final String name) { + final Query query = pm.newQuery(Project.class); + + final var filterBuilder = new ProjectQueryFilterBuilder() + .withName(name) + .onlyLatestVersion(); + + final String queryFilter = filterBuilder.buildFilter(); + final Map params = filterBuilder.getParams(); + + preprocessACLs(query, queryFilter, params, false); + query.setFilter(queryFilter); + query.setRange(0, 1); + + final Project project = singleResult(query.executeWithMap(params)); + if (project != null) { + // set Metrics to prevent extra round trip + project.setMetrics(getMostRecentProjectMetrics(project)); + // set ProjectVersions to prevent extra round trip + project.setVersions(getProjectVersions(project)); + } + return project; + } + /** * Returns a list of projects that are accessible by the specified team. * @param team the team the has access to Projects @@ -397,35 +430,35 @@ public PaginatedResult getProjects(final Tag tag) { * @return the created Project */ @Override - public Project createProject(String name, String description, String version, List tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) { + public Project createProject(String name, String description, String version, List tags, Project parent, + PackageURL purl, boolean active, boolean commitIndex) { + return createProject(name, description, version, tags, parent, purl, active, false, commitIndex); + } + + /** + * Creates a new Project. + * @param name the name of the project to create + * @param description a description of the project + * @param version the project version + * @param tags a List of Tags - these will be resolved if necessary + * @param parent an optional parent Project + * @param purl an optional Package URL + * @param active specified if the project is active + * @param commitIndex specifies if the search index should be committed (an expensive operation) + * @return the created Project + */ + @Override + public Project createProject(String name, String description, String version, List tags, Project parent, + PackageURL purl, boolean active, boolean isLatest, boolean commitIndex) { final Project project = new Project(); project.setName(name); project.setDescription(description); project.setVersion(version); - if (parent != null ) { - if (!Boolean.TRUE.equals(parent.isActive())){ - throw new IllegalArgumentException("An inactive Parent cannot be selected as parent"); - } - project.setParent(parent); - } + project.setParent(parent); project.setPurl(purl); project.setActive(active); - final Project result = persist(project); - - final List resolvedTags = resolveTags(tags); - bind(project, resolvedTags); - - Event.dispatch(new IndexEvent(IndexEvent.Action.CREATE, result)); - Notification.dispatch(new Notification() - .scope(NotificationScope.PORTFOLIO) - .group(NotificationGroup.PROJECT_CREATED) - .title(NotificationConstants.Title.PROJECT_CREATED) - .level(NotificationLevel.INFORMATIONAL) - .content(result.getName() + " was created") - .subject(NotificationUtil.toJson(pm.detachCopy(result))) - ); - commitSearchIndex(commitIndex, Project.class); - return result; + project.setIsLatest(isLatest); + return createProject(project, tags, commitIndex); } /** @@ -443,11 +476,33 @@ public Project createProject(final Project project, List tags, boolean comm if (project.isActive() == null) { project.setActive(Boolean.TRUE); } - final Project result = persist(project); - final List resolvedTags = resolveTags(tags); - bind(project, resolvedTags); + final Project oldLatestProject = project.isLatest() ? getLatestProjectVersion(project.getName()) : null; + final Project result = callInTransaction(() -> { + // Remove isLatest flag from current latest project version, if the new project will be the latest + if(oldLatestProject != null) { + oldLatestProject.setIsLatest(false); + persist(oldLatestProject); + } + final Project newProject = persist(project); + final List resolvedTags = resolveTags(tags); + bind(project, resolvedTags); + return newProject; + }); + + if(oldLatestProject != null) { + // if we removed isLatest flag from old version, dispatch update event for the old version + Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, oldLatestProject)); + } Event.dispatch(new IndexEvent(IndexEvent.Action.CREATE, result)); + Notification.dispatch(new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.PROJECT_CREATED) + .title(NotificationConstants.Title.PROJECT_CREATED) + .level(NotificationLevel.INFORMATIONAL) + .content(result.getName() + " was created") + .subject(NotificationUtil.toJson(pm.detachCopy(result))) + ); commitSearchIndex(commitIndex, Project.class); return result; } @@ -480,6 +535,14 @@ public Project updateProject(Project transientProject, boolean commitIndex) { } project.setActive(transientProject.isActive()); + final Project oldLatestProject; + if(Boolean.TRUE.equals(transientProject.isLatest()) && Boolean.FALSE.equals(project.isLatest())) { + oldLatestProject = getLatestProjectVersion(project.getName()); + } else { + oldLatestProject = null; + } + project.setIsLatest(transientProject.isLatest()); + if (transientProject.getParent() != null && transientProject.getParent().getUuid() != null) { if (project.getUuid().equals(transientProject.getParent().getUuid())){ throw new IllegalArgumentException("A project cannot select itself as a parent"); @@ -497,10 +560,23 @@ public Project updateProject(Project transientProject, boolean commitIndex) { project.setParent(null); } - final List resolvedTags = resolveTags(transientProject.getTags()); - bind(project, resolvedTags); + final Project result = callInTransaction(() -> { + // Remove isLatest flag from current latest project version, if this project will be the latest now + if(oldLatestProject != null) { + oldLatestProject.setIsLatest(false); + persist(oldLatestProject); + } + + final List resolvedTags = resolveTags(transientProject.getTags()); + bind(project, resolvedTags); - final Project result = persist(project); + return persist(project); + }); + + if(oldLatestProject != null) { + // if we removed isLatest flag from old version, dispatch update event for the old version + Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, oldLatestProject)); + } Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, result)); commitSearchIndex(commitIndex, Project.class); return result; @@ -516,10 +592,11 @@ public Project clone( final boolean includeServices, final boolean includeAuditHistory, final boolean includeACL, - final boolean includePolicyViolations + final boolean includePolicyViolations, + final boolean makeCloneLatest ) { + final AtomicReference oldLatestProject = new AtomicReference<>(); final var jsonMapper = new JsonMapper(); - final Project clonedProject = callInTransaction(() -> { final Project source = getObjectByUuid(Project.class, from, Project.FetchGroup.ALL.name()); if (source == null) { @@ -533,6 +610,11 @@ public Project clone( LOGGER.warn("Project was supposed to be cloned to version %s, but that version already exists".formatted(newVersion)); return null; } + if(makeCloneLatest) { + oldLatestProject.set(source.isLatest() ? source : getLatestProjectVersion(source.getName())); + } else { + oldLatestProject.set(null); + } Project project = new Project(); project.setAuthors(source.getAuthors()); project.setManufacturer(source.getManufacturer()); @@ -544,6 +626,7 @@ public Project clone( project.setVersion(newVersion); project.setClassifier(source.getClassifier()); project.setActive(source.isActive()); + project.setIsLatest(makeCloneLatest); project.setCpe(source.getCpe()); project.setPurl(source.getPurl()); project.setSwidTagId(source.getSwidTagId()); @@ -551,6 +634,13 @@ public Project clone( project.setDirectDependencies(source.getDirectDependencies()); } project.setParent(source.getParent()); + + // Remove isLatest flag from current latest project version, if this project will be the latest now + if(oldLatestProject.get() != null) { + oldLatestProject.get().setIsLatest(false); + persist(oldLatestProject.get()); + } + project = persist(project); if (source.getMetadata() != null) { @@ -703,6 +793,10 @@ public Project clone( return project; }); + if(oldLatestProject.get() != null) { + // if we removed isLatest flag from old version, dispatch update event for the old version + Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, oldLatestProject.get())); + } Event.dispatch(new IndexEvent(IndexEvent.Action.CREATE, clonedProject)); commitSearchIndex(true, Project.class); return clonedProject; diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index a610387b8c..75ee9f26a5 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -417,6 +417,10 @@ public Project getProject(final String name, final String version) { return getProjectQueryManager().getProject(name, version); } + public Project getLatestProjectVersion(final String name) { + return getProjectQueryManager().getLatestProjectVersion(name); + } + public PaginatedResult getProjects(final Team team, final boolean excludeInactive, final boolean bypass, final boolean onlyRoot) { return getProjectQueryManager().getProjects(team, excludeInactive, bypass, onlyRoot); } @@ -484,6 +488,10 @@ public List resolveTags(final List tags) { public Project createProject(String name, String description, String version, List tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) { return getProjectQueryManager().createProject(name, description, version, tags, parent, purl, active, commitIndex); } + public Project createProject(String name, String description, String version, List tags, Project parent, + PackageURL purl, boolean active, boolean isLatest, boolean commitIndex) { + return getProjectQueryManager().createProject(name, description, version, tags, parent, purl, active, isLatest, commitIndex); + } public Project createProject(final Project project, List tags, boolean commitIndex) { return getProjectQueryManager().createProject(project, tags, commitIndex); @@ -499,9 +507,9 @@ public boolean updateNewProjectACL(Project transientProject, Principal principal public Project clone(UUID from, String newVersion, boolean includeTags, boolean includeProperties, boolean includeComponents, boolean includeServices, boolean includeAuditHistory, - boolean includeACL, boolean includePolicyViolations) { + boolean includeACL, boolean includePolicyViolations, boolean makeCloneLatest) { return getProjectQueryManager().clone(from, newVersion, includeTags, includeProperties, - includeComponents, includeServices, includeAuditHistory, includeACL, includePolicyViolations); + includeComponents, includeServices, includeAuditHistory, includeACL, includePolicyViolations, makeCloneLatest); } public Project updateLastBomImport(Project p, Date date, String bomFormat) { @@ -675,7 +683,10 @@ public Policy getPolicy(final String name) { } public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState) { - return getPolicyQueryManager().createPolicy(name, operator, violationState); + return this.createPolicy(name, operator, violationState, false); + } + public Policy createPolicy(String name, Policy.Operator operator, Policy.ViolationState violationState, boolean onlyLatestProjectVersion) { + return getPolicyQueryManager().createPolicy(name, operator, violationState, onlyLatestProjectVersion); } public void removeProjectFromPolicies(final Project project) { diff --git a/src/main/java/org/dependencytrack/policy/PolicyEngine.java b/src/main/java/org/dependencytrack/policy/PolicyEngine.java index 7b6053718a..67db3db1a9 100644 --- a/src/main/java/org/dependencytrack/policy/PolicyEngine.java +++ b/src/main/java/org/dependencytrack/policy/PolicyEngine.java @@ -80,6 +80,9 @@ private List evaluate(final QueryManager qm, final List final List policyViolations = new ArrayList<>(); final List existingPolicyViolations = qm.detach(qm.getAllPolicyViolations(component)); for (final Policy policy : policies) { + if(policy.isOnlyLatestProjectVersion() && Boolean.FALSE.equals(component.getProject().isLatest())) { + continue; + } if (policy.isGlobal() || isPolicyAssignedToProject(policy, component.getProject()) || isPolicyAssignedToProjectTag(policy, component.getProject())) { LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy (" + policy.getUuid() + ")"); diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index 9c83022282..ddfd9c7f8b 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -327,8 +327,20 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request) return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified parent project is forbidden").build(); } } + final String trimmedProjectName = StringUtils.trimToNull(request.getProjectName()); + if(request.isLatestProjectVersion()) { + final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot create latest version for project with this name. Access to current latest " + + "version is forbidden!") + .build(); + } + } - project = qm.createProject(StringUtils.trimToNull(request.getProjectName()), null, StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent, null, true, true); + project = qm.createProject(trimmedProjectName, null, + StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent, + null, true, request.isLatestProjectVersion(), true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { @@ -390,6 +402,7 @@ public Response uploadBom( @FormDataParam("parentName") String parentName, @FormDataParam("parentVersion") String parentVersion, @FormDataParam("parentUUID") String parentUUID, + @DefaultValue("false") @FormDataParam("isLatest") boolean isLatest, @Parameter(schema = @Schema(type = "string")) @FormDataParam("bom") final List artifactParts ) { if (projectUuid != null) { // behavior in v3.0.0 @@ -421,10 +434,19 @@ public Response uploadBom( return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified parent project is forbidden").build(); } } + if(isLatest) { + final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot create latest version for project with this name. Access to current latest " + + "version is forbidden!") + .build(); + } + } final List tags = (projectTags != null && !projectTags.isBlank()) ? Arrays.stream(projectTags.split(",")).map(String::trim).filter(not(String::isEmpty)).map(Tag::new).toList() : null; - project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, parent, null, true, true); + project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, parent, null, true, isLatest, true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { diff --git a/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java b/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java index 8bf4ab299a..c4a9e88ee7 100644 --- a/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/PolicyResource.java @@ -157,7 +157,7 @@ public Response createPolicy(Policy jsonPolicy) { } policy = qm.createPolicy( StringUtils.trimToNull(jsonPolicy.getName()), - operator, violationState); + operator, violationState, jsonPolicy.isOnlyLatestProjectVersion()); return Response.status(Response.Status.CREATED).entity(policy).build(); } else { return Response.status(Response.Status.CONFLICT).entity("A policy with the specified name already exists.").build(); @@ -194,6 +194,7 @@ public Response updatePolicy(Policy jsonPolicy) { policy.setOperator(jsonPolicy.getOperator()); policy.setViolationState(jsonPolicy.getViolationState()); policy.setIncludeChildren(jsonPolicy.isIncludeChildren()); + policy.setOnlyLatestProjectVersion(jsonPolicy.isOnlyLatestProjectVersion()); policy = qm.persist(policy); return Response.ok(policy).build(); } else { diff --git a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java index d9dcf34432..fd66c5a466 100644 --- a/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java @@ -203,6 +203,42 @@ public Response getProject( } } + + @GET + @Path("/latest/{name}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns the latest version of a project by its name", + description = "

Requires permission VIEW_PORTFOLIO

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "The latest version of the specified project", + content = @Content(schema = @Schema(implementation = Project.class)) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Access to the specified project is forbidden"), + @ApiResponse(responseCode = "404", description = "The project could not be found") + }) + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getLatestProjectByName( + @Parameter(description = "The name of the project to retrieve the latest version of", required = true) + @PathParam("name") String name) { + try (QueryManager qm = new QueryManager()) { + final Project project = qm.getLatestProjectVersion(name); + if (project != null) { + if (qm.hasAccess(super.getPrincipal(), project)) { + return Response.ok(project).build(); + } else { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); + } + } else { + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); + } + } + } + @GET @Path("/tag/{tag}") @Produces(MediaType.APPLICATION_JSON) @@ -294,6 +330,7 @@ public Response getProjectsByClassifier( ), @ApiResponse(responseCode = "400", description = "Bad Request"), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "The project version cannot be created as latest version because access to current latest version is forbidden."), @ApiResponse(responseCode = "409", description = """
  • An inactive Parent cannot be selected as parent, or
  • @@ -329,6 +366,16 @@ public Response createProject(final Project jsonProject) { .build(); } + if(jsonProject.isLatest()) { + final Project oldLatest = qm.getLatestProjectVersion(jsonProject.getName()); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot create latest version for project with this name. Access to current latest " + + "version is forbidden!") + .build(); + } + } + if (jsonProject.getParent() != null && jsonProject.getParent().getUuid() != null) { Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); jsonProject.setParent(parent); @@ -428,6 +475,8 @@ public Response createProject(final Project jsonProject) { content = @Content(schema = @Schema(implementation = Project.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "The project version cannot be set as latest version " + + "because access to current latest version is forbidden."), @ApiResponse(responseCode = "404", description = "The UUID of the project could not be found"), @ApiResponse(responseCode = "409", description = """
      @@ -461,14 +510,27 @@ public Response updateProject(Project jsonProject) { if (!qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } - final String name = StringUtils.trimToNull(jsonProject.getName()); + String name = StringUtils.trimToNull(jsonProject.getName()); + // Name cannot be empty or null - prevent it + if (name == null) { + name = project.getName(); + jsonProject.setName(name); + } + + // if project is newly set to latest, ensure user has access to current latest version to modify it + if(jsonProject.isLatest() && !project.isLatest()) { + final Project oldLatest = qm.getLatestProjectVersion(name); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot set this project version to latest. Access to current latest " + + "version is forbidden!") + .build(); + } + } + final String version = StringUtils.trimToNull(jsonProject.getVersion()); final Project tmpProject = qm.getProject(name, version); if (tmpProject == null || (tmpProject.getUuid().equals(project.getUuid()))) { - // Name cannot be empty or null - prevent it - if (name == null) { - jsonProject.setName(project.getName()); - } try { project = qm.updateProject(jsonProject, true); } catch (IllegalArgumentException e){ @@ -536,6 +598,19 @@ public Response patchProject( if (!qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } + + // if project is newly set to latest, ensure user has access to current latest version to modify it + if(jsonProject.isLatest() && !project.isLatest()) { + final var oldName = jsonProject.getName() != null ? jsonProject.getName() : project.getName(); + final Project oldLatest = qm.getLatestProjectVersion(oldName); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot set this project version to latest. Access to current latest " + + "version is forbidden!") + .build(); + } + } + var modified = false; project = qm.detachWithGroups(project, List.of(FetchGroup.DEFAULT, Project.FetchGroup.PARENT.name())); modified |= setIfDifferent(jsonProject, project, Project::getName, Project::setName); @@ -555,6 +630,7 @@ public Response patchProject( modified |= setIfDifferent(jsonProject, project, Project::isActive, Project::setActive); modified |= setIfDifferent(jsonProject, project, Project::getManufacturer, Project::setManufacturer); modified |= setIfDifferent(jsonProject, project, Project::getSupplier, Project::setSupplier); + modified |= setIfDifferent(jsonProject, project, Project::isLatest, Project::setIsLatest); if (jsonProject.getParent() != null && jsonProject.getParent().getUuid() != null) { final Project parent = qm.getObjectByUuid(Project.class, jsonProject.getParent().getUuid()); if (parent == null) { @@ -673,6 +749,9 @@ public Response deleteProject( content = @Content(schema = @Schema(implementation = BomUploadResponse.class)) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + + @ApiResponse(responseCode = "403", description = "The project clone cannot be set to latest version " + + "because access to current latest version is forbidden."), @ApiResponse(responseCode = "404", description = "The UUID of the project could not be found") }) @PermissionRequired(Permissions.Constants.PORTFOLIO_MANAGEMENT) @@ -692,6 +771,17 @@ public Response cloneProject(CloneProjectRequest jsonRequest) { return Response.status(Response.Status.CONFLICT).entity("A project with the specified name and version already exists.").build(); } + // if project is newly set to latest, ensure user has access to current latest version to modify it + if(jsonRequest.makeCloneLatest() && !sourceProject.isLatest()) { + final Project oldLatest = qm.getLatestProjectVersion(sourceProject.getName()); + if(oldLatest != null && !qm.hasAccess(super.getPrincipal(), oldLatest)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot set cloned project version to latest. Access to current latest " + + "version is forbidden!") + .build(); + } + } + LOGGER.info("Project " + sourceProject + " is being cloned by " + super.getPrincipal().getName()); CloneProjectEvent event = new CloneProjectEvent(jsonRequest); Event.dispatch(event); diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java index 99129c7a81..b6171d1314 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java @@ -72,13 +72,16 @@ public final class BomSubmitRequest { private final boolean autoCreate; + private final boolean isLatestProjectVersion; + public BomSubmitRequest(String project, String projectName, String projectVersion, List projectTags, boolean autoCreate, + boolean isLatestProjectVersion, String bom) { - this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, bom); + this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, isLatestProjectVersion, bom); } @JsonCreator @@ -90,6 +93,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, @JsonProperty(value = "parentUUID") String parentUUID, @JsonProperty(value = "parentName") String parentName, @JsonProperty(value = "parentVersion") String parentVersion, + @JsonProperty(value = "isLatestProjectVersion", defaultValue = "false") boolean isLatestProjectVersion, @JsonProperty(value = "bom", required = true) String bom) { this.project = project; this.projectName = projectName; @@ -99,6 +103,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, this.parentUUID = parentUUID; this.parentName = parentName; this.parentVersion = parentVersion; + this.isLatestProjectVersion = isLatestProjectVersion; this.bom = bom; } @@ -141,6 +146,9 @@ public boolean isAutoCreate() { return autoCreate; } + @JsonProperty("isLatestProjectVersion") + public boolean isLatestProjectVersion() { return isLatestProjectVersion; } + @Schema( description = "Base64 encoded BOM", required = true, diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java b/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java index 2850d2369f..c86f951c66 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/CloneProjectRequest.java @@ -61,6 +61,8 @@ public class CloneProjectRequest { private final boolean includePolicyViolations; + private final boolean makeCloneLatest; + @JsonCreator public CloneProjectRequest(@JsonProperty(value = "project", required = true) String project, @JsonProperty(value = "version", required = true) String version, @@ -71,8 +73,10 @@ public CloneProjectRequest(@JsonProperty(value = "project", required = true) Str @JsonProperty(value = "includeServices") boolean includeServices, @JsonProperty(value = "includeAuditHistory") boolean includeAuditHistory, @JsonProperty(value = "includeACL") boolean includeACL, - @JsonProperty(value = "includePolicyViolations") boolean includePolicyViolations) { - if (includeDependencies) { // For backward compatibility + @JsonProperty(value = "includePolicyViolations") boolean includePolicyViolations, + @JsonProperty(value = "makeCloneLatest", defaultValue = "false") boolean makeCloneLatest) { + + if (includeDependencies) { // For backward compatibility includeComponents = true; } this.project = project; @@ -85,6 +89,7 @@ public CloneProjectRequest(@JsonProperty(value = "project", required = true) Str this.includeAuditHistory = includeAuditHistory; this.includeACL = includeACL; this.includePolicyViolations = includePolicyViolations; + this.makeCloneLatest = makeCloneLatest; } public String getProject() { @@ -127,4 +132,7 @@ public boolean includePolicyViolations() { return includePolicyViolations; } + public boolean makeCloneLatest() { + return makeCloneLatest; + } } diff --git a/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java b/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java index 863c46ed58..da4b9768c8 100644 --- a/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java +++ b/src/main/java/org/dependencytrack/tasks/CloneProjectTask.java @@ -55,7 +55,8 @@ public void inform(final Event e) { request.includeServices(), request.includeAuditHistory(), request.includeACL(), - request.includePolicyViolations() + request.includePolicyViolations(), + request.makeCloneLatest() ); LOGGER.info("Cloned project for version %s into project %s".formatted(project.getVersion(), project.getUuid())); } catch (RuntimeException ex) { diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index ef37c77967..1282a63204 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -24,6 +24,7 @@ import alpine.server.auth.PasswordService; import alpine.server.persistence.PersistenceManagerFactory; import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.persistence.QueryManager; import org.junit.After; import org.junit.Before; @@ -61,6 +62,7 @@ public abstract class ResourceTest { protected final String V1_POLICY = "/v1/policy"; protected final String V1_POLICY_VIOLATION = "/v1/violation"; protected final String V1_PROJECT = "/v1/project"; + protected final String V1_PROJECT_LATEST = "/v1/project/latest/"; protected final String V1_REPOSITORY = "/v1/repository"; protected final String V1_SCAN = "/v1/scan"; protected final String V1_SEARCH = "/v1/search"; @@ -123,6 +125,16 @@ public void initializeWithPermissions(Permissions... permissions) { qm.persist(team); } + protected void enablePortfolioAccessControl() { + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + } + protected String getPlainTextBody(Response response) { return response.readEntity(String.class); } diff --git a/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java b/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java index 1f46dabfe8..ebf1bfe00a 100644 --- a/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java +++ b/src/test/java/org/dependencytrack/event/CloneProjectEventTest.java @@ -29,7 +29,9 @@ public class CloneProjectEventTest { @Test public void testEvent() { UUID uuid = UUID.randomUUID(); - CloneProjectRequest request = new CloneProjectRequest(uuid.toString(), "1.0", true, true, true, true, true, true, true, true); + CloneProjectRequest request = new CloneProjectRequest(uuid.toString(), "1.0", true, + true, true, true, true, true, + true, true, false); CloneProjectEvent event = new CloneProjectEvent(request); Assert.assertEquals(request, event.getRequest()); } diff --git a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java index 2a84d9a621..9d2d674da5 100644 --- a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java +++ b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java @@ -18,7 +18,6 @@ */ package org.dependencytrack.persistence; -import alpine.persistence.PaginatedResult; import org.dependencytrack.PersistenceCapableTest; import org.dependencytrack.model.*; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; @@ -28,8 +27,6 @@ import java.util.Date; import java.util.List; -import static org.assertj.core.api.Assertions.assertThat; - public class ProjectQueryManagerTest extends PersistenceCapableTest { @Test @@ -48,7 +45,8 @@ public void testCloneProjectPreservesVulnerabilityAttributionDate() throws Excep vuln.setSeverity(Severity.HIGH); qm.persist(vuln); qm.addVulnerability(vuln, comp, AnalyzerIdentity.INTERNAL_ANALYZER, "Vuln1", "http://vuln.com/vuln1", new Date(1708559165229L)); - Project clonedProject = qm.clone(project.getUuid(), "1.1.0", false, false, true, false, false, false, false); + Project clonedProject = qm.clone(project.getUuid(), "1.1.0", false, false, + true, false, false, false, false, false); List findings = qm.getFindings(clonedProject); Assert.assertEquals(1, findings.size()); Finding finding = findings.get(0); diff --git a/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java b/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java index 62463bc9dc..75cb8a068f 100644 --- a/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java +++ b/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java @@ -195,6 +195,52 @@ public void noPolicyAssignedToParentProject() { Assert.assertEquals(0, violations.size()); } + @Test + public void policyForLatestTriggersOnLatestVersion() { + Policy policy = qm.createPolicy("Test Policy", Operator.ANY, ViolationState.INFO, true); + qm.createPolicyCondition(policy, Subject.SEVERITY, PolicyCondition.Operator.IS, Severity.CRITICAL.name()); + Project project = qm.createProject("My Project", null, "1", null, null, + null, true, true, false); + Component component = new Component(); + component.setName("Test Component"); + component.setVersion("1.0"); + component.setProject(project); + Vulnerability vulnerability = new Vulnerability(); + vulnerability.setVulnId("12345"); + vulnerability.setSource(Vulnerability.Source.INTERNAL); + vulnerability.setSeverity(Severity.CRITICAL); + qm.persist(project); + qm.persist(component); + qm.persist(vulnerability); + qm.addVulnerability(vulnerability, component, AnalyzerIdentity.INTERNAL_ANALYZER); + PolicyEngine policyEngine = new PolicyEngine(); + List violations = policyEngine.evaluate(List.of(component)); + Assert.assertEquals(1, violations.size()); + } + + @Test + public void policyForLatestTriggersNotOnNotLatestVersion() { + Policy policy = qm.createPolicy("Test Policy", Operator.ANY, ViolationState.INFO, true); + qm.createPolicyCondition(policy, Subject.SEVERITY, PolicyCondition.Operator.IS, Severity.CRITICAL.name()); + Project project = qm.createProject("My Project", null, "1", null, null, + null, true, false, false); + Component component = new Component(); + component.setName("Test Component"); + component.setVersion("1.0"); + component.setProject(project); + Vulnerability vulnerability = new Vulnerability(); + vulnerability.setVulnId("12345"); + vulnerability.setSource(Vulnerability.Source.INTERNAL); + vulnerability.setSeverity(Severity.CRITICAL); + qm.persist(project); + qm.persist(component); + qm.persist(vulnerability); + qm.addVulnerability(vulnerability, component, AnalyzerIdentity.INTERNAL_ANALYZER); + PolicyEngine policyEngine = new PolicyEngine(); + List violations = policyEngine.evaluate(List.of(component)); + Assert.assertEquals(0, violations.size()); + } + @Test public void determineViolationTypeTest() { PolicyCondition policyCondition = new PolicyCondition(); @@ -342,13 +388,17 @@ public void notificationTest() { // Evaluate policies and ensure that a notification has been sent. final var policyEngine = new PolicyEngine(); assertThat(policyEngine.evaluate(List.of(component))).hasSize(1); - assertThat(NOTIFICATIONS).hasSize(1); + assertThat(NOTIFICATIONS).hasSize(2); // Create an additional policy condition that matches on the exact version of the component, // and re-evaluate policies. Ensure that only one notification per newly violated condition was sent. final var policyConditionB = qm.createPolicyCondition(policy, Subject.VERSION, PolicyCondition.Operator.NUMERIC_EQUAL, "1.2.3"); assertThat(policyEngine.evaluate(List.of(component))).hasSize(2); assertThat(NOTIFICATIONS).satisfiesExactly( + notification -> { + assertThat(notification.getScope()).isEqualTo(NotificationScope.PORTFOLIO.name()); + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_CREATED.name()); + }, notification -> { assertThat(notification.getScope()).isEqualTo(NotificationScope.PORTFOLIO.name()); assertThat(notification.getGroup()).isEqualTo(NotificationGroup.POLICY_VIOLATION.name()); @@ -374,7 +424,7 @@ public void notificationTest() { // Delete a policy condition and re-evaluate policies again. No new notifications should be sent. qm.deletePolicyCondition(policyConditionA); assertThat(policyEngine.evaluate(List.of(component))).hasSize(1); - assertThat(NOTIFICATIONS).hasSize(2); + assertThat(NOTIFICATIONS).hasSize(3); } @Test diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index e99a365dae..30fa40a102 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -821,7 +821,7 @@ public void uploadBomTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -836,7 +836,7 @@ public void uploadBomTest() throws Exception { public void uploadBomInvalidProjectTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -850,7 +850,7 @@ public void uploadBomInvalidProjectTest() throws Exception { public void uploadBomAutoCreateTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -872,7 +872,8 @@ public void uploadBomAutoCreateWithTagsTest() throws Exception { tag.setName(name); return tag; }).collect(Collectors.toList()); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", tags, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", + tags, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -924,7 +925,8 @@ public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { @Test public void uploadBomUnauthorizedTest() throws Exception { String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", + null, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -933,12 +935,57 @@ public void uploadBomUnauthorizedTest() throws Exception { Assert.assertEquals("The principal does not have permission to create project.", body); } + @Test + public void uploadBomAutoCreateLatestWithAclTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + enablePortfolioAccessControl(); + + final var accessLatestProject = new Project(); + accessLatestProject.setName("acme-app-a"); + accessLatestProject.setVersion("1.0.0"); + accessLatestProject.setIsLatest(true); + accessLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessLatestProject); + + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + BomSubmitRequest request = new BomSubmitRequest(null, accessLatestProject.getName(), + "1.0.1", null, true, true, bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + } + + @Test + public void uploadBomAutoCreateLatestWithAclNoAccessTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + enablePortfolioAccessControl(); + + final var noAccessLatestProject = new Project(); + noAccessLatestProject.setName("acme-app-a"); + noAccessLatestProject.setVersion("1.0.0"); + noAccessLatestProject.setIsLatest(true); + qm.persist(noAccessLatestProject); + + String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); + BomSubmitRequest request = new BomSubmitRequest(null, noAccessLatestProject.getName(), + "1.0.1", null, true, true, bomString); + Response response = jersey.target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(403, response.getStatus(), 0); + } + @Test public void uploadBomAutoCreateTestWithParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); // Upload parent project - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", null, true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", + null, true, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -950,7 +997,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { String parentUUID = parent.getUuid().toString(); // Upload first child, search parent by UUID - request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, bomString); + request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -966,7 +1013,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { // Upload second child, search parent by name+ver - request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -981,7 +1028,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { Assert.assertEquals(parentUUID, child.getParent().getUuid().toString()); // Upload third child, specify parent's UUID, name, ver. Name and ver are ignored when UUID is specified. - request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", bomString); + request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1000,7 +1047,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { public void uploadBomInvalidParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); String bomString = Base64.getEncoder().encodeToString(resourceToByteArray("/unit/bom-1.xml")); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1008,7 +1055,7 @@ public void uploadBomInvalidParentTest() throws Exception { String body = getPlainTextBody(response); Assert.assertEquals("The parent component could not be found.", body); - request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, false, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 4efa8fcd09..b3c13396c4 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -116,14 +116,7 @@ public void getProjectsDefaultRequestTest() { @Test // https://github.com/DependencyTrack/dependency-track/issues/2583 public void getProjectsWithAclEnabledTest() { - // Enable portfolio access control. - qm.createConfigProperty( - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - null - ); + enablePortfolioAccessControl(); // Create project and give access to current principal's team. final Project accessProject = qm.createProject("acme-app-a", null, "1.0.0", null, null, null, true, false); @@ -304,12 +297,14 @@ public void getProjectByUuidTest() { "name": "acme-app-child", "version": "1.0.0", "uuid": "${json-unit.matches:childUuid}", - "active": true + "active": true, + "isLatest":false } ], "properties": [], "tags": [], "active": true, + "isLatest":false, "versions": [ { "uuid": "${json-unit.matches:projectUuid}", @@ -522,7 +517,8 @@ public void createProjectAsUserWithAclEnabledAndExistingTeamByUuidTest() { "children": [], "properties": [], "tags": [], - "active": true + "active": true, + "isLatest":false } """); @@ -567,7 +563,8 @@ public void createProjectAsUserWithAclEnabledAndExistingTeamByNameTest() { "children": [], "properties": [], "tags": [], - "active": true + "active": true, + "isLatest":false } """); @@ -607,7 +604,8 @@ public void createProjectAsUserWithAclEnabledAndWithoutTeamTest() { "children": [], "properties": [], "tags": [], - "active": true + "active": true, + "isLatest":false } """); @@ -688,7 +686,8 @@ public void createProjectAsUserWithAclEnabledAndNotMemberOfTeamAdminTest() { "children": [], "properties": [], "tags": [], - "active": true + "active": true, + "isLatest":false } """); @@ -797,13 +796,90 @@ public void createProjectAsApiKeyWithAclEnabledAndWithExistentTeamTest() { "children": [], "properties": [], "tags": [], - "active": true + "active": true, + "isLatest":false } """); assertThat(qm.getAllProjects()).satisfiesExactly(project -> assertThat(project.getAccessTeams()).extracting(Team::getName).containsOnly(team.getName())); } + @Test + public void createProjectAsLatestTest() { + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0"); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + // ensure initial value is false when not specified + Assert.assertFalse(json.getBoolean("isLatest")); + + project.setVersion("2.0"); + project.setIsLatest(true); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure value of latest version is true when specified + Assert.assertTrue(json.getBoolean("isLatest")); + String v20uuid = json.getString("uuid"); + + project.setVersion("2.1"); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure value of latest version is true when specified + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure v2.0 is no longer latest + Assert.assertFalse(qm.getProject(v20uuid).isLatest()); + } + + @Test + public void createProjectAsLatestWithACLTest() { + enablePortfolioAccessControl(); + + final var accessProject = new Project(); + accessProject.setName("acme-app-a"); + accessProject.setVersion("1.0.0"); + accessProject.setIsLatest(true); + accessProject.setAccessTeams(List.of(team)); + qm.persist(accessProject); + + final var noAccessProject = new Project(); + noAccessProject.setName("acme-app-b"); + noAccessProject.setVersion("2.0.0"); + noAccessProject.setIsLatest(true); + qm.persist(noAccessProject); + + Project project = new Project(); + project.setName(accessProject.getName()); + project.setVersion("1.0.1"); + project.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertTrue(json.getBoolean("isLatest")); + + project.setName(noAccessProject.getName()); + project.setVersion("3.0.0"); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + Assert.assertEquals(403, response.getStatus(), 0); + } @Test public void updateProjectTest() { @@ -921,6 +997,104 @@ public void updateProjectDuplicateTest() { Assert.assertEquals("A project with the specified name and version already exists.", body); } + @Test + public void updateProjectAsLatestTest() { + // create project not as latest + Project project = qm.createProject("ABC", null, "1.0", null, null, null, + true, false, false); + + // make it latest by update + var jsonProject = qm.detach(project); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertTrue(json.getBoolean("isLatest")); + + // add another project version, "forget" to make it latest + final Project newProject = qm.createProject("ABC", null, "1.0.1", null, null, null, + true, false, false); + // make the new version latest afterwards via update + jsonProject = qm.detach(newProject); + jsonProject.setIsLatest(true); + response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest + Assert.assertFalse(qm.getProject(project.getName(), project.getVersion()).isLatest()); + } + + @Test + public void updateProjectAsLatestWithACLAndAccessTest() { + enablePortfolioAccessControl(); + + final var accessLatestProject = new Project(); + accessLatestProject.setName("acme-app-a"); + accessLatestProject.setVersion("1.0.0"); + accessLatestProject.setIsLatest(true); + accessLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update + final var jsonProject = qm.detach(accessNotLatestProject); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest (bypass db cache) + qm.getPersistenceManager().refreshAll(); + Assert.assertFalse(qm.getProject(accessLatestProject.getName(), accessLatestProject.getVersion()).isLatest()); + } + + @Test + public void updateProjectAsLatestWithACLAndNoAccessTest() { + enablePortfolioAccessControl(); + + final var noAccessLatestProject = new Project(); + noAccessLatestProject.setName("acme-app-a"); + noAccessLatestProject.setVersion("1.0.0"); + noAccessLatestProject.setIsLatest(true); + qm.persist(noAccessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update (but have no access to old latest) + final var jsonProject = qm.detach(accessNotLatestProject); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(jsonProject, MediaType.APPLICATION_JSON)); + Assert.assertEquals(403, response.getStatus(), 0); + // ensure old is still latest + Assert.assertTrue(qm.getProject(noAccessLatestProject.getName(), noAccessLatestProject.getVersion()).isLatest()); + } + @Test public void deleteProjectTest() { Project project = qm.createProject("ABC", null, "1.0", null, null, null, true, false); @@ -1069,6 +1243,7 @@ public void patchProjectSuccessfullyPatchedTest() { } ], "active": false, + "isLatest":false, "children": [] } """); @@ -1141,7 +1316,8 @@ public void patchProjectParentTest() { }, "properties": [], "tags": [], - "active": true + "active": true, + "isLatest":false } """); @@ -1176,6 +1352,109 @@ public void patchProjectParentNotFoundTest() { assertThat(project.getParent().getUuid()).isEqualTo(parent.getUuid()); } + @Test + public void patchProjectAsLatestTest() { + // create project not as latest + Project project = qm.createProject("ABC", null, "1.0", null, null, null, + true, false, false); + + // make it latest by patch + var jsonProject = new Project(); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT + "/" + project.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertTrue(json.getBoolean("isLatest")); + + // add another project version, "forget" to make it latest + final Project newProject = qm.createProject("ABC", null, "1.0.1", null, null, null, + true, false, false); + // make the new version latest afterwards via update + jsonProject = new Project(); + jsonProject.setIsLatest(true); + response = jersey.target(V1_PROJECT + "/" + newProject.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest + Assert.assertFalse(qm.getProject(project.getName(), project.getVersion()).isLatest()); + } + + @Test + public void patchProjectAsLatestWithACLAndAccessTest() { + enablePortfolioAccessControl(); + + final var accessLatestProject = new Project(); + accessLatestProject.setName("acme-app-a"); + accessLatestProject.setVersion("1.0.0"); + accessLatestProject.setIsLatest(true); + accessLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update + final var jsonProject = new Project(); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT + "/" + accessNotLatestProject.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + // ensure is now latest + Assert.assertTrue(json.getBoolean("isLatest")); + // ensure old is no longer latest (bypass db cache) + qm.getPersistenceManager().refreshAll(); + Assert.assertFalse(qm.getProject(accessLatestProject.getName(), accessLatestProject.getVersion()).isLatest()); + } + + @Test + public void patchProjectAsLatestWithACLAndNoAccessTest() { + enablePortfolioAccessControl(); + + final var noAccessLatestProject = new Project(); + noAccessLatestProject.setName("acme-app-a"); + noAccessLatestProject.setVersion("1.0.0"); + noAccessLatestProject.setIsLatest(true); + qm.persist(noAccessLatestProject); + + final var accessNotLatestProject = new Project(); + accessNotLatestProject.setName("acme-app-a"); + accessNotLatestProject.setVersion("1.0.1"); + accessNotLatestProject.setIsLatest(false); + accessNotLatestProject.setAccessTeams(List.of(team)); + qm.persist(accessNotLatestProject); + + // make the new version latest afterwards via update (but have no access to old latest) + final var jsonProject = new Project(); + jsonProject.setIsLatest(true); + Response response = jersey.target(V1_PROJECT + "/" + accessNotLatestProject.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true) + .method(HttpMethod.PATCH, Entity.json(jsonProject)); + Assert.assertEquals(403, response.getStatus(), 0); + // ensure old is still latest + qm.getPersistenceManager().refreshAll(); + Assert.assertTrue(qm.getProject(noAccessLatestProject.getName(), noAccessLatestProject.getVersion()).isLatest()); + } + @Test public void getRootProjectsTest() { Project parent = qm.createProject("ABC", null, "1.0", null, null, null, true, false); @@ -1476,13 +1755,7 @@ public void cloneProjectConflictTest() { @Test public void cloneProjectWithAclTest() { - qm.createConfigProperty( - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), - "true", - ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), - null - ); + enablePortfolioAccessControl(); final var accessProject = new Project(); accessProject.setName("acme-app-a"); @@ -1521,6 +1794,45 @@ public void cloneProjectWithAclTest() { Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); } + @Test + public void cloneProjectAsLatestTest() { + EventService.getInstance().subscribe(CloneProjectEvent.class, CloneProjectTask.class); + + final var project = new Project(); + project.setName("acme-app-a"); + project.setVersion("1.0.0"); + project.setIsLatest(true); + qm.persist(project); + + final Response response = jersey.target("%s/clone".formatted(V1_PROJECT)).request() + .header(X_API_KEY, apiKey) + .put(Entity.json(""" + { + "project": "%s", + "version": "1.1.0", + "makeCloneLatest": true + } + """.formatted(project.getUuid()))); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); + + await("Cloning completion") + .atMost(Duration.ofSeconds(15)) + .pollInterval(Duration.ofMillis(50)) + .untilAsserted(() -> { + final Project clonedProject = qm.getProject("acme-app-a", "1.1.0"); + assertThat(clonedProject).isNotNull(); + assertThat(clonedProject.isLatest()).isTrue(); + + // ensure source is no longer latest + qm.getPersistenceManager().refresh(project); + assertThat(project.isLatest()).isFalse(); + }); + } + @Test // https://github.com/DependencyTrack/dependency-track/issues/3883 public void issue3883RegressionTest() { Response response = jersey.target(V1_PROJECT) @@ -1567,12 +1879,14 @@ public void issue3883RegressionTest() { "version": "1.0.0", "classifier": "APPLICATION", "uuid": "${json-unit.any-string}", - "active": true + "active": true, + "isLatest":false } ], "properties": [], "tags": [], "active": true, + "isLatest":false, "versions": [ { "uuid": "${json-unit.any-string}", @@ -1603,6 +1917,7 @@ public void issue3883RegressionTest() { "properties": [], "tags": [], "active": true, + "isLatest":false, "versions": [ { "uuid": "${json-unit.any-string}", @@ -1686,4 +2001,59 @@ public void issue4048RegressionTest() { } } + @Test + public void getLatestProjectTest() { + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + qm.createProject("Acme Example", null, "1.0.2", null, null, null, true, true, false); + qm.createProject("Different project", null, "1.0.3", null, null, null, true, true, false); + + Response response = jersey.target(V1_PROJECT_LATEST + "Acme Example") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Acme Example", json.getString("name")); + Assert.assertEquals("1.0.2", json.getString("version")); + } + + @Test + public void getLatestProjectWithAclEnabledTest() { + enablePortfolioAccessControl(); + + // Create project and give access to current principal's team. + Project accessProject = qm.createProject("acme-app-a", null, "1.0.0", null, null, null, true, false, false); + accessProject.setAccessTeams(List.of(team)); + qm.persist(accessProject); + + accessProject = qm.createProject("acme-app-a", null, "1.0.2", null, null, null, true, true, false); + accessProject.setAccessTeams(List.of(team)); + qm.persist(accessProject); + + final Response response = jersey.target(V1_PROJECT_LATEST + "acme-app-a") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("acme-app-a", json.getString("name")); + Assert.assertEquals("1.0.2", json.getString("version")); + } + + @Test + public void getLatestProjectWithAclEnabledNoAccessTest() { + enablePortfolioAccessControl(); + + // Create projects and give NO access + qm.createProject("acme-app-a", null, "1.0.0", null, null, null, true, false, false); + qm.createProject("acme-app-a", null, "1.0.2", null, null, null, true, true, false); + + final Response response = jersey.target(V1_PROJECT_LATEST + "acme-app-a") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } } diff --git a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java index 84a5c9daf7..100241138d 100644 --- a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java @@ -1397,7 +1397,7 @@ public void informIssue3981Test() { awaitBomProcessedNotification(bomUploadEvent); NOTIFICATIONS.clear(); - final Project clonedProject = qm.clone(project.getUuid(), "3.2.1", true, true, true, true, true, true, true); + final Project clonedProject = qm.clone(project.getUuid(), "3.2.1", true, true, true, true, true, true, true, false); bomBytes = """ { diff --git a/src/test/java/org/dependencytrack/tasks/scanners/TrivyAnalysisTaskTest.java b/src/test/java/org/dependencytrack/tasks/scanners/TrivyAnalysisTaskTest.java index 9138cd1eac..3bdb010338 100644 --- a/src/test/java/org/dependencytrack/tasks/scanners/TrivyAnalysisTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/scanners/TrivyAnalysisTaskTest.java @@ -38,6 +38,7 @@ import org.dependencytrack.model.Severity; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; @@ -315,8 +316,12 @@ Those using Woodstox to parse XML data may be vulnerable to Denial of Service at assertThat(qm.getCount(ComponentAnalysisCache.class)).isOne(); - assertThat(NOTIFICATIONS).satisfiesExactly(notification -> - assertThat(notification.getGroup()).isEqualTo(NotificationGroup.NEW_VULNERABILITY.name())); + assertThat(NOTIFICATIONS).satisfiesExactly( + notification -> + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_CREATED.name()), + notification -> + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.NEW_VULNERABILITY.name()) + ); wireMock.verify(postRequestedFor(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/PutBlob")) .withHeader("Trivy-Token", equalTo("token")) @@ -381,7 +386,10 @@ public void testAnalyzeWithNoVulnerabilities() { assertThat(qm.getCount(ComponentAnalysisCache.class)).isZero(); - assertThat(NOTIFICATIONS).isEmpty(); + assertThat(NOTIFICATIONS).satisfiesExactly( + notification -> + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_CREATED.name()) + ); wireMock.verify(postRequestedFor(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/PutBlob"))); wireMock.verify(postRequestedFor(urlPathEqualTo("/twirp/trivy.scanner.v1.Scanner/Scan"))); @@ -413,13 +421,19 @@ public void testAnalyzeWithConnectionError() { assertThat(qm.getCount(ComponentAnalysisCache.class)).isZero(); - assertThat(NOTIFICATIONS).satisfiesExactly(notification -> { - assertThat(notification.getGroup()).isEqualTo(NotificationGroup.ANALYZER.name()); - assertThat(notification.getLevel()).isEqualTo(NotificationLevel.ERROR); - assertThat(notification.getContent()).isEqualTo(""" - An error occurred while communicating with a vulnerability intelligence source. \ - Check log for details. Connection reset"""); - }); + assertThat(NOTIFICATIONS).satisfiesExactly( + notification -> { + assertThat(notification.getScope()).isEqualTo(NotificationScope.PORTFOLIO.name()); + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_CREATED.name()); + }, + notification -> { + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.ANALYZER.name()); + assertThat(notification.getLevel()).isEqualTo(NotificationLevel.ERROR); + assertThat(notification.getContent()).isEqualTo(""" + An error occurred while communicating with a vulnerability intelligence source. \ + Check log for details. Connection reset"""); + } + ); wireMock.verify(exactly(1), postRequestedFor(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/PutBlob"))); wireMock.verify(exactly(0), postRequestedFor(urlPathEqualTo("/twirp/trivy.scanner.v1.Scanner/Scan")));