diff --git a/src/Bootstrap/dist/css/bootstrap-theme.css b/src/Bootstrap/dist/css/bootstrap-theme.css index 1ab92c7f02..d318722783 100644 --- a/src/Bootstrap/dist/css/bootstrap-theme.css +++ b/src/Bootstrap/dist/css/bootstrap-theme.css @@ -613,6 +613,32 @@ img.reserved-indicator-icon { margin-top: 75px; margin-bottom: 0px; } +.package-warning { + padding-right: 8px; + padding-left: 8px; + border-radius: 2px; + margin-right: 5px; + color: #323130; +} +.package-warning--vulnerable { + padding-right: 8px; + padding-left: 8px; + border-radius: 2px; + margin-right: 5px; + color: #323130; + background-color: #fed9cc; +} +.package-warning--vulnerable i { + color: #d83b01; +} +.package-warning--deprecated { + padding-right: 8px; + padding-left: 8px; + border-radius: 2px; + margin-right: 5px; + color: #323130; + background-color: #fff4ce; +} .multi-select-dropdown { position: relative; display: block; @@ -1147,6 +1173,9 @@ p.frameworktableinfo-text { padding-left: 10px; border-left: 1px solid #dbdbdb; } +.page-api-keys .api-key-details .package-list li { + overflow-y: visible; +} .page-api-keys .api-key-details:not(:first-child) { border-top: 1px solid #dbdbdb; } @@ -1467,9 +1496,15 @@ p.frameworktableinfo-text { white-space: nowrap; text-overflow: ellipsis; } +.page-package-details .owner-list li .profile-icon { + margin: 1.5px; +} +.page-package-details .owner-list li .username { + margin-left: 6.5px; +} .page-package-details .owner-list img { - margin-right: 8px; border-radius: 5px; + margin: 2px; } .page-package-details .report-link i { color: #BE0151; diff --git a/src/Bootstrap/dist/js/bootstrap.js b/src/Bootstrap/dist/js/bootstrap.js index 5645023431..c7cc091728 100644 --- a/src/Bootstrap/dist/js/bootstrap.js +++ b/src/Bootstrap/dist/js/bootstrap.js @@ -585,6 +585,8 @@ if (typeof jQuery === 'undefined') { toggle: true } + Collapse.ARIA_EXPANDED_ALLOWED_ROLES = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem'] + Collapse.prototype.dimension = function () { var hasWidth = this.$element.hasClass('width') return hasWidth ? 'width' : 'height' @@ -615,7 +617,11 @@ if (typeof jQuery === 'undefined') { this.$element .removeClass('collapse') .addClass('collapsing')[dimension](0) - .attr('aria-expanded', true) + + // the aria-expanded attribute is only allowed when the element has an allowed role + if (Collapse.ARIA_EXPANDED_ALLOWED_ROLES.includes(this.$element.attr('role'))) { + this.$element.attr('aria-expanded', true) + } this.$trigger .removeClass('collapsed') @@ -655,7 +661,11 @@ if (typeof jQuery === 'undefined') { this.$element .addClass('collapsing') .removeClass('collapse in') - .attr('aria-expanded', false) + + // the aria-expanded attribute is only allowed when the element has an allowed role + if (Collapse.ARIA_EXPANDED_ALLOWED_ROLES.includes(this.$element.attr('role'))) { + this.$element.attr('aria-expanded', false) + } this.$trigger .addClass('collapsed') @@ -696,7 +706,10 @@ if (typeof jQuery === 'undefined') { Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { var isOpen = $element.hasClass('in') - $element.attr('aria-expanded', isOpen) + if (Collapse.ARIA_EXPANDED_ALLOWED_ROLES.includes(this.$element.attr('role'))) { + $element.attr('aria-expanded', isOpen) + } + $trigger .toggleClass('collapsed', !isOpen) .attr('aria-expanded', isOpen) @@ -2345,14 +2358,12 @@ if (typeof jQuery === 'undefined') { .end() .find('[data-toggle="tab"]') .attr('tabindex', "-1") - .attr('aria-expanded', false) .attr('aria-selected', false) element .addClass('active') .find('[data-toggle="tab"]') .attr('tabindex', "0") - .attr('aria-expanded', true) .attr('aria-selected', true) if (transition) { diff --git a/src/Bootstrap/js/collapse.js b/src/Bootstrap/js/collapse.js index a1a5ca49b6..fcb28893ed 100644 --- a/src/Bootstrap/js/collapse.js +++ b/src/Bootstrap/js/collapse.js @@ -38,6 +38,8 @@ toggle: true } + Collapse.ARIA_EXPANDED_ALLOWED_ROLES = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem'] + Collapse.prototype.dimension = function () { var hasWidth = this.$element.hasClass('width') return hasWidth ? 'width' : 'height' @@ -68,7 +70,11 @@ this.$element .removeClass('collapse') .addClass('collapsing')[dimension](0) - .attr('aria-expanded', true) + + // the aria-expanded attribute is only allowed when the element has an allowed role + if (Collapse.ARIA_EXPANDED_ALLOWED_ROLES.includes(this.$element.attr('role'))) { + this.$element.attr('aria-expanded', true) + } this.$trigger .removeClass('collapsed') @@ -108,7 +114,11 @@ this.$element .addClass('collapsing') .removeClass('collapse in') - .attr('aria-expanded', false) + + // the aria-expanded attribute is only allowed when the element has an allowed role + if (Collapse.ARIA_EXPANDED_ALLOWED_ROLES.includes(this.$element.attr('role'))) { + this.$element.attr('aria-expanded', false) + } this.$trigger .addClass('collapsed') @@ -149,7 +159,10 @@ Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { var isOpen = $element.hasClass('in') - $element.attr('aria-expanded', isOpen) + if (Collapse.ARIA_EXPANDED_ALLOWED_ROLES.includes(this.$element.attr('role'))) { + $element.attr('aria-expanded', isOpen) + } + $trigger .toggleClass('collapsed', !isOpen) .attr('aria-expanded', isOpen) diff --git a/src/Bootstrap/js/tab.js b/src/Bootstrap/js/tab.js index 436cf3b724..3f288e62aa 100644 --- a/src/Bootstrap/js/tab.js +++ b/src/Bootstrap/js/tab.js @@ -77,14 +77,12 @@ .end() .find('[data-toggle="tab"]') .attr('tabindex', "-1") - .attr('aria-expanded', false) .attr('aria-selected', false) element .addClass('active') .find('[data-toggle="tab"]') .attr('tabindex', "0") - .attr('aria-expanded', true) .attr('aria-selected', true) if (transition) { diff --git a/src/Bootstrap/less/theme/common-list-packages.less b/src/Bootstrap/less/theme/common-list-packages.less index 523193ab79..75d25c23cd 100644 --- a/src/Bootstrap/less/theme/common-list-packages.less +++ b/src/Bootstrap/less/theme/common-list-packages.less @@ -58,3 +58,30 @@ margin-top: 75px; margin-bottom: 0px; } + +@severe-warning-background-color: rgb(254, 217, 204); +@severe-warning-icon-color: rgb(216, 59, 1); +@warning-background-color: rgb(255, 244, 206); +@package-warning-color: rgb(50, 49, 48); +@badge-border-radius: 2px; + +.package-warning { + padding-right: 8px; + padding-left: 8px; + border-radius: @badge-border-radius; + margin-right: 5px; + color: @package-warning-color +} + +.package-warning--vulnerable { + .package-warning; + background-color: @severe-warning-background-color; + i { + color: @severe-warning-icon-color; + } +} + +.package-warning--deprecated { + .package-warning; + background-color: @warning-background-color; +} \ No newline at end of file diff --git a/src/Bootstrap/less/theme/page-api-keys.less b/src/Bootstrap/less/theme/page-api-keys.less index 4d05591dc6..02a48590e1 100644 --- a/src/Bootstrap/less/theme/page-api-keys.less +++ b/src/Bootstrap/less/theme/page-api-keys.less @@ -132,6 +132,11 @@ border-left: 1px solid @gray-lighter; } } + .package-list { + li { + overflow-y: visible; + } + } } .api-key-details:not(:first-child) diff --git a/src/Bootstrap/less/theme/page-display-package.less b/src/Bootstrap/less/theme/page-display-package.less index 6f6565dcd2..41a20b22f7 100644 --- a/src/Bootstrap/less/theme/page-display-package.less +++ b/src/Bootstrap/less/theme/page-display-package.less @@ -248,11 +248,19 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + + .profile-icon { + margin: 1.5px; + } + + .username{ + margin-left: 6.5px; + } } img { - margin-right: 8px; border-radius: 5px; + margin: 2px; } } diff --git a/src/NuGetGallery/App_Code/ViewHelpers.cshtml b/src/NuGetGallery/App_Code/ViewHelpers.cshtml index b15da2a79a..3bacba6c96 100644 --- a/src/NuGetGallery/App_Code/ViewHelpers.cshtml +++ b/src/NuGetGallery/App_Code/ViewHelpers.cshtml @@ -577,7 +577,7 @@ var hlp = new AccordionHelper(name, formModelStatePrefix, expanded, page); if (!disabled) { -
@content(MvcHtmlString.Empty) diff --git a/src/NuGetGallery/App_Data/Files/Content/Trusted-Image-Domains.json b/src/NuGetGallery/App_Data/Files/Content/Trusted-Image-Domains.json index de5ca2d786..acaf635f9d 100644 --- a/src/NuGetGallery/App_Data/Files/Content/Trusted-Image-Domains.json +++ b/src/NuGetGallery/App_Data/Files/Content/Trusted-Image-Domains.json @@ -34,6 +34,7 @@ "sonarcloud.io", "travis-ci.com", "travis-ci.org", + "wakatime.com", "avatars.githubusercontent.com", "raw.github.com", "raw.githubusercontent.com", diff --git a/src/NuGetGallery/Helpers/SearchResponseHelper.cs b/src/NuGetGallery/Helpers/SearchResponseHelper.cs new file mode 100644 index 0000000000..d91d8723ef --- /dev/null +++ b/src/NuGetGallery/Helpers/SearchResponseHelper.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using NuGet.Services.Entities; + +namespace NuGetGallery.Helpers +{ + public static class SearchResponseHelper + { + public static ICollection GetDeprecationsOrNull(JToken docDeprecation) + { + PackageDeprecation deprecation = null; + if (docDeprecation != null) + { + var docReasons = docDeprecation.Value("Reasons"); + if (docReasons != null && docReasons.HasValues) + { + PackageDeprecationStatus status = PackageDeprecationStatus.NotDeprecated; + foreach (var reason in docReasons) + { + if (Enum.TryParse(reason.Value(), out var pdStatus)) + { + status |= pdStatus; + } + } + + var docAlternatePackage = docDeprecation["AlternatePackage"]; + Package alternatePackage = null; + if (docAlternatePackage != null) + { + var range = docAlternatePackage.Value("Range"); + var id = docAlternatePackage.Value("Id"); + if (!string.IsNullOrEmpty(range) && !string.IsNullOrEmpty(id)) + { + var version = string.Empty; + var commaIndex = range.IndexOf(","); + if (range.StartsWith("[") && commaIndex > 0) + { + var startIndex = 1; + version = range.Substring(startIndex, commaIndex - startIndex); + } + + alternatePackage = new Package() + { + Id = id, + Version = version + }; + } + } + + deprecation = new PackageDeprecation() + { + CustomMessage = docDeprecation.Value("Message"), + Status = status, + AlternatePackage = alternatePackage + }; + } + } + + return deprecation == null ? null : new List() { deprecation }; + } + + public static ICollection GetVulnerabilities(JArray docVulnerabilities) + { + var vulnerabilities = new List(); + if (docVulnerabilities != null) + { + vulnerabilities = docVulnerabilities.Select(v => new VulnerablePackageVersionRange() + { + Vulnerability = new PackageVulnerability() + { + AdvisoryUrl = v.Value("AdvisoryUrl"), + Severity = (PackageVulnerabilitySeverity)v.Value("Severity") + } + }) + .ToList(); + } + + return vulnerabilities; + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Helpers/ViewModelExtensions/DisplayPackageViewModelFactory.cs b/src/NuGetGallery/Helpers/ViewModelExtensions/DisplayPackageViewModelFactory.cs index adbcf78989..780e0d0a54 100644 --- a/src/NuGetGallery/Helpers/ViewModelExtensions/DisplayPackageViewModelFactory.cs +++ b/src/NuGetGallery/Helpers/ViewModelExtensions/DisplayPackageViewModelFactory.cs @@ -6,6 +6,7 @@ using System.Linq; using NuGet.Services.Entities; using NuGet.Versioning; +using NuGetGallery.Helpers; namespace NuGetGallery { @@ -217,60 +218,11 @@ private DisplayPackageViewModel SetupCommon( viewModel.MaxVulnerabilitySeverity = default; } - viewModel.PackageWarningIconTitle = - GetWarningIconTitle(viewModel.Version, deprecation, maxVulnerabilitySeverity); + viewModel.PackageWarningIconTitle = WarningTitleHelper.GetWarningIconTitle(viewModel.Version, deprecation, maxVulnerabilitySeverity); return viewModel; } - private static string GetWarningIconTitle( - string version, - PackageDeprecation deprecation, - PackageVulnerabilitySeverity? maxVulnerabilitySeverity) - { - // We want a tooltip title for the warning icon, which concatenates deprecation and vulnerability information cleanly - var deprecationTitle = ""; - if (deprecation != null) - { - deprecationTitle = version; - var isLegacy = deprecation.Status.HasFlag(PackageDeprecationStatus.Legacy); - var hasCriticalBugs = deprecation.Status.HasFlag(PackageDeprecationStatus.CriticalBugs); - if (hasCriticalBugs) - { - if (isLegacy) - { - deprecationTitle += " is deprecated because it's legacy and has critical bugs"; - } - else - { - deprecationTitle += " is deprecated because it has critical bugs"; - } - } - else if (isLegacy) - { - deprecationTitle += " is deprecated because it's legacy and no longer maintained"; - } - else - { - deprecationTitle += " is deprecated"; - } - } - - if (maxVulnerabilitySeverity.HasValue) - { - var severity = Enum.GetName(typeof(PackageVulnerabilitySeverity), maxVulnerabilitySeverity)?.ToLowerInvariant() ?? "unknown"; - var vulnerabilitiesTitle = $"{version} has at least one vulnerability with {severity} severity."; - - return string.IsNullOrEmpty(deprecationTitle) - ? vulnerabilitiesTitle - : $"{deprecationTitle}; {vulnerabilitiesTitle}"; - } - - return string.IsNullOrEmpty(deprecationTitle) - ? string.Empty - : $"{deprecationTitle}."; - } - private static string GetPushedBy(Package package, User currentUser, Dictionary pushedByCache) { var userPushedBy = package.User; diff --git a/src/NuGetGallery/Helpers/ViewModelExtensions/ListPackageItemViewModelFactory.cs b/src/NuGetGallery/Helpers/ViewModelExtensions/ListPackageItemViewModelFactory.cs index 98dcb9ee74..5236dfdfbb 100644 --- a/src/NuGetGallery/Helpers/ViewModelExtensions/ListPackageItemViewModelFactory.cs +++ b/src/NuGetGallery/Helpers/ViewModelExtensions/ListPackageItemViewModelFactory.cs @@ -3,6 +3,7 @@ using System.Linq; using NuGet.Services.Entities; +using NuGetGallery.Helpers; namespace NuGetGallery { @@ -39,6 +40,19 @@ private ListPackageItemViewModel SetupInternal(ListPackageItemViewModel viewMode viewModel.MinClientVersion = package.MinClientVersion; viewModel.Owners = package.PackageRegistration?.Owners?.Select(GetBasicUserViewModel).ToList(); viewModel.IsVerified = package.PackageRegistration?.IsVerified; + viewModel.IsDeprecated = package.Deprecations?.Count > 0; + viewModel.IsVulnerable = package.VulnerablePackageRanges?.Count > 0; + + if (viewModel.IsDeprecated) + { + viewModel.DeprecationTitle = WarningTitleHelper.GetDeprecationTitle(package.Version, package.Deprecations.First().Status); + } + + if (viewModel.IsVulnerable) + { + var maxVulnerabilitySeverity = package.VulnerablePackageRanges.Max(vpr => vpr.Vulnerability.Severity); + viewModel.VulnerabilityTitle = WarningTitleHelper.GetVulnerabilityTitle(package.Version, maxVulnerabilitySeverity); + } viewModel.CanDisplayPrivateMetadata = CanPerformAction(currentUser, package, ActionsRequiringPermissions.DisplayPrivatePackageMetadata); viewModel.CanEdit = CanPerformAction(currentUser, package, ActionsRequiringPermissions.EditPackage); @@ -68,9 +82,10 @@ private static bool CanPerformAction(User currentUser, Package package, ActionRe private static BasicUserViewModel GetBasicUserViewModel(User user) { - return new BasicUserViewModel { - Username = user.Username, - EmailAddress = user.EmailAddress, + return new BasicUserViewModel + { + Username = user.Username, + EmailAddress = user.EmailAddress, IsOrganization = user is Organization, IsLocked = user.IsLocked }; diff --git a/src/NuGetGallery/Helpers/WarningTitleHelper.cs b/src/NuGetGallery/Helpers/WarningTitleHelper.cs new file mode 100644 index 0000000000..7381d3cf11 --- /dev/null +++ b/src/NuGetGallery/Helpers/WarningTitleHelper.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using NuGet.Services.Entities; + +namespace NuGetGallery.Helpers +{ + public static class WarningTitleHelper + { + public static string GetWarningIconTitle( + string version, + PackageDeprecation deprecation, + PackageVulnerabilitySeverity? maxVulnerabilitySeverity) + { + // We want a tooltip title for the warning icon, which concatenates deprecation and vulnerability information cleanly + var deprecationTitle = ""; + if (deprecation != null) + { + deprecationTitle = GetDeprecationTitle(version, deprecation.Status); + } + + if (maxVulnerabilitySeverity.HasValue) + { + var vulnerabilitiesTitle = GetVulnerabilityTitle(version, maxVulnerabilitySeverity.Value); + return string.IsNullOrEmpty(deprecationTitle) + ? vulnerabilitiesTitle + : $"{deprecationTitle.TrimEnd('.')}; {vulnerabilitiesTitle}"; + } + + return string.IsNullOrEmpty(deprecationTitle) ? string.Empty : deprecationTitle; + } + + public static string GetVulnerabilityTitle(string version, PackageVulnerabilitySeverity maxVulnerabilitySeverity) + { + var severity = Enum.GetName(typeof(PackageVulnerabilitySeverity), maxVulnerabilitySeverity)?.ToLowerInvariant() ?? "unknown"; + return $"{version} has at least one vulnerability with {severity} severity."; + } + + public static string GetDeprecationTitle(string version, PackageDeprecationStatus status) + { + var deprecationTitle = version; + var isLegacy = status.HasFlag(PackageDeprecationStatus.Legacy); + var hasCriticalBugs = status.HasFlag(PackageDeprecationStatus.CriticalBugs); + + if (hasCriticalBugs) + { + if (isLegacy) + { + deprecationTitle += " is deprecated because it is no longer maintained and has critical bugs"; + } + else + { + deprecationTitle += " is deprecated because it has critical bugs"; + } + } + else if (isLegacy) + { + deprecationTitle += " is deprecated because it is no longer maintained"; + } + else + { + deprecationTitle += " is deprecated"; + } + + return $"{deprecationTitle}."; + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Infrastructure/Lucene/ExternalSearchService.cs b/src/NuGetGallery/Infrastructure/Lucene/ExternalSearchService.cs index 52f715f45a..2a7e6b67ed 100644 --- a/src/NuGetGallery/Infrastructure/Lucene/ExternalSearchService.cs +++ b/src/NuGetGallery/Infrastructure/Lucene/ExternalSearchService.cs @@ -10,6 +10,7 @@ using NuGet.Services.Entities; using NuGetGallery.Configuration; using NuGetGallery.Diagnostics; +using NuGetGallery.Helpers; using NuGetGallery.Infrastructure.Lucene; namespace NuGetGallery.Infrastructure.Search @@ -25,7 +26,7 @@ public class ExternalSearchService : ISearchService, IIndexingService, IRawSearc public string IndexPath { - get { return string.Empty ; } + get { return string.Empty; } } public bool IsLocal @@ -82,7 +83,7 @@ private async Task SearchCore(SearchFilter filter, bool raw) if (content == null) { results = new SearchResults(0, null, Enumerable.Empty().AsQueryable()); - } + } else if (filter.CountOnly || content.TotalHits == 0) { results = new SearchResults(content.TotalHits, content.IndexTimestamp); @@ -175,11 +176,11 @@ internal static Package ReadPackage(JObject doc, string semVerLevel) doc.Value("Dependencies") .Cast() .Select(obj => new PackageDependency() - { - Id = obj.Value("Id"), - VersionSpec = obj.Value("VersionSpec"), - TargetFramework = obj.Value("TargetFramework") - }) + { + Id = obj.Value("Id"), + VersionSpec = obj.Value("VersionSpec"), + TargetFramework = obj.Value("TargetFramework") + }) .ToArray(); var frameworks = @@ -189,8 +190,10 @@ internal static Package ReadPackage(JObject doc, string semVerLevel) var reg = doc["PackageRegistration"]; PackageRegistration registration = null; - if(reg != null) { - registration = new PackageRegistration() { + if (reg != null) + { + registration = new PackageRegistration() + { Id = reg.Value("Id"), Owners = reg.Value("Owners") .Select(v => new User { Username = v.Value() }) @@ -205,6 +208,12 @@ internal static Package ReadPackage(JObject doc, string semVerLevel) var isLatestStable = doc.Value("IsLatestStable"); var semVer2 = SemVerLevelKey.ForSemVerLevel(semVerLevel) == SemVerLevelKey.SemVer2; + var docDeprecation = doc["Deprecation"]; + var deprecations = SearchResponseHelper.GetDeprecationsOrNull(docDeprecation); + + var docVulnerabilities = doc.Value("Vulnerabilities"); + var vulnerabilities = SearchResponseHelper.GetVulnerabilities(docVulnerabilities); + return new Package { Copyright = doc.Value("Copyright"), @@ -243,7 +252,9 @@ internal static Package ReadPackage(JObject doc, string semVerLevel) LicenseNames = doc.Value("LicenseNames"), LicenseReportUrl = doc.Value("LicenseReportUrl"), HideLicenseReport = doc.Value("HideLicenseReport"), - Listed = doc.Value("Listed") + Listed = doc.Value("Listed"), + Deprecations = deprecations, + VulnerablePackageRanges = vulnerabilities }; } diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index 2f11378fe9..13deba5cc5 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -227,6 +227,7 @@ + @@ -235,6 +236,7 @@ + @@ -1444,7 +1446,6 @@ - diff --git a/src/NuGetGallery/Scripts/gallery/async-file-upload.js b/src/NuGetGallery/Scripts/gallery/async-file-upload.js index 62687e86c2..3d003461cd 100644 --- a/src/NuGetGallery/Scripts/gallery/async-file-upload.js +++ b/src/NuGetGallery/Scripts/gallery/async-file-upload.js @@ -193,8 +193,8 @@ break; case "error": // IIS returns 404.13 (NotFound) when maxAllowedContentLength limit is exceeded. - if (fullResponse === "Not Found") { - displayErrors(["The package file exceeds the size limit. Please try again."]); + if (fullResponse === "Not Found" || fullResponse === "Request Entity Too Large") { + displayErrors(["The package file exceeds the size limit of 250 MB. Please reduce the package size and try again."]); } else { displayErrors(model.responseJSON); diff --git a/src/NuGetGallery/Scripts/gallery/common.js b/src/NuGetGallery/Scripts/gallery/common.js index cfed8a112e..10e973ffc7 100644 --- a/src/NuGetGallery/Scripts/gallery/common.js +++ b/src/NuGetGallery/Scripts/gallery/common.js @@ -461,13 +461,40 @@ }; nuget.setPopovers = function () { - var popoverElement = $(this); - var popoverElementDom = this; + setPopoversInternal(this, rightWithVerticalFallback); + } + + function rightWithVerticalFallback(popoverElement, ownerElement) { + // Both numbers below are in CSS pixels. + const MinSpaceOnRight = 150; + const MinSpaceOnTop = 100; + + const ownerBoundingBox = ownerElement.getBoundingClientRect(); + const spaceOnRight = window.innerWidth - ownerBoundingBox.right; + if (spaceOnRight > MinSpaceOnRight) { + return 'right'; + } + const spaceOnTop = ownerBoundingBox.top; + if (spaceOnTop > MinSpaceOnTop) { + return 'top'; + } + + return 'bottom'; + } + + function setPopoversInternal(element, placement) { + var popoverElement = $(element); + var popoverElementDom = element; var originalLabel = popoverElementDom.ariaLabel; var popoverHideTimeMS = 2000; var popoverFadeTimeMS = 200; - popoverElement.popover({ trigger: 'hover' }); + var popoverOptions = { trigger: 'hover', container: 'body' }; + if (placement) { + popoverOptions.placement = placement; + } + + popoverElement.popover(popoverOptions); popoverElement.click(popoverShowAndHide); popoverElement.focus(popoverShowAndHide); popoverElement.keyup(function (event) { diff --git a/src/NuGetGallery/Scripts/gallery/page-list-packages.js b/src/NuGetGallery/Scripts/gallery/page-list-packages.js index c3e682f196..c0f5f756f0 100644 --- a/src/NuGetGallery/Scripts/gallery/page-list-packages.js +++ b/src/NuGetGallery/Scripts/gallery/page-list-packages.js @@ -2,6 +2,16 @@ $(function() { 'use strict'; $(".reserved-indicator").each(window.nuget.setPopovers); + $(".package-warning--vulnerable").each(window.nuget.setPopovers); + $(".package-warning--deprecated").each(window.nuget.setPopovers); + + const storage = window['localStorage']; + const focusResultsColumnKey = 'focus_results_column'; + + if (storage && storage.getItem(focusResultsColumnKey)) { + storage.removeItem(focusResultsColumnKey); + document.getElementById('results-column').focus({ preventScroll: true }); + } const searchForm = document.forms.search; const allFrameworks = document.querySelectorAll('.framework'); @@ -69,6 +79,11 @@ $(function() { function submitSearchForm() { constructFilterParameter(searchForm.frameworks, allFrameworks); constructFilterParameter(searchForm.tfms, allTfms); + + if (storage) { + storage.setItem(focusResultsColumnKey, true); + } + searchForm.submit(); } diff --git a/src/NuGetGallery/ViewModels/ListPackageItemViewModel.cs b/src/NuGetGallery/ViewModels/ListPackageItemViewModel.cs index aa32eaef5d..04d8701398 100644 --- a/src/NuGetGallery/ViewModels/ListPackageItemViewModel.cs +++ b/src/NuGetGallery/ViewModels/ListPackageItemViewModel.cs @@ -58,6 +58,9 @@ public bool UseVersion public bool CanDeleteSymbolsPackage { get; set; } public bool CanDeprecate { get; set; } + public string VulnerabilityTitle { get; set; } + public string DeprecationTitle { get; set; } + public void SetShortDescriptionFrom(string fullDescription) { ShortDescription = fullDescription.TruncateAtWordBoundary(_descriptionLengthLimit, _omissionString, out var wasTruncated); diff --git a/src/NuGetGallery/ViewModels/PackageViewModel.cs b/src/NuGetGallery/ViewModels/PackageViewModel.cs index 3e6c250a9e..bd801630bb 100644 --- a/src/NuGetGallery/ViewModels/PackageViewModel.cs +++ b/src/NuGetGallery/ViewModels/PackageViewModel.cs @@ -30,6 +30,7 @@ public class PackageViewModel : IPackageVersionModel public string FullVersion { get; set; } public PackageStatusSummary PackageStatusSummary { get; set; } public bool IsVulnerable { get; set; } + public bool IsDeprecated { get; set; } public bool IsCurrent(IPackageVersionModel current) { diff --git a/src/NuGetGallery/Views/Organizations/_OrganizationAccountManageMembers.cshtml b/src/NuGetGallery/Views/Organizations/_OrganizationAccountManageMembers.cshtml index f3a4bdcddc..b061f3334e 100644 --- a/src/NuGetGallery/Views/Organizations/_OrganizationAccountManageMembers.cshtml +++ b/src/NuGetGallery/Views/Organizations/_OrganizationAccountManageMembers.cshtml @@ -40,7 +40,7 @@
- +
@@ -72,8 +72,8 @@
@if (Model.CanManageMemberships) { - } else diff --git a/src/NuGetGallery/Views/Packages/DisplayPackage.cshtml b/src/NuGetGallery/Views/Packages/DisplayPackage.cshtml index f74a350780..739d7e937a 100644 --- a/src/NuGetGallery/Views/Packages/DisplayPackage.cshtml +++ b/src/NuGetGallery/Views/Packages/DisplayPackage.cshtml @@ -184,7 +184,7 @@ @helper CommandTab(PackageManagerViewModel packageManager, bool active) {
  • @if (!string.IsNullOrEmpty(owner.EmailAddress)) { - + @ViewHelpers.GravatarImage( Url, owner.EmailAddress, @@ -1284,7 +1281,7 @@ GalleryConstants.GravatarElementSize) } - + @owner.Username
  • diff --git a/src/NuGetGallery/Views/Packages/_SupportedFrameworksTable.cshtml b/src/NuGetGallery/Views/Packages/_SupportedFrameworksTable.cshtml index eb83f49c6c..223b5f7ec5 100644 --- a/src/NuGetGallery/Views/Packages/_SupportedFrameworksTable.cshtml +++ b/src/NuGetGallery/Views/Packages/_SupportedFrameworksTable.cshtml @@ -3,7 +3,10 @@ Product - Versions + + + Compatible and additional computed target framework versions. + @@ -21,11 +24,13 @@ { if (frameworkVersion.IsComputed) { - @frameworkVersion.Framework.GetShortFolderName() + + @frameworkVersion.Framework.GetShortFolderName() was computed.  } else { - @frameworkVersion.Framework.GetShortFolderName() + + @frameworkVersion.Framework.GetShortFolderName() is compatible.  } } diff --git a/src/NuGetGallery/Views/Shared/ListPackages.cshtml b/src/NuGetGallery/Views/Shared/ListPackages.cshtml index c603f55c1e..e9b1e446b2 100644 --- a/src/NuGetGallery/Views/Shared/ListPackages.cshtml +++ b/src/NuGetGallery/Views/Shared/ListPackages.cshtml @@ -135,7 +135,7 @@
    }
    -
    +

    diff --git a/src/NuGetGallery/Views/Shared/_ListPackage.cshtml b/src/NuGetGallery/Views/Shared/_ListPackage.cshtml index 3eb2c2f428..6c2064e897 100644 --- a/src/NuGetGallery/Views/Shared/_ListPackage.cshtml +++ b/src/NuGetGallery/Views/Shared/_ListPackage.cshtml @@ -72,6 +72,29 @@ }

    + + @if(Model.IsVulnerable || Model.IsDeprecated) + { +
      +
    • + @if(Model.IsVulnerable) + { + + + Vulnerable + + } + @if(Model.IsDeprecated) + { + + + Deprecated + + } +
    • +
    + } +
    • @@ -126,7 +149,7 @@ @Model.ShortDescription @if (Model.IsDescriptionTruncated) { - @Html.RouteLink("More information", RouteName.DisplayPackage, new { Model.Id, Model.Version }, new { @title = "More information about " + Model.Id + " package." }) + @Html.RouteLink("More information", RouteName.DisplayPackage, new { Model.Id, Model.Version }, new { @title = "More information about " + Model.Id + " package", @aria_label = "More information about " + Model.Id + " package" }) }
    diff --git a/src/NuGetGallery/Views/Users/Organizations.cshtml b/src/NuGetGallery/Views/Users/Organizations.cshtml index 70ec8d2579..478fd1c9a6 100644 --- a/src/NuGetGallery/Views/Users/Organizations.cshtml +++ b/src/NuGetGallery/Views/Users/Organizations.cshtml @@ -12,7 +12,7 @@ Url, CurrentUser, false, - @Organizations   Add new) + @Organizations   Add new)
    diff --git a/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs index 8a02847ddd..368523f312 100644 --- a/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs @@ -1422,10 +1422,10 @@ public async Task ShowsFirstDeprecationPerPackage() [InlineData(PackageDeprecationStatus.NotDeprecated, PackageDeprecationStatus.NotDeprecated, "")] [InlineData(PackageDeprecationStatus.CriticalBugs, PackageDeprecationStatus.NotDeprecated, "{0} is deprecated because it has critical bugs.")] - [InlineData(PackageDeprecationStatus.Legacy, PackageDeprecationStatus.NotDeprecated, - "{0} is deprecated because it's legacy and no longer maintained.")] + [InlineData(PackageDeprecationStatus.Legacy, PackageDeprecationStatus.NotDeprecated, + "{0} is deprecated because it is no longer maintained.")] [InlineData(PackageDeprecationStatus.Legacy, PackageDeprecationStatus.CriticalBugs, - "{0} is deprecated because it's legacy and has critical bugs.")] + "{0} is deprecated because it is no longer maintained and has critical bugs.")] [InlineData(PackageDeprecationStatus.Other, PackageDeprecationStatus.NotDeprecated, "{0} is deprecated.")] public async Task ShowsCorrectDeprecationIconTitle( PackageDeprecationStatus deprecationStatus, @@ -1515,9 +1515,9 @@ public async Task ShowsCorrectDeprecationIconTitle( [Theory] [InlineData(false, false, "")] - [InlineData(true, false, "{0} is deprecated because it's legacy and no longer maintained.")] + [InlineData(true, false, "{0} is deprecated because it is no longer maintained.")] [InlineData(false, true, "{0} has at least one vulnerability with {1} severity.")] - [InlineData(true, true, "{0} is deprecated because it's legacy and no longer maintained; {0} has at least one vulnerability with {1} severity.")] + [InlineData(true, true, "{0} is deprecated because it is no longer maintained; {0} has at least one vulnerability with {1} severity.")] public async Task ShowsCombinedDeprecationAndVulnerabilitiesIconTitle( bool isDeprecationEnabled, bool isVulnerabilitiesEnabled, diff --git a/tests/NuGetGallery.Facts/Helpers/DeprecationItemsHelper.cs b/tests/NuGetGallery.Facts/Helpers/DeprecationItemsHelper.cs new file mode 100644 index 0000000000..46ed590bdd --- /dev/null +++ b/tests/NuGetGallery.Facts/Helpers/DeprecationItemsHelper.cs @@ -0,0 +1,73 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace NuGetGallery.Helpers +{ + public static class DeprecationItemsHelper + { + public static IEnumerable ValidObjects + { + get + { + yield return new object[] { + JObject.FromObject(new + { + Message = "message", + Reasons = new [] { "Other", "Legacy", "CriticalBugs" }, + AlternatePackage = new { + Id = "AnotherId", + Range = "[13.0.2-beta1, )" + } + }) + }; + yield return new object[] { + JObject.FromObject(new + { + Reasons = new [] { "Other", "Legacy", "CriticalBugs" }, + AlternatePackage = new { + Id = "AnotherId", + Range = "[13.0.2-beta1, )" + } + }) + }; + yield return new object[] { + JObject.FromObject(new + { + Message = "message", + Reasons = new [] { "Other", "Legacy", "CriticalBugs" }, + AlternatePackage = new {} + }) + }; + yield return new object[] { + JObject.FromObject(new + { + Reasons = new [] { "Legacy" }, + AlternatePackage = new {} + }) + }; + } + } + + public static IEnumerable InvalidObjects + { + get + { + yield return new object[] { + JObject.FromObject(new + { + Message = "message", + AlternatePackage = new { + Id = "AnotherId", + Range = "[13.0.2-beta1, )" + } + }) + }; + yield return new object[] { new JObject() }; + yield return new object[] { null }; + } + } + } +} diff --git a/tests/NuGetGallery.Facts/Helpers/VulnerabilityItemsHelper.cs b/tests/NuGetGallery.Facts/Helpers/VulnerabilityItemsHelper.cs new file mode 100644 index 0000000000..123c3d25d6 --- /dev/null +++ b/tests/NuGetGallery.Facts/Helpers/VulnerabilityItemsHelper.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace NuGetGallery.Helpers +{ + public static class VulnerabilityItemsHelper + { + public static IEnumerable ValidObjects + { + get + { + yield return new object[] { + new JArray() { + JObject.FromObject(new + { + AdvisoryURL = "url", + Severity = 1 + }) + } + }; + yield return new object[] { + new JArray() { + JObject.FromObject(new + { + AdvisoryURL = "url", + Severity = 1 + }), + JObject.FromObject(new + { + AdvisoryURL = "url", + Severity = 2 + }) + } + }; + } + } + + public static IEnumerable InvalidObjects + { + get + { + yield return new object[] { new JArray() }; + yield return new object[] { null }; + } + } + } +} diff --git a/tests/NuGetGallery.Facts/Infrastructure/Lucene/ExternalSearchServiceFacts.cs b/tests/NuGetGallery.Facts/Infrastructure/Lucene/ExternalSearchServiceFacts.cs index da72e17dcb..191b7b0704 100644 --- a/tests/NuGetGallery.Facts/Infrastructure/Lucene/ExternalSearchServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Infrastructure/Lucene/ExternalSearchServiceFacts.cs @@ -1,7 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Linq; using Newtonsoft.Json.Linq; +using NuGetGallery.Helpers; using Xunit; namespace NuGetGallery.Infrastructure.Search @@ -71,6 +73,110 @@ public void WhenSearchFilterIsSemVer2_SetsIsLatestSemVer2Properties() ""LicenseUrl"": """", ""RequiresLicenseAcceptance"": false }"; + + + [Theory] + [MemberData(nameof(DeprecationItemsHelper.ValidObjects), MemberType = typeof(DeprecationItemsHelper))] + public void WhenValidDeprecation_SetsPropertiesToPackage(JObject docDeprecation) + { + var doc = TheReadPackageMethod.CreateDocument(); + doc.Add("Deprecation", docDeprecation); + + // Act + var result = ExternalSearchService.ReadPackage(doc, SemVerLevelKey.SemVerLevel2); + + // Assert + var deprecationResult = result.Deprecations.First(); + var deprecation = SearchResponseHelper.GetDeprecationsOrNull(docDeprecation).First(); + + Assert.Equal(deprecation.CustomMessage, deprecationResult.CustomMessage); + + if (deprecation.AlternatePackage != null) + { + Assert.Equal(deprecation.AlternatePackage.Id, deprecationResult.AlternatePackage.Id); + Assert.Equal(deprecation.AlternatePackage.Version, deprecationResult.AlternatePackage.Version); + } + + Assert.Equal(deprecation.Status, deprecationResult.Status); + } + + [Theory] + [MemberData(nameof(DeprecationItemsHelper.InvalidObjects), MemberType = typeof(DeprecationItemsHelper))] + public void WhenInvalidDeprecation_SetsNullToPackage(JObject deprecation) + { + var doc = TheReadPackageMethod.CreateDocument(); + if (deprecation != null) + { + doc.Add("Deprecation", deprecation); + } + + // Act + var result = ExternalSearchService.ReadPackage(doc, SemVerLevelKey.SemVerLevel2); + + // Assert + Assert.Null(result.Deprecations); + } + + [Theory] + [MemberData(nameof(VulnerabilityItemsHelper.ValidObjects), MemberType = typeof(VulnerabilityItemsHelper))] + public void WhenValidVulnerabilities_SetsPropertiesToPackage(JArray docVulnerabilities) + { + var doc = TheReadPackageMethod.CreateDocument(); + doc.Add("Vulnerabilities", docVulnerabilities); + + // Act + var result = ExternalSearchService.ReadPackage(doc, SemVerLevelKey.SemVerLevel2); + + // Assert + var vulnerabilities = SearchResponseHelper.GetVulnerabilities(docVulnerabilities); + var vulnerabilitiesResult = result.VulnerablePackageRanges; + + Assert.NotNull(vulnerabilitiesResult); + Assert.NotEmpty(vulnerabilitiesResult); + Assert.Equal(vulnerabilities.Count, vulnerabilitiesResult.Count); + + for (var index = 0; index < vulnerabilities.Count; index++) + { + Assert.Equal(vulnerabilities.ElementAt(index).Vulnerability.AdvisoryUrl, vulnerabilitiesResult.ElementAt(index).Vulnerability.AdvisoryUrl); + Assert.Equal(vulnerabilities.ElementAt(index).Vulnerability.Severity, vulnerabilitiesResult.ElementAt(index).Vulnerability.Severity); + } + } + + [Theory] + [MemberData(nameof(VulnerabilityItemsHelper.InvalidObjects), MemberType = typeof(VulnerabilityItemsHelper))] + public void WhenInvalidVulnerabilities_SetsEmptyArrayToPackage(JArray docVulnerabilities) + { + var doc = TheReadPackageMethod.CreateDocument(); + if (docVulnerabilities != null) + { + doc.Add("Vulnerabilities", docVulnerabilities); + } + + // Act + var result = ExternalSearchService.ReadPackage(doc, SemVerLevelKey.SemVerLevel2); + + // Assert + Assert.Empty(result.VulnerablePackageRanges); + } + + public static JObject CreateDocument() + { + var doc = new JObject(); + + doc.Add("PackageRegistration", JObject.FromObject( + new + { + Id = "myId", + Owners = new string[] { "nuget" }, + DownloadCount = 1, + IsVerified = true, + Key = 2 + })); + doc.Add("Dependencies", JToken.Parse("[]")); + doc.Add("SupportedFrameworks", JToken.Parse("[]")); + + return doc; + } } } } diff --git a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj index 9d479c94c2..cbff61bf63 100644 --- a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj +++ b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj @@ -306,6 +306,7 @@ + @@ -316,6 +317,7 @@ + diff --git a/tests/NuGetGallery.Facts/ViewModels/ListPackageItemViewModelFacts.cs b/tests/NuGetGallery.Facts/ViewModels/ListPackageItemViewModelFacts.cs index 75a323aef5..e2ddb01d3d 100644 --- a/tests/NuGetGallery.Facts/ViewModels/ListPackageItemViewModelFacts.cs +++ b/tests/NuGetGallery.Facts/ViewModels/ListPackageItemViewModelFacts.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Linq; using Moq; +using Newtonsoft.Json.Linq; using NuGet.Services.Entities; +using NuGetGallery.Helpers; using Xunit; namespace NuGetGallery.ViewModels @@ -346,6 +348,159 @@ private void SignPackage() } } + public class IsDeprecated + { + [Theory] + [MemberData(nameof(DeprecationItemsHelper.ValidObjects), MemberType = typeof(DeprecationItemsHelper))] + public void SetDeprecationToTrueWhenIsValid(JObject docDeprecation) + { + var deprecations = SearchResponseHelper.GetDeprecationsOrNull(docDeprecation); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + Deprecations = deprecations + }; + + var vm = CreateListPackageItemViewModel(package); + + Assert.True(vm.IsDeprecated); + } + + [Theory] + [MemberData(nameof(DeprecationItemsHelper.InvalidObjects), MemberType = typeof(DeprecationItemsHelper))] + public void SetDeprecationToFalseWhenIsInvalid(JObject docDeprecation) + { + var deprecations = SearchResponseHelper.GetDeprecationsOrNull(docDeprecation); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + Deprecations = deprecations + }; + + var vm = CreateListPackageItemViewModel(package); + + Assert.False(vm.IsDeprecated); + } + } + + public class DeprecationTitle + { + [Theory] + [MemberData(nameof(DeprecationItemsHelper.ValidObjects), MemberType = typeof(DeprecationItemsHelper))] + public void SetDeprecationTitleWhenDeprecationIsValid(JObject docDeprecation) + { + var deprecations = SearchResponseHelper.GetDeprecationsOrNull(docDeprecation); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + Deprecations = deprecations + }; + + var deprecationTitle = WarningTitleHelper.GetDeprecationTitle(package.Version, package.Deprecations.First().Status); + + var vm = CreateListPackageItemViewModel(package); + + Assert.Equal(deprecationTitle, vm.DeprecationTitle); + } + + [Theory] + [MemberData(nameof(DeprecationItemsHelper.InvalidObjects), MemberType = typeof(DeprecationItemsHelper))] + public void DeprecationTitleIsNullWhenDeprecationIsInvalid(JObject docDeprecation) + { + var deprecations = SearchResponseHelper.GetDeprecationsOrNull(docDeprecation); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + Deprecations = deprecations + }; + + var vm = CreateListPackageItemViewModel(package); + + Assert.Null(vm.DeprecationTitle); + } + } + + public class IsVulnerable + { + [Theory] + [MemberData(nameof(VulnerabilityItemsHelper.ValidObjects), MemberType = typeof(VulnerabilityItemsHelper))] + public void SetVulnerableToTrueWhenIsValid(JArray docVulnerabilities) + { + var vulnerabilities = SearchResponseHelper.GetVulnerabilities(docVulnerabilities); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + VulnerablePackageRanges = vulnerabilities + }; + + var vm = CreateListPackageItemViewModel(package); + + Assert.True(vm.IsVulnerable); + } + + [Theory] + [MemberData(nameof(VulnerabilityItemsHelper.InvalidObjects), MemberType = typeof(VulnerabilityItemsHelper))] + public void SetVulnerableToFalseWhenIsInvalid(JArray docVulnerabilities) + { + var vulnerabilities = SearchResponseHelper.GetVulnerabilities(docVulnerabilities); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + VulnerablePackageRanges = vulnerabilities + }; + + var vm = CreateListPackageItemViewModel(package); + + Assert.False(vm.IsVulnerable); + } + } + + public class VulnerabilityTitle + { + [Theory] + [MemberData(nameof(VulnerabilityItemsHelper.ValidObjects), MemberType = typeof(VulnerabilityItemsHelper))] + public void SetVulnerabilityTitleWhenVulnerabilitiesAreValid(JArray docVulnerabilities) + { + var vulnerabilities = SearchResponseHelper.GetVulnerabilities(docVulnerabilities); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + VulnerablePackageRanges = vulnerabilities + }; + + var maxVulnerabilitySeverity = package.VulnerablePackageRanges.Max(vpr => vpr.Vulnerability.Severity); + var vulnerabilityTitle = WarningTitleHelper.GetVulnerabilityTitle(package.Version, maxVulnerabilitySeverity); + + var vm = CreateListPackageItemViewModel(package); + + Assert.Equal(vulnerabilityTitle, vm.VulnerabilityTitle); + } + + [Theory] + [MemberData(nameof(VulnerabilityItemsHelper.InvalidObjects), MemberType = typeof(VulnerabilityItemsHelper))] + public void VulnerabilityTitleIsNullWhenVulnerabilitiesAreInvalid(JArray docVulnerabilities) + { + var vulnerabilities = SearchResponseHelper.GetVulnerabilities(docVulnerabilities); + var package = new Package() + { + Version = "1.0.0", + PackageRegistration = new PackageRegistration { Id = "SomeId" }, + VulnerablePackageRanges = vulnerabilities + }; + + var vm = CreateListPackageItemViewModel(package); + + Assert.Null(vm.VulnerabilityTitle); + } + } + private static ListPackageItemViewModel CreateListPackageItemViewModel(Package package, User user = null) { return new ListPackageItemViewModelFactory(Mock.Of()).Create(package, currentUser: user);