diff --git a/build.ps1 b/build.ps1 index 4eab978485..f52fd28182 100644 --- a/build.ps1 +++ b/build.ps1 @@ -25,6 +25,10 @@ trap { if (-not (Test-Path "$PSScriptRoot/build")) { New-Item -Path "$PSScriptRoot/build" -ItemType "directory" } + +# Enable TLS 1.2 since GitHub requires it. +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + wget -UseBasicParsing -Uri "https://raw.githubusercontent.com/NuGet/ServerCommon/$BuildBranch/build/init.ps1" -OutFile "$PSScriptRoot/build/init.ps1" . "$PSScriptRoot/build/init.ps1" -BuildBranch "$BuildBranch" diff --git a/src/Bootstrap/dist/css/bootstrap-theme.css b/src/Bootstrap/dist/css/bootstrap-theme.css index 8af7c600ef..f7fd247cfb 100644 --- a/src/Bootstrap/dist/css/bootstrap-theme.css +++ b/src/Bootstrap/dist/css/bootstrap-theme.css @@ -348,6 +348,7 @@ img.reserved-indicator-icon { } .modal-container .modal-box .modal-body { padding: 0; + font-size: 14px; } .modal-container .modal-box .modal-body .tag-node { padding: 13px; @@ -358,15 +359,40 @@ img.reserved-indicator-icon { vertical-align: top; } .modal-container .modal-box .modal-body .action-node { - padding: 9% 6%; + padding: 0 6%; text-align: center; } .modal-container .modal-box .modal-body .action-button { font-size: 16px; line-height: 2em; } +.modal-container .modal-box .modal-body .form-button-node { + padding-right: 0; + padding-bottom: 0; + padding-left: 65%; + text-align: center; +} +.modal-container .modal-box .modal-body .username-submit { + padding-top: 13%; +} +.modal-container .modal-box .modal-body .email-submit { + padding-top: 16%; +} +.modal-container .modal-box .modal-body .close-button { + padding-top: 27%; +} +.modal-container .modal-box .modal-body .text-label { + padding: 15px; +} +.modal-container .modal-box .modal-body .form-error-message { + float: left; + padding-top: 6px; +} +.modal-container .modal-box .modal-body .text-row { + padding-top: 13px; +} .modal-container .modal-box .modal-body .transform-text { - padding: 0 15px; + padding: 40px 15px; margin: 0; } #edit-metadata-form-container .loading:after { @@ -1018,6 +1044,18 @@ img.reserved-indicator-icon { .manage-members-listing { margin-bottom: 0; } +.manage-members-listing .heading-left { + padding-left: 5px; +} +.manage-members-listing .heading-right { + padding-right: 5px; +} +.manage-members-listing .icon-left { + padding-left: 25px; +} +.manage-members-listing .member-icon { + vertical-align: middle; +} .page-manage-owners h2 .ms-Icon { position: relative; top: -2px; @@ -1065,6 +1103,9 @@ img.reserved-indicator-icon { margin-top: 0; margin-bottom: 10px; } +.page-manage-packages .subtitle { + margin-top: 24px; +} .page-manage-packages .form-section-title { margin-top: 40px; } @@ -1415,6 +1456,60 @@ img.reserved-indicator-icon { .page-status .table tbody + tbody { border: none; } +.page-transform-account .center-box { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + -webkit-box-align: stretch; + -webkit-align-items: stretch; + -ms-flex-align: stretch; + align-items: stretch; +} +.page-transform-account .center-box .form-box { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + flex-direction: column; + + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; +} +.page-transform-account .center-box .logo-box { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + margin-bottom: 50px; + flex-direction: column; + + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; +} +.page-transform-account .center-box .logo-box .logo { + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} .page-upload #browse-for-package-button { margin: 0; } diff --git a/src/Bootstrap/less/theme/all.less b/src/Bootstrap/less/theme/all.less index d65be97d39..3eae94d985 100644 --- a/src/Bootstrap/less/theme/all.less +++ b/src/Bootstrap/less/theme/all.less @@ -29,4 +29,5 @@ @import "page-statistics-overview.less"; @import "page-statistics-per-package.less"; @import "page-status.less"; -@import "page-upload.less"; +@import "page-transform-account.less"; +@import "page-upload.less"; \ No newline at end of file diff --git a/src/Bootstrap/less/theme/modals.less b/src/Bootstrap/less/theme/modals.less index 3ac6e51421..dd3e040ae8 100644 --- a/src/Bootstrap/less/theme/modals.less +++ b/src/Bootstrap/less/theme/modals.less @@ -38,6 +38,7 @@ .modal-body { padding: 0px; + font-size: 14px; .tag-node { background-color: #fff4ce; @@ -50,7 +51,7 @@ } .action-node { - padding: 9% 6%; + padding: 0% 6%; text-align: center; } @@ -59,8 +60,40 @@ line-height: 2em; } + .form-button-node { + padding-left: 65%; + padding-right: 0; + padding-bottom: 0; + text-align: center; + } + + .username-submit { + padding-top: 13%; + } + + .email-submit { + padding-top: 16%; + } + + .close-button { + padding-top: 27%; + } + + .text-label { + padding: 15px; + } + + .form-error-message { + padding-top: 6px; + float: left; + } + + .text-row { + padding-top: 13px; + } + .transform-text { - padding: 0px 15px; + padding: 40px 15px; margin: 0px; } } diff --git a/src/Bootstrap/less/theme/page-manage-organizations.less b/src/Bootstrap/less/theme/page-manage-organizations.less index e5bb19ca39..79ad41c436 100644 --- a/src/Bootstrap/less/theme/page-manage-organizations.less +++ b/src/Bootstrap/less/theme/page-manage-organizations.less @@ -45,4 +45,20 @@ .manage-members-listing { margin-bottom: 0; + + .heading-left { + padding-left: 5px; + } + + .heading-right { + padding-right: 5px; + } + + .icon-left { + padding-left: 25px; + } + + .member-icon { + vertical-align: middle; + } } \ No newline at end of file diff --git a/src/Bootstrap/less/theme/page-manage-packages.less b/src/Bootstrap/less/theme/page-manage-packages.less index a2f62bba4b..d19d478642 100644 --- a/src/Bootstrap/less/theme/page-manage-packages.less +++ b/src/Bootstrap/less/theme/page-manage-packages.less @@ -10,6 +10,10 @@ margin-top: 0; } + .subtitle { + margin-top: 24px; + } + .form-section-title { margin-top: @section-margin-top; diff --git a/src/Bootstrap/less/theme/page-transform-account.less b/src/Bootstrap/less/theme/page-transform-account.less new file mode 100644 index 0000000000..f51a3a9973 --- /dev/null +++ b/src/Bootstrap/less/theme/page-transform-account.less @@ -0,0 +1,24 @@ +.page-transform-account { + .center-box { + display: flex; + justify-content: space-between; + align-items: stretch; + + .form-box { + display: flex; + justify-content: space-between; + flex-direction: column; + } + + .logo-box { + display: flex; + justify-content: space-between; + flex-direction: column; + margin-bottom: 50px; + + .logo { + flex-grow: 1; + } + } + } +} \ No newline at end of file diff --git a/src/GalleryTools/Commands/HashCommand.cs b/src/GalleryTools/Commands/HashCommand.cs index 7c0b91682d..43f44870ed 100644 --- a/src/GalleryTools/Commands/HashCommand.cs +++ b/src/GalleryTools/Commands/HashCommand.cs @@ -63,7 +63,7 @@ private static async Task Hash(string connectionString, bool whatIf) do { - batch = allCredentials.Where(predicate).Take(BatchSize).ToList(); + batch = allCredentials.Where(predicate).OrderBy(x => x.Key).Take(BatchSize).ToList(); if (batch.Count > 0) { @@ -97,6 +97,7 @@ private static async Task Hash(string connectionString, bool whatIf) else { Console.WriteLine("Skipping DB save."); + allCredentials = allCredentials.Where(predicate).OrderBy(x => x.Key).Skip(batch.Count); } batchNumber++; diff --git a/src/NuGetGallery.Core/CoreConstants.cs b/src/NuGetGallery.Core/CoreConstants.cs index 0050fabc8c..c0ea412777 100644 --- a/src/NuGetGallery.Core/CoreConstants.cs +++ b/src/NuGetGallery.Core/CoreConstants.cs @@ -10,6 +10,7 @@ public static class CoreConstants public const int MaxPackageIdLength = 128; public const string PackageFileSavePathTemplate = "{0}.{1}{2}"; + public const string PackageFileBackupSavePathTemplate = "{0}/{1}/{2}.{3}"; public const string NuGetPackageFileExtension = ".nupkg"; diff --git a/src/NuGetGallery.Core/NuGetGallery.Core.csproj b/src/NuGetGallery.Core/NuGetGallery.Core.csproj index 0964d1bfde..ef2441500a 100644 --- a/src/NuGetGallery.Core/NuGetGallery.Core.csproj +++ b/src/NuGetGallery.Core/NuGetGallery.Core.csproj @@ -232,6 +232,7 @@ + @@ -241,6 +242,7 @@ + diff --git a/src/NuGetGallery.Core/Packaging/ManifestValidator.cs b/src/NuGetGallery.Core/Packaging/ManifestValidator.cs index 024bdca0fd..167f03ee17 100644 --- a/src/NuGetGallery.Core/Packaging/ManifestValidator.cs +++ b/src/NuGetGallery.Core/Packaging/ManifestValidator.cs @@ -22,7 +22,7 @@ public static IEnumerable Validate(Stream nuspecStream, out Nu var rawMetadata = nuspecReader.GetMetadata(); if (rawMetadata != null && rawMetadata.Any()) { - return ValidateCore(PackageMetadata.FromNuspecReader(nuspecReader)); + return ValidateCore(PackageMetadata.FromNuspecReader(nuspecReader, strict: true)); } } catch (Exception ex) diff --git a/src/NuGetGallery.Core/Packaging/PackageMetadata.cs b/src/NuGetGallery.Core/Packaging/PackageMetadata.cs index dacf48be29..b39f457400 100644 --- a/src/NuGetGallery.Core/Packaging/PackageMetadata.cs +++ b/src/NuGetGallery.Core/Packaging/PackageMetadata.cs @@ -147,15 +147,18 @@ private Uri GetValue(string key, Uri alternateValue) /// Gets package metadata from a the provided instance. /// /// The instance used to read the + /// + /// Whether or not to be strict when reading the . This should be true + /// on initial ingestion but false when a package that has already been accepted is being processed. /// /// We default to use a strict version-check on dependency groups. /// When an invalid dependency version range is detected, a will be thrown. /// - public static PackageMetadata FromNuspecReader(NuspecReader nuspecReader) + public static PackageMetadata FromNuspecReader(NuspecReader nuspecReader, bool strict) { return new PackageMetadata( nuspecReader.GetMetadata().ToDictionary(kvp => kvp.Key, kvp => kvp.Value), - nuspecReader.GetDependencyGroups(useStrictVersionCheck: true), + nuspecReader.GetDependencyGroups(useStrictVersionCheck: strict), nuspecReader.GetFrameworkReferenceGroups(), nuspecReader.GetPackageTypes(), nuspecReader.GetMinClientVersion() diff --git a/src/NuGetGallery.Core/Services/AccessConditionWrapper.cs b/src/NuGetGallery.Core/Services/AccessConditionWrapper.cs new file mode 100644 index 0000000000..8f7d08fc59 --- /dev/null +++ b/src/NuGetGallery.Core/Services/AccessConditionWrapper.cs @@ -0,0 +1,34 @@ +// 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 Microsoft.WindowsAzure.Storage; + +namespace NuGetGallery +{ + public class AccessConditionWrapper : IAccessCondition + { + private AccessConditionWrapper(string ifNoneMatchETag, string ifMatchETag) + { + IfNoneMatchETag = ifNoneMatchETag; + IfMatchETag = IfMatchETag; + } + + public string IfNoneMatchETag { get; } + + public string IfMatchETag { get; } + + public static IAccessCondition GenerateIfMatchCondition(string etag) + { + return new AccessConditionWrapper( + ifNoneMatchETag: null, + ifMatchETag: AccessCondition.GenerateIfMatchCondition(etag).IfMatchETag); + } + + public static IAccessCondition GenerateIfNotExistsCondition() + { + return new AccessConditionWrapper( + ifNoneMatchETag: AccessCondition.GenerateIfNotExistsCondition().IfNoneMatchETag, + ifMatchETag: null); + } + } +} diff --git a/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs b/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs index 8755721894..13422c1d94 100644 --- a/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs +++ b/src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Net; @@ -16,6 +17,15 @@ namespace NuGetGallery { public class CloudBlobCoreFileStorageService : ICoreFileStorageService { + /// + /// This is the maximum duration for to poll, + /// waiting for a package copy to complete. The value picked today is based off of the maximum duration we wait + /// when uploading files to Azure China blob storage. Note that in cases when the copy source and destination + /// are in the same container, the copy completed immediately and no polling is necessary. + /// + private static readonly TimeSpan MaxCopyDuration = TimeSpan.FromMinutes(10); + private static readonly TimeSpan CopyPollFrequency = TimeSpan.FromMilliseconds(500); + private static readonly HashSet KnownPublicFolders = new HashSet { CoreConstants.PackagesFolderName, CoreConstants.PackageBackupsFolderName, @@ -100,6 +110,110 @@ public async Task GetFileReferenceAsync(string folderName, strin } } + public async Task CopyFileAsync( + string srcFolderName, + string srcFileName, + string destFolderName, + string destFileName, + IAccessCondition destAccessCondition) + { + if (srcFolderName == null) + { + throw new ArgumentNullException(nameof(srcFolderName)); + } + + if (srcFileName == null) + { + throw new ArgumentNullException(nameof(srcFileName)); + } + + if (destFolderName == null) + { + throw new ArgumentNullException(nameof(destFolderName)); + } + + if (destFileName == null) + { + throw new ArgumentNullException(nameof(destFileName)); + } + + var srcContainer = await GetContainerAsync(srcFolderName); + var srcBlob = srcContainer.GetBlobReference(srcFileName); + + var destContainer = await GetContainerAsync(destFolderName); + var destBlob = destContainer.GetBlobReference(destFileName); + destAccessCondition = destAccessCondition ?? AccessConditionWrapper.GenerateIfNotExistsCondition(); + var mappedDestAccessCondition = new AccessCondition + { + IfNoneMatchETag = destAccessCondition.IfNoneMatchETag, + IfMatchETag = destAccessCondition.IfMatchETag, + }; + + // Determine the source blob etag. + await srcBlob.FetchAttributesAsync(); + var srcAccessCondition = AccessCondition.GenerateIfMatchCondition(srcBlob.ETag); + + // Check if the destination blob already exists and fetch attributes. + if (await destBlob.ExistsAsync()) + { + if (destBlob.CopyState?.Status == CopyStatus.Failed) + { + // If the last copy failed, allow this copy to occur no matter what the caller's destination + // condition is. This is because the source blob is preferable over a failed copy. We use the etag + // of the failed blob to avoid inadvertently replacing a blob that is now valid (i.e. has a + // successful copy status). + mappedDestAccessCondition = AccessCondition.GenerateIfMatchCondition(destBlob.ETag); + } + else if ((srcBlob.Properties.ContentMD5 != null + && srcBlob.Properties.ContentMD5 == destBlob.Properties.ContentMD5 + && srcBlob.Properties.Length == destBlob.Properties.Length)) + { + // If the blob hash is the same and the length is the same, no-op the copy. + return srcBlob.ETag; + } + } + + // Start the server-side copy and wait for it to complete. If "If-None-Match: *" was specified and the + // destination already exists, HTTP 409 is thrown. If "If-Match: ETAG" was specified and the destination + // has changed, HTTP 412 is thrown. + try + { + await destBlob.StartCopyAsync( + srcBlob, + srcAccessCondition, + mappedDestAccessCondition); + } + catch (StorageException ex) when (ex.RequestInformation?.HttpStatusCode == (int?)HttpStatusCode.Conflict) + { + throw new InvalidOperationException( + String.Format( + CultureInfo.CurrentCulture, + "There is already a blob with name {0} in container {1}.", + destFileName, + destFolderName), + ex); + } + + var stopwatch = Stopwatch.StartNew(); + while (destBlob.CopyState.Status == CopyStatus.Pending + && stopwatch.Elapsed < MaxCopyDuration) + { + await destBlob.FetchAttributesAsync(); + await Task.Delay(CopyPollFrequency); + } + + if (destBlob.CopyState.Status == CopyStatus.Pending) + { + throw new TimeoutException($"Waiting for the blob copy operation to complete timed out after {MaxCopyDuration.TotalSeconds} seconds."); + } + else if (destBlob.CopyState.Status != CopyStatus.Success) + { + throw new StorageException($"The blob copy operation had copy status {destBlob.CopyState.Status} ({destBlob.CopyState.StatusDescription})."); + } + + return srcBlob.ETag; + } + public async Task SaveFileAsync(string folderName, string fileName, Stream packageFile, bool overwrite = true) { ICloudBlobContainer container = await GetContainerAsync(folderName); diff --git a/src/NuGetGallery.Core/Services/CloudBlobWrapper.cs b/src/NuGetGallery.Core/Services/CloudBlobWrapper.cs index 2bdf15f143..270e9de7b3 100644 --- a/src/NuGetGallery.Core/Services/CloudBlobWrapper.cs +++ b/src/NuGetGallery.Core/Services/CloudBlobWrapper.cs @@ -1,5 +1,6 @@ // 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.IO; using System.Net; @@ -12,9 +13,9 @@ namespace NuGetGallery { public class CloudBlobWrapper : ISimpleCloudBlob { - private readonly ICloudBlob _blob; + private readonly CloudBlockBlob _blob; - public CloudBlobWrapper(ICloudBlob blob) + public CloudBlobWrapper(CloudBlockBlob blob) { _blob = blob; } @@ -24,6 +25,11 @@ public BlobProperties Properties get { return _blob.Properties; } } + public CopyState CopyState + { + get { return _blob.CopyState; } + } + public Uri Uri { get { return _blob.Uri; } @@ -137,6 +143,24 @@ public string GetSharedReadSignature(DateTimeOffset? endOfAccess) return signature; } + public async Task StartCopyAsync(ISimpleCloudBlob source, AccessCondition sourceAccessCondition, AccessCondition destAccessCondition) + { + // To avoid this we would need to somehow abstract away the primary and secondary storage locations. This + // is not worth the effort right now! + var sourceWrapper = source as CloudBlobWrapper; + if (sourceWrapper == null) + { + throw new ArgumentException($"The source blob must be a {nameof(CloudBlobWrapper)}."); + } + + await _blob.StartCopyAsync( + sourceWrapper._blob, + sourceAccessCondition: sourceAccessCondition, + destAccessCondition: destAccessCondition, + options: null, + operationContext: null); + } + // The default retry policy treats a 304 as an error that requires a retry. We don't want that! private class DontRetryOnNotModifiedPolicy : IRetryPolicy { diff --git a/src/NuGetGallery.Core/Services/CorePackageFileService.cs b/src/NuGetGallery.Core/Services/CorePackageFileService.cs index c84db2ca4a..2bfe534df0 100644 --- a/src/NuGetGallery.Core/Services/CorePackageFileService.cs +++ b/src/NuGetGallery.Core/Services/CorePackageFileService.cs @@ -5,6 +5,8 @@ using System.Globalization; using System.IO; using System.Threading.Tasks; +using System.Web; +using NuGet.Versioning; namespace NuGetGallery { @@ -128,6 +130,86 @@ public Task DoesValidationPackageFileExistAsync(Package package) return _fileStorageService.FileExistsAsync(CoreConstants.ValidationFolderName, fileName); } + public async Task StorePackageFileInBackupLocationAsync(Package package, Stream packageFile) + { + if (package == null) + { + throw new ArgumentNullException(nameof(package)); + } + + if (packageFile == null) + { + throw new ArgumentNullException(nameof(packageFile)); + } + + if (package.PackageRegistration == null || + string.IsNullOrWhiteSpace(package.PackageRegistration.Id) || + (string.IsNullOrWhiteSpace(package.NormalizedVersion) && string.IsNullOrWhiteSpace(package.Version))) + { + throw new ArgumentException(CoreStrings.PackageIsMissingRequiredData, nameof(package)); + } + + string version; + if (string.IsNullOrEmpty(package.NormalizedVersion)) + { + version = NuGetVersion.Parse(package.Version).ToNormalizedString(); + } + else + { + version = package.NormalizedVersion; + } + + var fileName = BuildBackupFileName( + package.PackageRegistration.Id, + version, + package.Hash); + + // If the package already exists, don't even bother uploading it. The file name is based off of the hash so + // we know the upload isn't necessary. + if (await _fileStorageService.FileExistsAsync(CoreConstants.PackageBackupsFolderName, fileName)) + { + return; + } + + try + { + await _fileStorageService.SaveFileAsync(CoreConstants.PackageBackupsFolderName, fileName, packageFile); + } + catch (InvalidOperationException) + { + // If the package file already exists, swallow the exception since we know the content is the same. + return; + } + } + + private static string BuildBackupFileName(string id, string version, string hash) + { + if (id == null) + { + throw new ArgumentNullException(nameof(id)); + } + + if (version == null) + { + throw new ArgumentNullException(nameof(version)); + } + + if (hash == null) + { + throw new ArgumentNullException(nameof(hash)); + } + + var hashBytes = Convert.FromBase64String(hash); + + return string.Format( + CultureInfo.InvariantCulture, + CoreConstants.PackageFileBackupSavePathTemplate, + id.ToLowerInvariant(), + version.ToLowerInvariant(), + HttpServerUtility.UrlTokenEncode(hashBytes), + CoreConstants.NuGetPackageFileExtension); + } + protected static string BuildFileName(Package package, string format, string extension) { if (package == null) diff --git a/src/NuGetGallery.Core/Services/CorePackageService.cs b/src/NuGetGallery.Core/Services/CorePackageService.cs index e214acf5fe..fcc547f183 100644 --- a/src/NuGetGallery.Core/Services/CorePackageService.cs +++ b/src/NuGetGallery.Core/Services/CorePackageService.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using NuGet.Versioning; +using NuGetGallery.Packaging; namespace NuGetGallery { @@ -18,6 +19,41 @@ public CorePackageService(IEntityRepository packageRepository) _packageRepository = packageRepository ?? throw new ArgumentNullException(nameof(packageRepository)); } + public virtual async Task UpdatePackageStreamMetadataAsync( + Package package, + PackageStreamMetadata metadata, + bool commitChanges = true) + { + if (package == null) + { + throw new ArgumentNullException(nameof(package)); + } + + if (metadata == null) + { + throw new ArgumentNullException(nameof(metadata)); + } + + package.Hash = metadata.Hash; + package.HashAlgorithm = metadata.HashAlgorithm; + package.PackageFileSize = metadata.Size; + + var now = DateTime.UtcNow; + package.LastUpdated = now; + + /// If the package is available, consider this change as an "edit" so that the package appears for cursors + /// on the field. + if (package.PackageStatusKey == PackageStatus.Available) + { + package.LastEdited = now; + } + + if (commitChanges) + { + await _packageRepository.CommitChangesAsync(); + } + } + public virtual async Task UpdatePackageStatusAsync( Package package, PackageStatus newPackageStatus, diff --git a/src/NuGetGallery.Core/Services/IAccessCondition.cs b/src/NuGetGallery.Core/Services/IAccessCondition.cs new file mode 100644 index 0000000000..545751a41b --- /dev/null +++ b/src/NuGetGallery.Core/Services/IAccessCondition.cs @@ -0,0 +1,14 @@ +// 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. + +namespace NuGetGallery +{ + /// + /// A wrapper type around . + /// + public interface IAccessCondition + { + string IfNoneMatchETag { get; } + string IfMatchETag { get; } + } +} diff --git a/src/NuGetGallery.Core/Services/ICoreFileStorageService.cs b/src/NuGetGallery.Core/Services/ICoreFileStorageService.cs index 392704a299..cc47d53066 100644 --- a/src/NuGetGallery.Core/Services/ICoreFileStorageService.cs +++ b/src/NuGetGallery.Core/Services/ICoreFileStorageService.cs @@ -35,5 +35,29 @@ public interface ICoreFileStorageService Task GetFileReadUriAsync(string folderName, string fileName, DateTimeOffset? endOfAccess); Task SaveFileAsync(string folderName, string fileName, Stream packageFile, bool overwrite = true); + + /// + /// Copies the source file to the destination file. If the destination already exists and the content + /// is different, an exception should be thrown. If the file already exists, the implementation can choose to + /// no-op if the content is the same instead of throwing an exception. This method should throw if the source + /// file does not exist. + /// + /// The source folder. + /// The source file name or relative file path. + /// The destination folder. + /// The destination file name or relative file path. + /// + /// The access condition used to determine whether the destination is in the expected state. + /// + /// + /// The etag of the source file. This can be used if the destination file is later intended to replace + /// the source file in conjunction with . + /// + Task CopyFileAsync( + string srcFolderName, + string srcFileName, + string destFolderName, + string destFileName, + IAccessCondition destAccessCondition); } } diff --git a/src/NuGetGallery.Core/Services/ICorePackageFileService.cs b/src/NuGetGallery.Core/Services/ICorePackageFileService.cs index f7e8fd17d0..f0efb7910f 100644 --- a/src/NuGetGallery.Core/Services/ICorePackageFileService.cs +++ b/src/NuGetGallery.Core/Services/ICorePackageFileService.cs @@ -81,5 +81,10 @@ public interface ICorePackageFileService /// The package ID. This value is case-insensitive. /// The package version. This value is case-insensitive and need not be normalized. Task DeletePackageFileAsync(string id, string version); + + /// + /// Copies the contents of the package represented by the stream into the file storage backup location. + /// + Task StorePackageFileInBackupLocationAsync(Package package, Stream packageFile); } } \ No newline at end of file diff --git a/src/NuGetGallery.Core/Services/ICorePackageService.cs b/src/NuGetGallery.Core/Services/ICorePackageService.cs index 993d7584c3..66799cfd39 100644 --- a/src/NuGetGallery.Core/Services/ICorePackageService.cs +++ b/src/NuGetGallery.Core/Services/ICorePackageService.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Threading.Tasks; +using NuGetGallery.Packaging; namespace NuGetGallery { @@ -10,6 +11,14 @@ namespace NuGetGallery /// public interface ICorePackageService { + /// + /// Updates the package properties related to the package stream itself. + /// + /// The package to update the stream details of. + /// The new package stream metadata. + /// Whether or not to commit the changes to the entity context. + Task UpdatePackageStreamMetadataAsync(Package package, PackageStreamMetadata metadata, bool commitChanges = true); + /// /// Set the status on the package and any other related package properties. /// diff --git a/src/NuGetGallery.Core/Services/ISimpleCloudBlob.cs b/src/NuGetGallery.Core/Services/ISimpleCloudBlob.cs index 559827123d..9b366f0cee 100644 --- a/src/NuGetGallery.Core/Services/ISimpleCloudBlob.cs +++ b/src/NuGetGallery.Core/Services/ISimpleCloudBlob.cs @@ -12,6 +12,7 @@ namespace NuGetGallery public interface ISimpleCloudBlob { BlobProperties Properties { get; } + CopyState CopyState { get; } Uri Uri { get; } string Name { get; } DateTime LastModifiedUtc { get; } @@ -27,6 +28,8 @@ public interface ISimpleCloudBlob Task FetchAttributesAsync(); + Task StartCopyAsync(ISimpleCloudBlob source, AccessCondition sourceAccessCondition, AccessCondition destAccessCondition); + /// /// Generates the shared read signature that if appended to the blob URI /// would allow reading the contents of the blob using the produced URI diff --git a/src/NuGetGallery/App_Code/ViewHelpers.cshtml b/src/NuGetGallery/App_Code/ViewHelpers.cshtml index 7831577342..c5509e516b 100644 --- a/src/NuGetGallery/App_Code/ViewHelpers.cshtml +++ b/src/NuGetGallery/App_Code/ViewHelpers.cshtml @@ -105,7 +105,7 @@ { @AlertWarning( @ - NuGet.org password login is being deprecated. Please use a Microsoft account to sign into NuGet gallery. + NuGet.org password login is deprecated. Please use a Microsoft account to sign into NuGet gallery. ) } diff --git a/src/NuGetGallery/App_Data/Files/Content/Login-Discontinuation-Configuration.json b/src/NuGetGallery/App_Data/Files/Content/Login-Discontinuation-Configuration.json index 770147da56..bc35f1c4cc 100644 --- a/src/NuGetGallery/App_Data/Files/Content/Login-Discontinuation-Configuration.json +++ b/src/NuGetGallery/App_Data/Files/Content/Login-Discontinuation-Configuration.json @@ -7,5 +7,8 @@ ], "ExceptionsForEmailAddresses": [ "exception@cannotUsePassword.com" + ], + "ForceTransformationToOrganizationForEmailAddresses": [ + "organization@cannotUsePassword.com" ] } \ No newline at end of file diff --git a/src/NuGetGallery/App_Start/AppActivator.cs b/src/NuGetGallery/App_Start/AppActivator.cs index ac89a4c22b..ee94934aea 100644 --- a/src/NuGetGallery/App_Start/AppActivator.cs +++ b/src/NuGetGallery/App_Start/AppActivator.cs @@ -207,6 +207,10 @@ private static void BundlingPostStart() .Include("~/Scripts/gallery/page-home.js"); BundleTable.Bundles.Add(homeScriptBundle); + var signinScriptBundle = new ScriptBundle("~/Scripts/gallery/page-signin.min.js") + .Include("~/Scripts/gallery/page-signin.js"); + BundleTable.Bundles.Add(signinScriptBundle); + var displayPackageScriptBundle = new ScriptBundle("~/Scripts/gallery/page-display-package.min.js") .Include("~/Scripts/gallery/page-display-package.js") .Include("~/Scripts/gallery/clamp.js"); @@ -235,6 +239,11 @@ private static void BundlingPostStart() var manageOrganizationScriptBundle = new ScriptBundle("~/Scripts/gallery/page-manage-organization.min.js") .Include("~/Scripts/gallery/page-manage-organization.js"); BundleTable.Bundles.Add(manageOrganizationScriptBundle); + + var addOrganizationScriptBundle = new ScriptBundle("~/Scripts/gallery/page-add-organization.min.js") + .Include("~/Scripts/gallery/page-add-organization.js") + .Include("~/Scripts/gallery/md5.js"); + BundleTable.Bundles.Add(addOrganizationScriptBundle); } private static void AppPostStart(IAppConfiguration configuration) diff --git a/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs b/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs index d8870ddfc0..60962cfd63 100644 --- a/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs +++ b/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs @@ -154,6 +154,11 @@ protected override void Load(ContainerBuilder builder) .As>() .InstancePerLifetimeScope(); + builder.RegisterType>() + .AsSelf() + .As>() + .InstancePerLifetimeScope(); + builder.RegisterType() .AsSelf() .As() @@ -246,12 +251,6 @@ protected override void Load(ContainerBuilder builder) .AsSelf() .As() .InstancePerLifetimeScope(); - - builder.RegisterType() - .SingleInstance(); - - builder.RegisterType() - .SingleInstance(); builder.RegisterType() .AsSelf() diff --git a/src/NuGetGallery/App_Start/Routes.cs b/src/NuGetGallery/App_Start/Routes.cs index a12cbab7d5..78e586da22 100644 --- a/src/NuGetGallery/App_Start/Routes.cs +++ b/src/NuGetGallery/App_Start/Routes.cs @@ -225,6 +225,12 @@ public static void RegisterUIRoutes(RouteCollection routes) new { controller = "Authentication", action = "Register" }, new { httpMethod = new HttpMethodConstraint("POST") }); + routes.MapRoute( + RouteName.SigninAssistance, + "account/assistance", + new { controller = "Authentication", action = "SignInAssistance" }, + new { httpMethod = new HttpMethodConstraint("POST") }); + routes.MapRoute( RouteName.LegacyRegister, "account/register", @@ -297,6 +303,11 @@ public static void RegisterUIRoutes(RouteCollection routes) "account/{action}", new { controller = "Users", action = "Account" }); + routes.MapRoute( + RouteName.AddOrganization, + "organization/add", + new { controller = "Organizations", action = "Add" }); + routes.MapRoute( RouteName.OrganizationAccount, "organization/{accountName}/{action}", diff --git a/src/NuGetGallery/Configuration/AppConfiguration.cs b/src/NuGetGallery/Configuration/AppConfiguration.cs index c90a8e60df..07c0392ddf 100644 --- a/src/NuGetGallery/Configuration/AppConfiguration.cs +++ b/src/NuGetGallery/Configuration/AppConfiguration.cs @@ -66,6 +66,10 @@ public class AppConfiguration : IAppConfiguration public bool BlockingAsynchronousPackageValidationEnabled { get; set; } + public TimeSpan AsynchronousPackageValidationDelay { get; set; } + + public bool DeprecateNuGetPasswordLogins { get; set; } + /// /// Gets the URI to the search service /// diff --git a/src/NuGetGallery/Configuration/IAppConfiguration.cs b/src/NuGetGallery/Configuration/IAppConfiguration.cs index ce9f25c636..075b400e63 100644 --- a/src/NuGetGallery/Configuration/IAppConfiguration.cs +++ b/src/NuGetGallery/Configuration/IAppConfiguration.cs @@ -88,6 +88,18 @@ public interface IAppConfiguration : ICoreMessageServiceConfiguration /// bool BlockingAsynchronousPackageValidationEnabled { get; set; } + /// + /// If is set to true, + /// this is the delay that downstream validations should wait before starting + /// to process a package. + /// + TimeSpan AsynchronousPackageValidationDelay { get; set; } + + /// + /// Gets a boolean indicating whether NuGet password logins are deprecated. + /// + bool DeprecateNuGetPasswordLogins { get; set; } + /// /// Gets the URI to the search service /// diff --git a/src/NuGetGallery/Constants.cs b/src/NuGetGallery/Constants.cs index f0d74a8b24..a6dacd4517 100644 --- a/src/NuGetGallery/Constants.cs +++ b/src/NuGetGallery/Constants.cs @@ -38,8 +38,6 @@ public static class Constants public const string ReadMeFileSavePathTemplateActive = "active/{0}/{1}{2}"; public const string ReadMeFileSavePathTemplatePending = "pending/{0}/{1}{2}"; - public const string PackageFileBackupSavePathTemplate = "{0}/{1}/{2}.{3}"; - public const string MarkdownFileExtension = ".md"; public const string HtmlFileExtension = ".html"; public const string JsonFileExtension = ".json"; @@ -61,6 +59,17 @@ public static class Constants public const string UrlValidationRegEx = @"(https?):\/\/[^ ""]+$"; public const string UrlValidationErrorMessage = "This doesn't appear to be a valid HTTP/HTTPS URL"; + // Note: regexes must be tested to work in JavaScript + // We do NOT follow strictly the RFCs at this time, and we choose not to support many obscure email address variants. + // Specifically the following are not supported by-design: + // * Addresses containing () or [] + // * Second parts with no dots (i.e. foo@localhost or foo@com) + // * Addresses with quoted (" or ') first parts + // * Addresses with IP Address second parts (foo@[127.0.0.1]) + internal const string EmailValidationRegexFirstPart = @"[-A-Za-z0-9!#$%&'*+\/=?^_`{|}~\.]+"; + internal const string EmailValidationRegexSecondPart = @"[A-Za-z0-9]+[\w\.-]*[A-Za-z0-9]*\.[A-Za-z0-9][A-Za-z\.]*[A-Za-z]"; + public const string EmailValidationRegex = "^" + EmailValidationRegexFirstPart + "@" + EmailValidationRegexSecondPart + "$"; + internal const string ApiKeyHeaderName = "X-NuGet-ApiKey"; // X-NuGet-Client-Version header was deprecated and replaced with X-NuGet-Protocol-Version header // It stays here for backwards compatibility diff --git a/src/NuGetGallery/Content/gallery/img/manage-organizations-260x150.png b/src/NuGetGallery/Content/gallery/img/manage-organizations-260x150.png new file mode 100644 index 0000000000..fa489d8a9f Binary files /dev/null and b/src/NuGetGallery/Content/gallery/img/manage-organizations-260x150.png differ diff --git a/src/NuGetGallery/Content/gallery/img/manage-organizations.svg b/src/NuGetGallery/Content/gallery/img/manage-organizations.svg new file mode 100644 index 0000000000..806897b943 --- /dev/null +++ b/src/NuGetGallery/Content/gallery/img/manage-organizations.svg @@ -0,0 +1,338 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NuGetGallery/Controllers/AccountsController.cs b/src/NuGetGallery/Controllers/AccountsController.cs index 5f581ace99..031042743f 100644 --- a/src/NuGetGallery/Controllers/AccountsController.cs +++ b/src/NuGetGallery/Controllers/AccountsController.cs @@ -23,10 +23,6 @@ public class ViewMessages public string EmailPreferencesUpdated { get; set; } public string EmailUpdateCancelled { get; set; } - - public string EmailUpdated { get; set; } - - public string EmailUpdatedWithConfirmationRequired { get; set; } } public AuthenticationService AuthenticationService { get; } @@ -237,12 +233,6 @@ public virtual async Task ChangeEmail(TAccountViewModel model) if (account.Confirmed) { SendEmailChangedConfirmationNotice(account); - - TempData["Message"] = Messages.EmailUpdatedWithConfirmationRequired; - } - else - { - TempData["Message"] = Messages.EmailUpdated; } return RedirectToAction(AccountAction); diff --git a/src/NuGetGallery/Controllers/AuthenticationController.cs b/src/NuGetGallery/Controllers/AuthenticationController.cs index b5a4fdd151..1d82a7949a 100644 --- a/src/NuGetGallery/Controllers/AuthenticationController.cs +++ b/src/NuGetGallery/Controllers/AuthenticationController.cs @@ -2,14 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Web; +using System.Collections.Generic; +using System.Globalization; using System.Linq; -using System.Web.Mvc; using System.Net.Mail; -using System.Globalization; -using System.Threading.Tasks; using System.Security.Claims; -using System.Collections.Generic; +using System.Threading.Tasks; +using System.Web; +using System.Web.Mvc; using NuGetGallery.Authentication; using NuGetGallery.Authentication.Providers; using NuGetGallery.Authentication.Providers.AzureActiveDirectoryV2; @@ -29,6 +29,8 @@ public partial class AuthenticationController private readonly ICredentialBuilder _credentialBuilder; + private const string EMAIL_FORMAT_PADDING = "**********"; + // Prioritize the external authentication mechanism. private readonly static string[] ExternalAuthenticationPriority = new string[] { Authenticator.GetName(typeof(AzureActiveDirectoryV2Authenticator)), @@ -328,6 +330,46 @@ public virtual ActionResult LogOff(string returnUrl) return SafeRedirect(returnUrl); } + [HttpPost] + [ValidateAntiForgeryToken] + public virtual JsonResult SignInAssistance(string username, string providedEmailAddress) + { + // If provided email address is empty or null, return the result with a formatted + // email address, otherwise send sign-in assistance email to the associated mail address. + try + { + var user = _userService.FindByUsername(username); + if (user == null) + { + throw new ArgumentException(Strings.UserNotFound); + } + + var email = user.EmailAddress; + if (string.IsNullOrWhiteSpace(providedEmailAddress)) + { + var formattedEmail = FormatEmailAddressForAssistance(email); + return Json(new { success = true, EmailAddress = formattedEmail }); + } + else + { + if (!email.Equals(providedEmailAddress, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException(Strings.SigninAssistance_EmailMismatched); + } + else + { + var externalCredentials = user.Credentials.Where(cred => cred.IsExternal()); + _messageService.SendSigninAssistanceEmail(new MailAddress(email, user.Username), externalCredentials); + return Json(new { success = true }); + } + } + } + catch (ArgumentException ex) + { + return Json(new { success = false, message = ex.Message }); + } + } + [ActionName("Authenticate")] [HttpGet] public virtual ActionResult AuthenticateGet(string returnUrl, string provider) @@ -521,6 +563,24 @@ public virtual async Task LinkExternalAccount(string returnUrl) } } + private string FormatEmailAddressForAssistance(string email) + { + var emailParts = email.Split('@'); + if (emailParts.Length != 2) + { + throw new ArgumentException(@"Invalid email address. Please contact support for assistance."); + } + + // Format the email address as x**********y@domain.com + // One character wide email id eg: x@domain.com; format it as x**********@domain.com + var idPart = emailParts[0]; + var domainPart = emailParts[1]; + return idPart[0] + + EMAIL_FORMAT_PADDING + + (idPart.Length > 1 ? idPart[idPart.Length - 1] + "" : "") + + $"@{domainPart}"; + } + private ActionResult RedirectFromRegister(string returnUrl) { if (returnUrl != Url.Home()) diff --git a/src/NuGetGallery/Controllers/JsonApiController.cs b/src/NuGetGallery/Controllers/JsonApiController.cs index 7e7c2b4435..9266a811e7 100644 --- a/src/NuGetGallery/Controllers/JsonApiController.cs +++ b/src/NuGetGallery/Controllers/JsonApiController.cs @@ -114,7 +114,7 @@ public virtual ActionResult GetAddPackageOwnerConfirmation(string id, string use { success = true, confirmation = string.Format(CultureInfo.CurrentCulture, Strings.AddOwnerConfirmation, username), - policyMessage = GetNoticeOfPoliciesRequiredConfirmation(model.Package, model.User, model.CurrentUser) + policyMessage = string.Empty }, JsonRequestBehavior.AllowGet); } @@ -149,10 +149,9 @@ public async Task AddPackageOwner(string id, string username, string relativeUrl: false); var packageUrl = Url.Package(model.Package.Id, version: null, relativeUrl: false); - var policyMessage = GetNoticeOfPoliciesRequiredMessage(model.Package, model.User, model.CurrentUser); _messageService.SendPackageOwnerRequest(model.CurrentUser, model.User, model.Package, packageUrl, - confirmationUrl, rejectionUrl, encodedMessage, policyMessage); + confirmationUrl, rejectionUrl, encodedMessage, policyMessage: string.Empty); return Json(new { @@ -204,97 +203,6 @@ public async Task RemovePackageOwner(string id, string username) } } - /// - /// UI confirmation message for adding owner from ManageOwners.cshtml - /// - private string GetNoticeOfPoliciesRequiredConfirmation(PackageRegistration package, User user, User currentUser) - { - if (IsFirstPropagatingOwner(package, user)) - { - return string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerConfirmation_SecurePushRequiredByNewOwner, - user.Username, GetSecurePushPolicyDescriptions(), _appConfiguration.GalleryOwner.Address); - } - else if (!_policyService.IsSubscribed(user, SecurePushSubscription.Name)) - { - IEnumerable propagating = null; - if ((propagating = GetPropagatingOwners(package)).Any()) - { - var propagators = string.Join(", ", propagating); - return string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerConfirmation_SecurePushRequiredByOwner, - propagators, user.Username, GetSecurePushPolicyDescriptions(), _appConfiguration.GalleryOwner.Address); - } - else if ((propagating = GetPendingPropagatingOwners(package)).Any()) - { - var propagators = string.Join(", ", propagating); - return string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerConfirmation_SecurePushRequiredByPendingOwner, - propagators, user.Username, GetSecurePushPolicyDescriptions(), _appConfiguration.GalleryOwner.Address); - } - } - return string.Empty; - } - - /// - /// Policy message for the package owner request notification. - /// - private string GetNoticeOfPoliciesRequiredMessage(PackageRegistration package, User user, User currentUser) - { - IEnumerable propagating = null; - - if (IsFirstPropagatingOwner(package, user)) - { - return string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerRequest_SecurePushRequiredByNewOwner, - _appConfiguration.GalleryOwner.Address, GetSecurePushPolicyDescriptions()); - } - else if (!_policyService.IsSubscribed(user, SecurePushSubscription.Name)) - { - if ((propagating = GetPropagatingOwners(package)).Any()) - { - var propagators = string.Join(", ", propagating); - return string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerRequest_SecurePushRequiredByOwner, - propagators, _appConfiguration.GalleryOwner.Address, GetSecurePushPolicyDescriptions()); - } - else if ((propagating = GetPendingPropagatingOwners(package)).Any()) - { - var propagators = string.Join(", ", propagating); - return string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerRequest_SecurePushRequiredByPendingOwner, - propagators, _appConfiguration.GalleryOwner.Address, GetSecurePushPolicyDescriptions()); - } - } - - return string.Empty; - } - - private bool IsFirstPropagatingOwner(PackageRegistration package, User user) - { - return RequireSecurePushForCoOwnersPolicy.IsSubscribed(user) && - !package.Owners.Any(RequireSecurePushForCoOwnersPolicy.IsSubscribed); - } - - private IEnumerable GetPropagatingOwners(PackageRegistration package) - { - return package.Owners.Where(RequireSecurePushForCoOwnersPolicy.IsSubscribed).Select(o => o.Username); - } - - private IEnumerable GetPendingPropagatingOwners(PackageRegistration package) - { - return _packageOwnershipManagementService.GetPackageOwnershipRequests(package: package) - .Select(po => po.NewOwner) - .Where(RequireSecurePushForCoOwnersPolicy.IsSubscribed) - .Select(po => po.Username); - } - - private string GetSecurePushPolicyDescriptions() - { - return string.Format(CultureInfo.CurrentCulture, Strings.SecurePushPolicyDescriptionsHtml, - SecurePushSubscription.MinProtocolVersion, SecurePushSubscription.PushKeysExpirationInDays); - } - private bool TryGetManagePackageOwnerModel(string id, string username, bool isAddOwner, out ManagePackageOwnerModel model) { if (string.IsNullOrEmpty(id)) diff --git a/src/NuGetGallery/Controllers/ODataV2CuratedFeedController.cs b/src/NuGetGallery/Controllers/ODataV2CuratedFeedController.cs index f8a9c900b8..9fba91f077 100644 --- a/src/NuGetGallery/Controllers/ODataV2CuratedFeedController.cs +++ b/src/NuGetGallery/Controllers/ODataV2CuratedFeedController.cs @@ -183,7 +183,7 @@ private async Task GetCore( .ToV2FeedPackageQuery(GetSiteRoot(), _configurationService.Features.FriendlyLicenses, semVerLevelKey); return QueryResult(options, pagedQueryable, MaxPageSize, totalHits, (o, s, resultCount) => - SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, new { id }, o, s)); + SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, new { id }, o, s, semVerLevelKey)); } } catch (Exception ex) @@ -299,7 +299,8 @@ public async Task Search( resultCount, new { searchTerm, targetFramework, includePrerelease }, o, - s); + s, + semVerLevelKey); } return null; }); diff --git a/src/NuGetGallery/Controllers/ODataV2FeedController.cs b/src/NuGetGallery/Controllers/ODataV2FeedController.cs index c3ab850c93..430901cb25 100644 --- a/src/NuGetGallery/Controllers/ODataV2FeedController.cs +++ b/src/NuGetGallery/Controllers/ODataV2FeedController.cs @@ -92,7 +92,7 @@ public async Task Get( semVerLevelKey); return QueryResult(options, pagedQueryable, MaxPageSize, totalHits, (o, s, resultCount) => - SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, null, o, s)); + SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, null, o, s, semVerLevelKey)); } } } @@ -251,7 +251,7 @@ private async Task GetCore( semVerLevelKey); return QueryResult(options, pagedQueryable, MaxPageSize, totalHits, (o, s, resultCount) => - SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, new { id }, o, s)); + SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, new { id }, o, s, semVerLevelKey)); } } catch (Exception ex) @@ -358,7 +358,7 @@ public async Task Search( // Strip it of for backward compatibility. if (o.Top == null || (resultCount.HasValue && o.Top.Value >= resultCount.Value)) { - return SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, new { searchTerm, targetFramework, includePrerelease }, o, s); + return SearchAdaptor.GetNextLink(Request.RequestUri, resultCount, new { searchTerm, targetFramework, includePrerelease }, o, s, semVerLevelKey); } return null; }); diff --git a/src/NuGetGallery/Controllers/OrganizationsController.cs b/src/NuGetGallery/Controllers/OrganizationsController.cs index b0a7a418d3..2ae76cab8a 100644 --- a/src/NuGetGallery/Controllers/OrganizationsController.cs +++ b/src/NuGetGallery/Controllers/OrganizationsController.cs @@ -1,6 +1,7 @@ // 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.Linq; using System.Net; using System.Net.Mail; @@ -29,9 +30,7 @@ public OrganizationsController( { EmailConfirmed = Strings.OrganizationEmailConfirmed, EmailPreferencesUpdated = Strings.OrganizationEmailPreferencesUpdated, - EmailUpdateCancelled = Strings.OrganizationEmailUpdateCancelled, - EmailUpdated = Strings.OrganizationEmailUpdated, - EmailUpdatedWithConfirmationRequired = Strings.OrganizationEmailUpdatedWithConfirmationRequired + EmailUpdateCancelled = Strings.OrganizationEmailUpdateCancelled }; protected override void SendNewAccountEmail(User account) @@ -47,6 +46,39 @@ protected override void SendEmailChangedConfirmationNotice(User account) MessageService.SendEmailChangeConfirmationNotice(new MailAddress(account.UnconfirmedEmailAddress, account.Username), confirmationUrl); } + [HttpGet] + [UIAuthorize] + public ActionResult Add() + { + return View(new AddOrganizationViewModel()); + } + + [HttpPost] + [UIAuthorize] + [ValidateAntiForgeryToken] + public async Task Add(AddOrganizationViewModel model) + { + var organizationName = model.OrganizationName; + var organizationEmailAddress = model.OrganizationEmailAddress; + var adminUser = GetCurrentUser(); + + string errorMessage; + + try + { + var organization = await UserService.AddOrganizationAsync(organizationName, organizationEmailAddress, adminUser); + SendNewAccountEmail(organization); + return RedirectToAction(nameof(ManageOrganization), new { accountName = organization.Username }); + } + catch (EntityException e) + { + errorMessage = e.Message; + } + + TempData["ErrorMessage"] = errorMessage; + return View(model); + } + [HttpGet] [UIAuthorize] public virtual ActionResult ManageOrganization(string accountName) diff --git a/src/NuGetGallery/Controllers/PackagesController.cs b/src/NuGetGallery/Controllers/PackagesController.cs index 9391ac7f7d..af67273a51 100644 --- a/src/NuGetGallery/Controllers/PackagesController.cs +++ b/src/NuGetGallery/Controllers/PackagesController.cs @@ -169,7 +169,8 @@ public async virtual Task UploadPackage() try { packageMetadata = PackageMetadata.FromNuspecReader( - package.GetNuspecReader()); + package.GetNuspecReader(), + strict: true); } catch (Exception ex) { @@ -407,7 +408,8 @@ public virtual async Task UploadPackage(HttpPostedFileBase uploadFil try { packageMetadata = PackageMetadata.FromNuspecReader( - package.GetNuspecReader()); + package.GetNuspecReader(), + strict: true); } catch (Exception ex) { @@ -1050,7 +1052,8 @@ public virtual async Task Reflow(string id, string version) var reflowPackageService = new ReflowPackageService( _entitiesContext, (PackageService)_packageService, - _packageFileService); + _packageFileService, + _telemetryService); try { @@ -1298,6 +1301,8 @@ private async Task HandleOwnershipRequest(string id, string userna if (package.Owners.Any(o => o.MatchesUser(user))) { + // If the user is already an owner, clean up the invalid request. + await _packageOwnershipManagementService.DeletePackageOwnershipRequestAsync(package, user); return View("ConfirmOwner", new PackageOwnerConfirmationModel(id, user.Username, ConfirmOwnershipResult.AlreadyOwner)); } @@ -1309,11 +1314,9 @@ private async Task HandleOwnershipRequest(string id, string userna if (accept) { - var result = await HandleSecurePushPropagation(package, user); - await _packageOwnershipManagementService.AddPackageOwnerAsync(package, user); - SendAddPackageOwnerNotification(package, user, result.Item1, result.Item2); + SendAddPackageOwnerNotification(package, user); return View("ConfirmOwner", new PackageOwnerConfirmationModel(id, user.Username, ConfirmOwnershipResult.Success)); } @@ -1334,19 +1337,23 @@ private async Task HandleOwnershipRequest(string id, string userna [RequiresAccountConfirmation("cancel pending ownership request")] public virtual async Task CancelPendingOwnershipRequest(string id, string requestingUsername, string pendingUsername) { - if (!string.Equals(requestingUsername, User.Identity.Name, StringComparison.OrdinalIgnoreCase)) + var package = _packageService.FindPackageRegistrationById(id); + if (package == null) + { + return HttpNotFound(); + } + + if (ActionsRequiringPermissions.ManagePackageOwnership.CheckPermissionsOnBehalfOfAnyAccount(GetCurrentUser(), package) != PermissionsCheckResult.Allowed) { return View("ConfirmOwner", new PackageOwnerConfirmationModel(id, requestingUsername, ConfirmOwnershipResult.NotYourRequest)); } - var package = _packageService.FindPackageRegistrationById(id); - if (package == null) + var requestingUser = _userService.FindByUsername(requestingUsername); + if (requestingUser == null) { return HttpNotFound(); } - var requestingUser = GetCurrentUser(); - var pendingUser = _userService.FindByUsername(pendingUsername); if (pendingUser == null) { @@ -1371,97 +1378,14 @@ public virtual async Task CancelPendingOwnershipRequest(string id, /// /// Package to which owner was added. /// Owner added. - /// Propagating owners for secure push. - /// Owners subscribed to secure push. - private void SendAddPackageOwnerNotification(PackageRegistration package, User newOwner, List propagators, List subscribed) + private void SendAddPackageOwnerNotification(PackageRegistration package, User newOwner) { var packageUrl = Url.Package(package.Id, version: null, relativeUrl: false); Func notNewOwner = o => !o.Username.Equals(newOwner.Username, StringComparison.OrdinalIgnoreCase); - // prepare policy messages if there were any secure push subscriptions. - var propagatorsPolicyMessage = string.Empty; - var subscribedPolicyMessage = string.Empty; - if (subscribed.Any()) - { - propagatorsPolicyMessage = string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerNotification_SecurePushRequired_Propagators, - string.Join(", ", propagators.Select(u => u.Username)), - string.Join(", ", subscribed.Select(s => s.Username)), - GetSecurePushPolicyDescriptions(), _config.GalleryOwner.Address); - - subscribedPolicyMessage = string.Format(CultureInfo.CurrentCulture, - Strings.AddOwnerNotification_SecurePushRequired_Subscribed, - string.Join(", ", propagators.Select(u => u.Username)), - GetSecurePushPolicyDescriptions(), _config.GalleryOwner.Address); - } - else - { - // new owner should only be notified if they have propagated policies. - propagators = propagators.Where(notNewOwner).ToList(); - } - - // notify propagators about new owner, including policy statement if any owners were subscribed. - propagators.ForEach(owner => _messageService.SendPackageOwnerAddedNotice(owner, newOwner, package, packageUrl, propagatorsPolicyMessage)); - - // notify subscribed about new owner, including policy statement. - subscribed.Where(notNewOwner).ToList() - .ForEach(owner => _messageService.SendPackageOwnerAddedNotice(owner, newOwner, package, packageUrl, subscribedPolicyMessage)); - - // notify already subscribed about new owner, excluding any policy statement. - var notSubscribed = package.Owners.Where(notNewOwner).Except(propagators).Except(subscribed).ToList(); - notSubscribed.ForEach(owner => _messageService.SendPackageOwnerAddedNotice(owner, newOwner, package, packageUrl, string.Empty)); - } - - /// - /// Enforce secure push policies on co-owners if new or existing owner requires it. - /// - /// Tuple where Item1 is propagators, Item2 is subscribed owners - private async Task, List>> HandleSecurePushPropagation(PackageRegistration package, User user) - { - var subscribed = new List(); - var propagators = package.Owners.Where(RequireSecurePushForCoOwnersPolicy.IsSubscribed).ToList(); - - if (RequireSecurePushForCoOwnersPolicy.IsSubscribed(user)) - { - propagators.Add(user); - } - - if (propagators.Any()) - { - if (await SubscribeToSecurePushAsync(user)) - { - subscribed.Add(user); - } - foreach (var owner in package.Owners) - { - if (await SubscribeToSecurePushAsync(owner)) - { - subscribed.Add(owner); - } - } - } - - return Tuple.Create(propagators, subscribed); - } - - private string GetSecurePushPolicyDescriptions() - { - return string.Format(CultureInfo.CurrentCulture, Strings.SecurePushPolicyDescriptions, - SecurePushSubscription.MinProtocolVersion, SecurePushSubscription.PushKeysExpirationInDays); - } - - private async Task SubscribeToSecurePushAsync(User user) - { - try - { - return await _securityPolicyService.SubscribeAsync(user, SecurePushSubscription.Name); - } - catch (Exception ex) - { - ex.Log(); - - throw; - } + // Notify existing owners + var notNewOwners = package.Owners.Where(notNewOwner).ToList(); + notNewOwners.ForEach(owner => _messageService.SendPackageOwnerAddedNotice(owner, newOwner, package, packageUrl, string.Empty)); } internal virtual async Task Edit(string id, string version, bool? listed, Func urlFactory) @@ -1545,7 +1469,8 @@ public virtual async Task VerifyPackage(VerifyPackageRequest formDat Debug.Assert(nugetPackage != null); var packageMetadata = PackageMetadata.FromNuspecReader( - nugetPackage.GetNuspecReader()); + nugetPackage.GetNuspecReader(), + strict: true); // Rule out problem scenario with multiple tabs - verification request (possibly with edits) was submitted by user // viewing a different package to what was actually most recently uploaded diff --git a/src/NuGetGallery/Controllers/PagesController.cs b/src/NuGetGallery/Controllers/PagesController.cs index 41e1943704..0d82cf3cda 100644 --- a/src/NuGetGallery/Controllers/PagesController.cs +++ b/src/NuGetGallery/Controllers/PagesController.cs @@ -19,15 +19,18 @@ public partial class PagesController : AppController { private readonly IContentService _contentService; + private readonly IContentObjectService _contentObjectService; private readonly IMessageService _messageService; private readonly ISupportRequestService _supportRequestService; protected PagesController() { } public PagesController(IContentService contentService, + IContentObjectService contentObjectService, IMessageService messageService, ISupportRequestService supportRequestService) { _contentService = contentService; + _contentObjectService = contentObjectService; _messageService = messageService; _supportRequestService = supportRequestService; } @@ -102,8 +105,10 @@ public virtual async Task Contact(ContactSupportViewModel contactF public virtual ActionResult Home() { var identity = OwinContext.Authentication?.User?.Identity as ClaimsIdentity; - var showTransformModal = ClaimsExtensions.HasDiscontinuedLoginCLaims(identity); - return View(new GalleryHomeViewModel(showTransformModal)); + var showTransformModal = ClaimsExtensions.HasDiscontinuedLoginClaims(identity); + var transformIntoOrganization = _contentObjectService.LoginDiscontinuationConfiguration + .ShouldUserTransformIntoOrganization(GetCurrentUser()); + return View(new GalleryHomeViewModel(showTransformModal, transformIntoOrganization)); } [HttpGet] diff --git a/src/NuGetGallery/Controllers/UsersController.cs b/src/NuGetGallery/Controllers/UsersController.cs index 8826bc88ef..6e470f548e 100644 --- a/src/NuGetGallery/Controllers/UsersController.cs +++ b/src/NuGetGallery/Controllers/UsersController.cs @@ -56,9 +56,7 @@ public UsersController( { EmailConfirmed = Strings.UserEmailConfirmed, EmailPreferencesUpdated = Strings.UserEmailPreferencesUpdated, - EmailUpdateCancelled = Strings.UserEmailUpdateCancelled, - EmailUpdated = Strings.UserEmailUpdated, - EmailUpdatedWithConfirmationRequired = Strings.UserEmailUpdatedWithConfirmationRequired + EmailUpdateCancelled = Strings.UserEmailUpdateCancelled }; protected override void SendNewAccountEmail(User account) @@ -126,14 +124,14 @@ public virtual async Task TransformToOrganization(TransformAccount var adminUser = UserService.FindByUsername(transformViewModel.AdminUsername); if (adminUser == null) { - ModelState.AddModelError("AdminUsername", String.Format(CultureInfo.CurrentCulture, + ModelState.AddModelError(string.Empty, String.Format(CultureInfo.CurrentCulture, Strings.TransformAccount_AdminAccountDoesNotExist, transformViewModel.AdminUsername)); return View(transformViewModel); } if (!UserService.CanTransformUserToOrganization(accountToTransform, adminUser, out var errorReason)) { - ModelState.AddModelError("AdminUsername", errorReason); + ModelState.AddModelError(string.Empty, errorReason); return View(transformViewModel); } @@ -368,11 +366,26 @@ public virtual ActionResult Packages() .Select(p => new ListPackageItemViewModel(p, currentUser)).OrderBy(p => p.Id) .ToList(); - var received = _packageOwnerRequestService.GetPackageOwnershipRequests(newOwner: currentUser); - var sent = _packageOwnerRequestService.GetPackageOwnershipRequests(requestingOwner: currentUser); + // find all received ownership requests + var userReceived = _packageOwnerRequestService.GetPackageOwnershipRequests(newOwner: currentUser); + var orgReceived = currentUser.Organizations + .Where(m => ActionsRequiringPermissions.HandlePackageOwnershipRequest.CheckPermissions(currentUser, m.Organization) == PermissionsCheckResult.Allowed) + .SelectMany(m => _packageOwnerRequestService.GetPackageOwnershipRequests(newOwner: m.Organization)); + var received = userReceived.Union(orgReceived); + + // find all sent ownership requests + var userSent = _packageOwnerRequestService.GetPackageOwnershipRequests(requestingOwner: currentUser); + var orgSent = currentUser.Organizations + .Where(m => ActionsRequiringPermissions.HandlePackageOwnershipRequest.CheckPermissions(currentUser, m.Organization) == PermissionsCheckResult.Allowed) + .SelectMany(m => _packageOwnerRequestService.GetPackageOwnershipRequests(requestingOwner: m.Organization)); + var sent = userSent.Union(orgSent); var ownerRequests = new OwnerRequestsViewModel(received, sent, currentUser, _packageService); - var reservedPrefixes = new ReservedNamespaceListViewModel(currentUser.ReservedNamespaces); + + var userReservedNamespaces = currentUser.ReservedNamespaces; + var organizationsReservedNamespaces = currentUser.Organizations.SelectMany(m => m.Organization.ReservedNamespaces); + + var reservedPrefixes = new ReservedNamespaceListViewModel(userReservedNamespaces.Union(organizationsReservedNamespaces).ToArray()); var model = new ManagePackagesViewModel { @@ -420,10 +433,10 @@ public virtual async Task ForgotPassword(ForgotPasswordViewModel m switch (result.Type) { case PasswordResetResultType.UserNotConfirmed: - ModelState.AddModelError("Email", Strings.UserIsNotYetConfirmed); + ModelState.AddModelError(string.Empty, Strings.UserIsNotYetConfirmed); break; case PasswordResetResultType.UserNotFound: - ModelState.AddModelError("Email", Strings.CouldNotFindAnyoneWithThatUsernameOrEmail); + ModelState.AddModelError(string.Empty, Strings.CouldNotFindAnyoneWithThatUsernameOrEmail); break; case PasswordResetResultType.Success: return SendPasswordResetEmail(result.User, forgotPassword: true); @@ -481,7 +494,7 @@ public virtual async Task ResetPassword(string username, string to } catch (InvalidOperationException ex) { - ModelState.AddModelError("", ex.Message); + ModelState.AddModelError(string.Empty, ex.Message); return View(model); } @@ -489,7 +502,7 @@ public virtual async Task ResetPassword(string username, string to if (!ViewBag.ResetTokenValid) { - ModelState.AddModelError("", Strings.InvalidOrExpiredPasswordResetToken); + ModelState.AddModelError(string.Empty, Strings.InvalidOrExpiredPasswordResetToken); return View(model); } diff --git a/src/NuGetGallery/ExtensionMethods.cs b/src/NuGetGallery/ExtensionMethods.cs index c5f2a7c542..09e20f2b5e 100644 --- a/src/NuGetGallery/ExtensionMethods.cs +++ b/src/NuGetGallery/ExtensionMethods.cs @@ -345,15 +345,24 @@ public static HtmlString ShowValidationMessagesFor(this HtmlH var metadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData); var propertyName = metadata.PropertyName.ToLower(); - return html.ValidationMessageFor(expression, validationMessage: null, htmlAttributes: new Dictionary + return html.ValidationMessageFor(expression, validationMessage: null, htmlAttributes: new Dictionary(ValidationHtmlAttributes) { { "id", $"{propertyName}-validation-message" }, - { "class", "help-block" }, - { "role", "alert" }, - { "aria-live", "assertive" }, }); } + public static MvcHtmlString ShowValidationMessagesForEmpty(this HtmlHelper html) + { + return html.ValidationMessage(modelName: string.Empty, htmlAttributes: ValidationHtmlAttributes); + } + + private static IDictionary ValidationHtmlAttributes = new Dictionary + { + { "class", "help-block" }, + { "role", "alert" }, + { "aria-live", "assertive" }, + }; + public static string ToShortNameOrNull(this NuGetFramework frameworkName) { if (frameworkName == null) diff --git a/src/NuGetGallery/Extensions/ClaimsExtensions.cs b/src/NuGetGallery/Extensions/ClaimsExtensions.cs index 8a1feb7293..9e23c48a20 100644 --- a/src/NuGetGallery/Extensions/ClaimsExtensions.cs +++ b/src/NuGetGallery/Extensions/ClaimsExtensions.cs @@ -33,7 +33,7 @@ public static IdentityInformation GetIdentityInformation(ClaimsIdentity claimsId return new IdentityInformation(identifierClaim.Value, nameClaim.Value, emailClaim?.Value, authType, tenantId: null); } - public static bool HasDiscontinuedLoginCLaims(ClaimsIdentity identity) + public static bool HasDiscontinuedLoginClaims(ClaimsIdentity identity) { if (identity == null || !identity.IsAuthenticated) { diff --git a/src/NuGetGallery/Filters/UiAuthorizeAttribute.cs b/src/NuGetGallery/Filters/UiAuthorizeAttribute.cs index 84a08814f4..e76be29c18 100644 --- a/src/NuGetGallery/Filters/UiAuthorizeAttribute.cs +++ b/src/NuGetGallery/Filters/UiAuthorizeAttribute.cs @@ -21,7 +21,7 @@ public override void OnAuthorization(AuthorizationContext filterContext) { // If the user has a discontinued login claim, redirect them to the homepage var identity = filterContext.HttpContext.User.Identity as ClaimsIdentity; - if (!AllowDiscontinuedLogins && ClaimsExtensions.HasDiscontinuedLoginCLaims(identity)) + if (!AllowDiscontinuedLogins && ClaimsExtensions.HasDiscontinuedLoginClaims(identity)) { filterContext.Result = new RedirectToRouteResult( new RouteValueDictionary( diff --git a/src/NuGetGallery/Infrastructure/UserSafeException.cs b/src/NuGetGallery/Infrastructure/UserSafeException.cs index e2b2024f00..8830db406e 100644 --- a/src/NuGetGallery/Infrastructure/UserSafeException.cs +++ b/src/NuGetGallery/Infrastructure/UserSafeException.cs @@ -1,9 +1,7 @@ // 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 System.Web; namespace NuGetGallery { diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index e28afd075e..327e52318d 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -912,8 +912,6 @@ - - @@ -922,7 +920,6 @@ - @@ -1070,6 +1067,7 @@ + @@ -2029,10 +2027,8 @@ - - @@ -2054,6 +2050,8 @@ + + @@ -2098,7 +2096,6 @@ - diff --git a/src/NuGetGallery/OData/SearchService/SearchAdaptor.cs b/src/NuGetGallery/OData/SearchService/SearchAdaptor.cs index a8be81f2d0..ea1ffa095f 100644 --- a/src/NuGetGallery/OData/SearchService/SearchAdaptor.cs +++ b/src/NuGetGallery/OData/SearchService/SearchAdaptor.cs @@ -291,7 +291,7 @@ private static bool TryReadSearchFilter(bool allVersionsInIndex, string url, boo return true; } - public static Uri GetNextLink(Uri currentRequestUri, long? totalResultCount, object queryParameters, ODataQueryOptions options, ODataQuerySettings settings) + public static Uri GetNextLink(Uri currentRequestUri, long? totalResultCount, object queryParameters, ODataQueryOptions options, ODataQuerySettings settings, int? semVerLevelKey = null) { if (!totalResultCount.HasValue || totalResultCount.Value <= MaxPageSize || totalResultCount.Value == 0) { @@ -374,6 +374,16 @@ public static Uri GetNextLink(Uri currentRequestUri, long? totalResultCount, obj queryBuilder.Append("&"); } + if (semVerLevelKey != null) + { + if(semVerLevelKey == SemVerLevelKey.SemVer2) + { + queryBuilder.Append("semVerLevel="); + queryBuilder.Append(SemVerLevelKey.SemVerLevel2); + queryBuilder.Append("&"); + } + } + var queryString = queryBuilder.ToString().TrimEnd('&'); var builder = new UriBuilder(currentRequestUri); diff --git a/src/NuGetGallery/Public/clientaccesspolicy.xml b/src/NuGetGallery/Public/clientaccesspolicy.xml deleted file mode 100644 index aee9221f88..0000000000 --- a/src/NuGetGallery/Public/clientaccesspolicy.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/NuGetGallery/RouteName.cs b/src/NuGetGallery/RouteName.cs index 55ef10f202..06ce001b19 100644 --- a/src/NuGetGallery/RouteName.cs +++ b/src/NuGetGallery/RouteName.cs @@ -7,6 +7,7 @@ public static class RouteName { public const string Account = "Account"; public const string OrganizationAccount = "ManageOrganization"; + public const string AddOrganization = "AddOrganization"; public const string ChangeOrganizationEmailSubscription = "ChangeOrganizationEmailSubscription"; public const string TransformToOrganization = "TransformToOrganization"; public const string TransformToOrganizationConfirmation = "ConfirmTransformToOrganization"; @@ -23,7 +24,9 @@ public static class RouteName public const string UploadPackage = "UploadPackage"; public const string UploadPackageProgress = "UploadPackageProgress"; public const string PackageVersionAction = "PackageVersionAction"; + public const string ConfirmPendingOwnershipRequest = "ConfirmPendingOwnershipRequest"; public const string PackageOwnerConfirmation = "PackageOwnerConfirmation"; + public const string RejectPendingOwnershipRequest = "RejectPendingOwnershipRequest"; public const string PackageOwnerRejection = "PackageOwnerRejection"; public const string PackageOwnerCancellation = "PackageOwnerCancellation"; public const string PackageAction = "PackageAction"; @@ -61,6 +64,7 @@ public static class RouteName public const string RemoveCredential = "RemoveCredential"; public const string RemovePassword = "RemovePassword"; public const string ConfirmAccount = "ConfirmAccount"; + public const string SigninAssistance = "SigninAssistance"; public const string ChangeEmailSubscription = "ChangeEmailSubscription"; public const string ErrorReadOnly = "ErrorReadOnly"; public const string Error500 = "Error500"; diff --git a/src/NuGetGallery/Scripts/gallery/md5.js b/src/NuGetGallery/Scripts/gallery/md5.js new file mode 100644 index 0000000000..584b405471 --- /dev/null +++ b/src/NuGetGallery/Scripts/gallery/md5.js @@ -0,0 +1,251 @@ +/** + * + * MD5 (Message-Digest Algorithm) + * http://www.webtoolkit.info/ + * + **/ + +(function (exports) { + 'use strict'; + + var MD5 = function (string) { + + function RotateLeft(lValue, iShiftBits) { + return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); + } + + function AddUnsigned(lX, lY) { + var lX4, lY4, lX8, lY8, lResult; + lX8 = (lX & 0x80000000); + lY8 = (lY & 0x80000000); + lX4 = (lX & 0x40000000); + lY4 = (lY & 0x40000000); + lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF); + if (lX4 & lY4) { + return (lResult ^ 0x80000000 ^ lX8 ^ lY8); + } + if (lX4 | lY4) { + if (lResult & 0x40000000) { + return (lResult ^ 0xC0000000 ^ lX8 ^ lY8); + } else { + return (lResult ^ 0x40000000 ^ lX8 ^ lY8); + } + } else { + return (lResult ^ lX8 ^ lY8); + } + } + + function F(x, y, z) { + return (x & y) | ((~x) & z); + } + + function G(x, y, z) { + return (x & z) | (y & (~z)); + } + + function H(x, y, z) { + return (x ^ y ^ z); + } + + function I(x, y, z) { + return (y ^ (x | (~z))); + } + + function FF(a, b, c, d, x, s, ac) { + a = AddUnsigned(a, AddUnsigned(AddUnsigned(F(b, c, d), x), ac)); + return AddUnsigned(RotateLeft(a, s), b); + }; + + function GG(a, b, c, d, x, s, ac) { + a = AddUnsigned(a, AddUnsigned(AddUnsigned(G(b, c, d), x), ac)); + return AddUnsigned(RotateLeft(a, s), b); + }; + + function HH(a, b, c, d, x, s, ac) { + a = AddUnsigned(a, AddUnsigned(AddUnsigned(H(b, c, d), x), ac)); + return AddUnsigned(RotateLeft(a, s), b); + }; + + function II(a, b, c, d, x, s, ac) { + a = AddUnsigned(a, AddUnsigned(AddUnsigned(I(b, c, d), x), ac)); + return AddUnsigned(RotateLeft(a, s), b); + }; + + function ConvertToWordArray(string) { + var lWordCount; + var lMessageLength = string.length; + var lNumberOfWords_temp1 = lMessageLength + 8; + var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64; + var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16; + var lWordArray = Array(lNumberOfWords - 1); + var lBytePosition = 0; + var lByteCount = 0; + while (lByteCount < lMessageLength) { + lWordCount = (lByteCount - (lByteCount % 4)) / 4; + lBytePosition = (lByteCount % 4) * 8; + lWordArray[lWordCount] = (lWordArray[lWordCount] | (string.charCodeAt(lByteCount) << lBytePosition)); + lByteCount++; + } + lWordCount = (lByteCount - (lByteCount % 4)) / 4; + lBytePosition = (lByteCount % 4) * 8; + lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); + lWordArray[lNumberOfWords - 2] = lMessageLength << 3; + lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; + return lWordArray; + }; + + function WordToHex(lValue) { + var WordToHexValue = "", + WordToHexValue_temp = "", + lByte, lCount; + for (lCount = 0; lCount <= 3; lCount++) { + lByte = (lValue >>> (lCount * 8)) & 255; + WordToHexValue_temp = "0" + lByte.toString(16); + WordToHexValue = WordToHexValue + WordToHexValue_temp.substr(WordToHexValue_temp.length - 2, 2); + } + return WordToHexValue; + }; + + function Utf8Encode(string) { + string = string.replace(/\r\n/g, "\n"); + var utftext = ""; + + for (var n = 0; n < string.length; n++) { + + var c = string.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } else if ((c > 127) && (c < 2048)) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + + } + + return utftext; + }; + + var x = Array(); + var k, AA, BB, CC, DD, a, b, c, d; + var S11 = 7, + S12 = 12, + S13 = 17, + S14 = 22; + var S21 = 5, + S22 = 9, + S23 = 14, + S24 = 20; + var S31 = 4, + S32 = 11, + S33 = 16, + S34 = 23; + var S41 = 6, + S42 = 10, + S43 = 15, + S44 = 21; + + string = Utf8Encode(string); + + x = ConvertToWordArray(string); + + a = 0x67452301; + b = 0xEFCDAB89; + c = 0x98BADCFE; + d = 0x10325476; + + for (k = 0; k < x.length; k += 16) { + AA = a; + BB = b; + CC = c; + DD = d; + a = FF(a, b, c, d, x[k + 0], S11, 0xD76AA478); + d = FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756); + c = FF(c, d, a, b, x[k + 2], S13, 0x242070DB); + b = FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE); + a = FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF); + d = FF(d, a, b, c, x[k + 5], S12, 0x4787C62A); + c = FF(c, d, a, b, x[k + 6], S13, 0xA8304613); + b = FF(b, c, d, a, x[k + 7], S14, 0xFD469501); + a = FF(a, b, c, d, x[k + 8], S11, 0x698098D8); + d = FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF); + c = FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1); + b = FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE); + a = FF(a, b, c, d, x[k + 12], S11, 0x6B901122); + d = FF(d, a, b, c, x[k + 13], S12, 0xFD987193); + c = FF(c, d, a, b, x[k + 14], S13, 0xA679438E); + b = FF(b, c, d, a, x[k + 15], S14, 0x49B40821); + a = GG(a, b, c, d, x[k + 1], S21, 0xF61E2562); + d = GG(d, a, b, c, x[k + 6], S22, 0xC040B340); + c = GG(c, d, a, b, x[k + 11], S23, 0x265E5A51); + b = GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA); + a = GG(a, b, c, d, x[k + 5], S21, 0xD62F105D); + d = GG(d, a, b, c, x[k + 10], S22, 0x2441453); + c = GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681); + b = GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8); + a = GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6); + d = GG(d, a, b, c, x[k + 14], S22, 0xC33707D6); + c = GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87); + b = GG(b, c, d, a, x[k + 8], S24, 0x455A14ED); + a = GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905); + d = GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8); + c = GG(c, d, a, b, x[k + 7], S23, 0x676F02D9); + b = GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A); + a = HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942); + d = HH(d, a, b, c, x[k + 8], S32, 0x8771F681); + c = HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122); + b = HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C); + a = HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44); + d = HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9); + c = HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60); + b = HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70); + a = HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6); + d = HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA); + c = HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085); + b = HH(b, c, d, a, x[k + 6], S34, 0x4881D05); + a = HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039); + d = HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5); + c = HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8); + b = HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665); + a = II(a, b, c, d, x[k + 0], S41, 0xF4292244); + d = II(d, a, b, c, x[k + 7], S42, 0x432AFF97); + c = II(c, d, a, b, x[k + 14], S43, 0xAB9423A7); + b = II(b, c, d, a, x[k + 5], S44, 0xFC93A039); + a = II(a, b, c, d, x[k + 12], S41, 0x655B59C3); + d = II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92); + c = II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D); + b = II(b, c, d, a, x[k + 1], S44, 0x85845DD1); + a = II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F); + d = II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0); + c = II(c, d, a, b, x[k + 6], S43, 0xA3014314); + b = II(b, c, d, a, x[k + 13], S44, 0x4E0811A1); + a = II(a, b, c, d, x[k + 4], S41, 0xF7537E82); + d = II(d, a, b, c, x[k + 11], S42, 0xBD3AF235); + c = II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB); + b = II(b, c, d, a, x[k + 9], S44, 0xEB86D391); + a = AddUnsigned(a, AA); + b = AddUnsigned(b, BB); + c = AddUnsigned(c, CC); + d = AddUnsigned(d, DD); + } + + var temp = WordToHex(a) + WordToHex(b) + WordToHex(c) + WordToHex(d); + + return temp.toLowerCase(); + }; + + // Expose the class either via AMD, CommonJS or the global object + if (typeof define === 'function' && define.amd) { + define(function () { + return MD5; + }); + } else if (typeof module === 'object' && module.exports) { + module.exports = MD5; + } else { + exports.MD5 = MD5; + } +})(this); \ No newline at end of file diff --git a/src/NuGetGallery/Scripts/gallery/page-add-organization.js b/src/NuGetGallery/Scripts/gallery/page-add-organization.js new file mode 100644 index 0000000000..1fa87885ba --- /dev/null +++ b/src/NuGetGallery/Scripts/gallery/page-add-organization.js @@ -0,0 +1,49 @@ +$(function () { + 'use strict'; + + var _gravatarTimeout = 0; + var _gravatarDelay = 500; + var _gravatarIsUpdating = false; + + var _emailBox = $("#" + emailBoxGroupId + " > input"); + var _gravatar = $("#" + gravatarId); + var _template = "https://secure.gravatar.com/avatar/{email}?s=512&r=g&d=retro"; + + // When the email in the form changes, update the Gravatar displayed as the logo. + // If the user is continuing to type, wait until they are done before updating the Gravatar. + _emailBox.on("keyup", function (e) { + if ((e.keyCode >= 46 && e.keyCode <= 90) // delete, 0-9, a-z + || (e.keyCode >= 96 && e.keyCode <= 111) // numpad + || (e.keyCode >= 186) // punctuation + || (e.keyCode === 8)) // backspace + { + clearTimeout(_gravatarTimeout); + _gravatarTimeout = setTimeout(UpdateGravatar, _gravatarDelay); + } + }); + + // When the user switches focus from the email textbox, update the Gravatar displayed as the logo. + // This is because the user can change the email without typing (for example, by pasting contents from somewhere else). + _emailBox.on("blur", function (e) { + if (!_gravatarTimeout) { + UpdateGravatar() + } + }); + + function UpdateGravatar() { + _gravatarTimeout = 0; + var src = defaultImage; + + var email = _emailBox.val(); + if (email.match(emailValidationRegex)) { + src = _template.replace("{email}", MD5(email)); + } + + _gravatar.attr("src", src); + } + + // Immediately fetch the image if the email box is filled in initially. + if (_emailBox.val()) { + UpdateGravatar(); + } +}); \ No newline at end of file diff --git a/src/NuGetGallery/Scripts/gallery/page-manage-packages.js b/src/NuGetGallery/Scripts/gallery/page-manage-packages.js index ea4fa6193f..65ffd41c0a 100644 --- a/src/NuGetGallery/Scripts/gallery/page-manage-packages.js +++ b/src/NuGetGallery/Scripts/gallery/page-manage-packages.js @@ -3,16 +3,16 @@ function showInitialPackagesData(dataSelector, packagesList) { var downloadsCount = 0; - $.each(packagesList, function () { downloadsCount += this.TotalDownloadCount }); + $.each(packagesList, function () { downloadsCount += this.TotalDownloadCount; }); $(dataSelector).text(formatPackagesData(packagesList.length, downloadsCount)); } function formatPackagesData(packagesCount, downloadsCount) { return packagesCount.toLocaleString() - + ' package' + (packagesCount == 1 ? '' : 's') + + ' package' + (packagesCount === 1 ? '' : 's') + ' / ' + downloadsCount.toLocaleString() - + ' download' + (downloadsCount == 1 ? '' : 's'); + + ' download' + (downloadsCount === 1 ? '' : 's'); } $(function () { @@ -90,6 +90,141 @@ }, this); } + function showInitialReservedNamespaceData(dataSelector, namespacesList) { + $(dataSelector).text(formatReservedNamespacesData(namespacesList.length)); + } + + function formatReservedNamespacesData(namespacesCount) { + return namespacesCount.toLocaleString() + " namespace" + (namespacesCount === 1 ? '' : 's'); + } + + function ReservedNamespaceListItemViewModel(reservedNamespaceListViewModel, namespaceItem) { + var self = this; + + this.ReservedNamespaceListViewModel = reservedNamespaceListViewModel; + this.Pattern = namespaceItem.Pattern; + this.SearchUrl = namespaceItem.SearchUrl; + this.Owners = namespaceItem.Owners; + this.IsPublic = namespaceItem.IsPublic; + + this.Visible = ko.observable(true); + + this.UpdateVisibility = function (ownerFilter) { + var visible = ownerFilter === "All packages"; + if (!visible) { + for (var i in self.Owners) { + if (ownerFilter === self.Owners[i].Username) { + visible = true; + break; + } + } + } + this.Visible(visible); + }; + } + + function ReservedNamespaceListViewModel(managePackagesViewModel, namespaces) { + var self = this; + + this.ManagePackagesViewModel = managePackagesViewModel; + this.Namespaces = $.map(namespaces, function (data) { + return new ReservedNamespaceListItemViewModel(self, data); + }); + this.VisibleNamespacesCount = ko.observable(); + this.VisibleNamespacesHeading = ko.pureComputed(function () { + return formatReservedNamespacesData(ko.unwrap(self.VisibleNamespacesCount())); + }); + + this.ManagePackagesViewModel.OwnerFilter.subscribe(function (newOwner) { + var namespacesCount = 0; + for (var i in self.Namespaces) { + self.Namespaces[i].UpdateVisibility(newOwner.Username); + if (self.Namespaces[i].Visible()) { + namespacesCount++; + } + } + this.VisibleNamespacesCount(namespacesCount); + }, this); + } + + function showInitialOwnerRequestsData(dataSelector, requestsList) { + $(dataSelector).text(formatOwnerRequestsData(requestsList.length)); + } + + function formatOwnerRequestsData(requestsCount) { + return requestsCount.toLocaleString() + " request" + (requestsCount === 1 ? '' : 's'); + } + + function OwnerRequestsItemViewModel(ownerRequestsListViewModel, ownerRequestItem, showReceived, showSent) { + var self = this; + + this.OwnerRequestsListViewModel = ownerRequestsListViewModel; + this.Id = ownerRequestItem.Id; + this.Requesting = ownerRequestItem.Requesting; + this.New = ownerRequestItem.New; + this.Owners = ownerRequestItem.Owners; + this.PackageIconUrl = ownerRequestItem.PackageIconUrl + ? ownerRequestItem.PackageIconUrl + : this.OwnerRequestsListViewModel.ManagePackagesViewModel.DefaultPackageIconUrl; + this.PackageUrl = ownerRequestItem.PackageUrl; + this.CanAccept = ownerRequestItem.CanAccept; + this.CanCancel = ownerRequestItem.CanCancel; + this.ConfirmUrl = ownerRequestItem.ConfirmUrl; + this.RejectUrl = ownerRequestItem.RejectUrl; + this.CancelUrl = ownerRequestItem.CancelUrl; + this.ShowReceived = showReceived; + this.ShowSent = showSent; + + this.Visible = ko.observable(true); + + this.UpdateVisibility = function (ownerFilter) { + var visible = ownerFilter === "All packages"; + if (!visible) { + if (self.ShowReceived && ownerFilter === self.New.Username) { + visible = true; + } + + if (self.ShowSent) { + for (var i in self.Owners) { + if (ownerFilter === self.Owners[i].Username) { + visible = true; + break; + } + } + } + } + this.Visible(visible); + }; + this.PackageIconUrlFallback = ko.pureComputed(function () { + var url = this.OwnerRequestsListViewModel.ManagePackagesViewModel.PackageIconUrlFallback; + return "this.src='" + url + "'; this.onerror = null;"; + }, this); + } + + function OwnerRequestsListViewModel(managePackagesViewModel, requests, showReceived, showSent) { + var self = this; + + this.ManagePackagesViewModel = managePackagesViewModel; + this.Requests = $.map(requests, function (data) { + return new OwnerRequestsItemViewModel(self, data, showReceived, showSent); + }); + this.VisibleRequestsCount = ko.observable(); + this.VisibleRequestsHeading = ko.pureComputed(function () { + return formatOwnerRequestsData(ko.unwrap(self.VisibleRequestsCount())); + }, this); + + this.ManagePackagesViewModel.OwnerFilter.subscribe(function (newOwner) { + var requestsCount = 0; + for (var i in self.Requests) { + self.Requests[i].UpdateVisibility(newOwner.Username); + if (self.Requests[i].Visible()) { + requestsCount++; + } + } + this.VisibleRequestsCount(requestsCount); + }, this); + } + function ManagePackagesViewModel(initialData) { var self = this; @@ -105,11 +240,17 @@ this.ListedPackages = new PackagesListViewModel(this, "published", initialData.ListedPackages); this.UnlistedPackages = new PackagesListViewModel(this, "unlisted", initialData.UnlistedPackages); + this.ReservedNamespaces = new ReservedNamespaceListViewModel(this, initialData.ReservedNamespaces); + this.RequestsReceived = new OwnerRequestsListViewModel(this, initialData.RequestsReceived, true, false); + this.RequestsSent = new OwnerRequestsListViewModel(this, initialData.RequestsSent, false, true); } // Immediately load initial expander data showInitialPackagesData("#listed-data", initialData.ListedPackages); showInitialPackagesData("#unlisted-data", initialData.UnlistedPackages); + showInitialReservedNamespaceData("#namespaces-data", initialData.ReservedNamespaces); + showInitialOwnerRequestsData("#requests-received-data", initialData.RequestsReceived); + showInitialOwnerRequestsData("#requests-sent-data", initialData.RequestsSent); // Set up the data binding. var managePackagesViewModel = new ManagePackagesViewModel(initialData); diff --git a/src/NuGetGallery/Scripts/gallery/page-signin.js b/src/NuGetGallery/Scripts/gallery/page-signin.js new file mode 100644 index 0000000000..dfedf7b7ca --- /dev/null +++ b/src/NuGetGallery/Scripts/gallery/page-signin.js @@ -0,0 +1,107 @@ +$(function () { + 'use strict'; + $('#signin-assistance').click(function () { + $('#signinAssistanceModal').modal({ + show: true + }); + }); + + var addAjaxAntiForgeryToken = function (data) { + var $field = $("#AntiForgeryForm input[name=__RequestVerificationToken]"); + data["__RequestVerificationToken"] = $field.val(); + }; + + var failHandler = function (jqXHR, textStatus, errorThrown) { + viewModel.message(window.nuget.formatString(errorThrown)); + }; + + var viewModel = { + message: ko.observable(''), + usernameForAssistance: ko.observable(''), + formattedEmailAddress: ko.observable(''), + inputEmailAddress: ko.observable(''), + getUsername: ko.observable(true), + getEmail: ko.observable(false), + emailNotificationSent: ko.observable(false), + + getEmailAddress: function () { + viewModel.message(""); + + var username = viewModel.usernameForAssistance(); + if (!username) { + viewModel.message("Please enter a valid username"); + return; + } + + var obj = { + username: username + }; + + addAjaxAntiForgeryToken(obj); + + $.ajax({ + url: signinAssistanceUrl, + dataType: 'json', + type: 'POST', + data: obj, + success: function (data) { + if (data.success) { + viewModel.formattedEmailAddress(data.EmailAddress); + viewModel.getUsername(false); + viewModel.getEmail(true); + } else { + viewModel.message(data.message); + } + } + }) + .error(failHandler); + }, + + sendEmailNotification: function () { + viewModel.message(""); + + var username = viewModel.usernameForAssistance(); + var inputEmailAddress = viewModel.inputEmailAddress(); + if (!inputEmailAddress) { + viewModel.message("Please enter a valid email address"); + return; + } + + var obj = { + username: username, + providedEmailAddress: inputEmailAddress + }; + + addAjaxAntiForgeryToken(obj); + + $.ajax({ + url: signinAssistanceUrl, + dataType: 'json', + type: 'POST', + data: obj, + success: function (data) { + if (data.success) { + viewModel.getUsername(false); + viewModel.getEmail(false); + viewModel.emailNotificationSent(true); + } else { + viewModel.message(data.message); + } + } + }) + .error(failHandler); + }, + + resetViewModel: function () { + viewModel.message(''); + viewModel.usernameForAssistance(''); + viewModel.formattedEmailAddress(''); + viewModel.inputEmailAddress(''); + viewModel.getUsername(true); + viewModel.getEmail(false); + viewModel.emailNotificationSent(false); + } + }; + + ko.applyBindings(viewModel); +}); diff --git a/src/NuGetGallery/Security/ISecurityPolicyService.cs b/src/NuGetGallery/Security/ISecurityPolicyService.cs index 3c01b390dc..e3155bdcf8 100644 --- a/src/NuGetGallery/Security/ISecurityPolicyService.cs +++ b/src/NuGetGallery/Security/ISecurityPolicyService.cs @@ -36,7 +36,7 @@ public interface ISecurityPolicyService /// /// Subscribe a user to one or more security policies. /// - Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscription); + Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscription, bool commitChanges = true); /// /// Unsubscribe a user from one or more security policies. diff --git a/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs b/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs deleted file mode 100644 index 0ccd491ed8..0000000000 --- a/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs +++ /dev/null @@ -1,108 +0,0 @@ -// 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.Globalization; -using System.Linq; -using Newtonsoft.Json; -using NuGet.Protocol; -using NuGet.Versioning; - -namespace NuGetGallery.Security -{ - /// - /// This code should be removed soon: https://github.com/NuGet/Engineering/issues/800 - /// User security policy that requires a minimum client version in order to push packages. - /// - public class RequireMinClientVersionForPushPolicy : UserSecurityPolicyHandler - { - public const string PolicyName = nameof(RequireMinClientVersionForPushPolicy); - - public class State - { - [JsonProperty("v")] - [JsonConverter(typeof(NuGetVersionConverter))] - public NuGetVersion MinClientVersion { get; set; } - } - - public RequireMinClientVersionForPushPolicy() - : base(PolicyName, SecurityPolicyAction.PackagePush) - { - } - - /// - /// Create a user security policy that requires a minimum client version. - /// - public static UserSecurityPolicy CreatePolicy(string subscription, NuGetVersion minClientVersion) - { - var value = JsonConvert.SerializeObject(new State() { - MinClientVersion = minClientVersion - }); - - return new UserSecurityPolicy(PolicyName, subscription, value); - } - - /// - /// In case of multiple, select the max of the minimum required client versions. - /// - private NuGetVersion GetMaxOfMinClientVersions(UserSecurityPolicyEvaluationContext context) - { - var policyStates = context.Policies - .Where(p => !string.IsNullOrEmpty(p.Value)) - .Select(p => JsonConvert.DeserializeObject(p.Value)); - return policyStates.Max(s => s.MinClientVersion); - } - - /// - /// Get the current client version from the request. - /// - private NuGetVersion GetClientVersion(UserSecurityPolicyEvaluationContext context) - { - var clientVersionString = context.HttpContext.Request?.Headers[Constants.ClientVersionHeaderName]; - - NuGetVersion clientVersion; - return NuGetVersion.TryParse(clientVersionString, out clientVersion) ? clientVersion : null; - } - - /// - /// Get the current protocol version from the request. - /// - private NuGetVersion GetProtocolVersion(UserSecurityPolicyEvaluationContext context) - { - var protocolVersionString = context.HttpContext.Request?.Headers[Constants.NuGetProtocolHeaderName]; - - NuGetVersion protocolVersion; - return NuGetVersion.TryParse(protocolVersionString, out protocolVersion) ? protocolVersion : null; - } - - /// - /// Evaluate if this security policy is met. - /// - public override SecurityPolicyResult Evaluate(UserSecurityPolicyEvaluationContext context) - { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } - - var minClientVersion = GetMaxOfMinClientVersions(context); - - // Do we have X-NuGet-Protocol-Version header? - var protocolVersion = GetProtocolVersion(context); - - if (protocolVersion == null) - { - // Do we have X-NuGet-Client-Version header? - protocolVersion = GetClientVersion(context); - } - - if (protocolVersion == null || protocolVersion < minClientVersion) - { - return SecurityPolicyResult.CreateErrorResult(string.Format(CultureInfo.CurrentCulture, - Strings.SecurityPolicy_RequireMinProtocolVersionForPush, minClientVersion)); - } - - return SecurityPolicyResult.SuccessResult; - } - } -} \ No newline at end of file diff --git a/src/NuGetGallery/Security/RequireSecurePushForCoOwnersPolicy.cs b/src/NuGetGallery/Security/RequireSecurePushForCoOwnersPolicy.cs deleted file mode 100644 index 3606795d27..0000000000 --- a/src/NuGetGallery/Security/RequireSecurePushForCoOwnersPolicy.cs +++ /dev/null @@ -1,54 +0,0 @@ -// 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 System.Threading.Tasks; - -namespace NuGetGallery.Security -{ - /// - /// Subscribable policy that propagates secure push policies to new package co-owners. - /// - public class RequireSecurePushForCoOwnersPolicy : UserSecurityPolicyHandler, IUserSecurityPolicySubscription - { - public const string _SubscriptionName = "SecurePushForCoOwners"; - public const string PolicyName = nameof(RequireSecurePushForCoOwnersPolicy); - - public string SubscriptionName => _SubscriptionName; - - public IEnumerable Policies - { - get - { - yield return new UserSecurityPolicy(PolicyName, SubscriptionName); - } - } - - public RequireSecurePushForCoOwnersPolicy() - : base(PolicyName, SecurityPolicyAction.ManagePackageOwners) - { - } - - public Task OnSubscribeAsync(UserSecurityPolicySubscriptionContext context) - { - return Task.CompletedTask; - } - - public Task OnUnsubscribeAsync(UserSecurityPolicySubscriptionContext context) - { - return Task.CompletedTask; - } - - public override SecurityPolicyResult Evaluate(UserSecurityPolicyEvaluationContext context) - { - return SecurityPolicyResult.SuccessResult; - } - - public static bool IsSubscribed(User user) - { - return user.SecurityPolicies.Any(p => p.Name.Equals(RequireSecurePushForCoOwnersPolicy.PolicyName, StringComparison.OrdinalIgnoreCase)); - } - } -} \ No newline at end of file diff --git a/src/NuGetGallery/Security/SecurePushSubscription.cs b/src/NuGetGallery/Security/SecurePushSubscription.cs deleted file mode 100644 index d578b22faa..0000000000 --- a/src/NuGetGallery/Security/SecurePushSubscription.cs +++ /dev/null @@ -1,100 +0,0 @@ -// 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 System.Threading.Tasks; -using NuGet.Versioning; -using NuGetGallery.Authentication; -using NuGetGallery.Auditing; -using NuGetGallery.Diagnostics; - -namespace NuGetGallery.Security -{ - /// - /// User security policies for the secure push subscription. - /// - public class SecurePushSubscription : IUserSecurityPolicySubscription - { - public const string Name = "SecurePush"; - internal const string MinProtocolVersion = "4.1.0"; - internal const int PushKeysExpirationInDays = 30; - - private IAuditingService _auditing; - private IDiagnosticsSource _diagnostics; - - /// - /// Subscription name. - /// - public string SubscriptionName - { - get - { - return Name; - } - } - - /// - /// Required policies for this subscription. - /// - public IEnumerable Policies - { - get - { - yield return new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName, SubscriptionName); - yield return RequireMinProtocolVersionForPushPolicy.CreatePolicy(SubscriptionName, new NuGetVersion(MinProtocolVersion)); - } - } - - public SecurePushSubscription(IAuditingService auditing, IDiagnosticsService diagnostics) - { - _auditing = auditing ?? throw new ArgumentNullException(nameof(auditing)); - - if (diagnostics == null) - { - throw new ArgumentNullException(nameof(diagnostics)); - } - - _diagnostics = diagnostics.SafeGetSource(nameof(SecurePushSubscription)); - } - - /// - /// On subscribe, set API keys with push capability to expire in days. - /// - /// - public async Task OnSubscribeAsync(UserSecurityPolicySubscriptionContext context) - { - var pushKeys = context.User.Credentials.Where(c => - c.IsApiKey() && - ( - c.Scopes.Count == 0 || - c.Scopes.Any(s => - s.AllowedAction.Equals(NuGetScopes.PackagePush, StringComparison.OrdinalIgnoreCase) || - s.AllowedAction.Equals(NuGetScopes.PackagePushVersion, StringComparison.OrdinalIgnoreCase) - )) - ); - - var expires = DateTime.UtcNow.AddDays(PushKeysExpirationInDays); - var expireTasks = new List(); - foreach (var key in pushKeys) - { - if (!key.Expires.HasValue || key.Expires > expires) - { - expireTasks.Add(_auditing.SaveAuditRecordAsync( - new UserAuditRecord(context.User, AuditedUserAction.ExpireCredential, key))); - - key.Expires = expires; - } - } - await Task.WhenAll(expireTasks); - - _diagnostics.Information($"Expiring {pushKeys.Count()} keys with push capability."); - } - - public Task OnUnsubscribeAsync(UserSecurityPolicySubscriptionContext context) - { - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/NuGetGallery/Security/SecurityPolicyService.cs b/src/NuGetGallery/Security/SecurityPolicyService.cs index 520b9274e9..25ad68fd00 100644 --- a/src/NuGetGallery/Security/SecurityPolicyService.cs +++ b/src/NuGetGallery/Security/SecurityPolicyService.cs @@ -28,23 +28,16 @@ public class SecurityPolicyService : ISecurityPolicyService protected IAppConfiguration Configuration { get; set; } - protected SecurePushSubscription SecurePush { get; set; } - protected IUserSecurityPolicySubscription DefaultSubscription { get; set; } - protected RequireSecurePushForCoOwnersPolicy SecurePushForCoOwners { get; set; } - protected SecurityPolicyService() { } - public SecurityPolicyService(IEntitiesContext entitiesContext, IAuditingService auditing, IDiagnosticsService diagnostics, IAppConfiguration configuration, - SecurePushSubscription securePush = null, RequireSecurePushForCoOwnersPolicy securePushForCoOwners = null) + public SecurityPolicyService(IEntitiesContext entitiesContext, IAuditingService auditing, IDiagnosticsService diagnostics, IAppConfiguration configuration) { EntitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); Auditing = auditing ?? throw new ArgumentNullException(nameof(auditing)); - SecurePush = securePush; - SecurePushForCoOwners = securePushForCoOwners; if (diagnostics == null) { @@ -74,8 +67,7 @@ public virtual IEnumerable UserSubscriptions { get { - yield return SecurePush; - yield return SecurePushForCoOwners; + return new List(); } } @@ -253,7 +245,7 @@ public Task SubscribeAsync(User user, string subscriptionName) /// Subscribe a user to one or more security policies. /// /// True if user was subscribed, false if not (i.e., was already subscribed). - public async Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscription) + public async Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscription, bool commitChanges = true) { if (user == null) { @@ -282,7 +274,10 @@ public async Task SubscribeAsync(User user, IUserSecurityPolicySubscriptio await Auditing.SaveAuditRecordAsync( new UserAuditRecord(user, AuditedUserAction.SubscribeToPolicies, subscription.Policies)); - await EntitiesContext.SaveChangesAsync(); + if (commitChanges) + { + await EntitiesContext.SaveChangesAsync(); + } Diagnostics.Information($"User is now subscribed to '{subscription.SubscriptionName}'."); @@ -361,7 +356,6 @@ private static IEnumerable FindPolicies(User user, IUserSecu /// private static IEnumerable CreateUserHandlers() { - yield return new RequireMinClientVersionForPushPolicy(); yield return new RequirePackageVerifyScopePolicy(); yield return new RequireMinProtocolVersionForPushPolicy(); yield return new RequireOrganizationTenantPolicy(); diff --git a/src/NuGetGallery/Services/AsynchronousPackageValidationInitiator.cs b/src/NuGetGallery/Services/AsynchronousPackageValidationInitiator.cs index 7f2b6eeb49..e65f5f5e43 100644 --- a/src/NuGetGallery/Services/AsynchronousPackageValidationInitiator.cs +++ b/src/NuGetGallery/Services/AsynchronousPackageValidationInitiator.cs @@ -51,7 +51,9 @@ public async Task StartValidationAsync(Package package) $"{data.PackageId} {data.PackageVersion} ({data.ValidationTrackingId})"; using (_diagnosticsSource.Activity(activityName)) { - await _enqueuer.StartValidationAsync(data); + var postponeProcessingTill = DateTimeOffset.UtcNow + _appConfiguration.AsynchronousPackageValidationDelay; + + await _enqueuer.StartValidationAsync(data, postponeProcessingTill); } if (_appConfiguration.BlockingAsynchronousPackageValidationEnabled) diff --git a/src/NuGetGallery/Services/ContentObjectService.cs b/src/NuGetGallery/Services/ContentObjectService.cs index 6fb5605098..5e60bf29d6 100644 --- a/src/NuGetGallery/Services/ContentObjectService.cs +++ b/src/NuGetGallery/Services/ContentObjectService.cs @@ -18,9 +18,7 @@ public ContentObjectService(IContentService contentService) { _contentService = contentService; - LoginDiscontinuationConfiguration = - new LoginDiscontinuationConfiguration( - Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty()); + LoginDiscontinuationConfiguration = new LoginDiscontinuationConfiguration(); } public ILoginDiscontinuationConfiguration LoginDiscontinuationConfiguration { get; set; } diff --git a/src/NuGetGallery/Services/FileSystemFileStorageService.cs b/src/NuGetGallery/Services/FileSystemFileStorageService.cs index 3b694a942c..51f68f1881 100644 --- a/src/NuGetGallery/Services/FileSystemFileStorageService.cs +++ b/src/NuGetGallery/Services/FileSystemFileStorageService.cs @@ -4,6 +4,7 @@ using System; using System.Globalization; using System.IO; +using System.Security.Cryptography; using System.Threading.Tasks; using System.Web.Hosting; using System.Web.Mvc; @@ -168,6 +169,50 @@ public Task SaveFileAsync(string folderName, string fileName, Stream packageFile return Task.FromResult(0); } + public Task CopyFileAsync( + string srcFolderName, + string srcFileName, + string destFolderName, + string destFileName, + IAccessCondition destAccessCondition) + { + if (srcFolderName == null) + { + throw new ArgumentNullException(nameof(srcFolderName)); + } + + if (srcFileName == null) + { + throw new ArgumentNullException(nameof(srcFileName)); + } + + if (destFolderName == null) + { + throw new ArgumentNullException(nameof(destFolderName)); + } + + if (destFileName == null) + { + throw new ArgumentNullException(nameof(destFileName)); + } + + var srcFilePath = BuildPath(_configuration.FileStorageDirectory, srcFolderName, srcFileName); + var destFilePath = BuildPath(_configuration.FileStorageDirectory, destFolderName, destFileName); + + _fileSystemService.CreateDirectory(Path.GetDirectoryName(destFilePath)); + + try + { + _fileSystemService.Copy(srcFilePath, destFilePath, overwrite: false); + } + catch (IOException e) + { + throw new InvalidOperationException("Could not copy because destination file already exists", e); + } + + return Task.FromResult(null); + } + public Task IsAvailableAsync() { return Task.FromResult(Directory.Exists(_configuration.FileStorageDirectory)); diff --git a/src/NuGetGallery/Services/FileSystemService.cs b/src/NuGetGallery/Services/FileSystemService.cs index 6a70691126..aaa4bd883c 100644 --- a/src/NuGetGallery/Services/FileSystemService.cs +++ b/src/NuGetGallery/Services/FileSystemService.cs @@ -51,5 +51,10 @@ public IFileReference GetFileReference(string path) var info = new FileInfo(path); return info.Exists ? new LocalFileReference(info) : null; } + + public virtual void Copy(string sourceFileName, string destFileName, bool overwrite) + { + File.Copy(sourceFileName, destFileName, overwrite); + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/IFileSystemService.cs b/src/NuGetGallery/Services/IFileSystemService.cs index 59ced9a68e..723a031530 100644 --- a/src/NuGetGallery/Services/IFileSystemService.cs +++ b/src/NuGetGallery/Services/IFileSystemService.cs @@ -16,5 +16,7 @@ public interface IFileSystemService DateTimeOffset GetCreationTimeUtc(string path); IFileReference GetFileReference(string path); + + void Copy(string sourceFileName, string destFileName, bool overwrite); } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/IMessageService.cs b/src/NuGetGallery/Services/IMessageService.cs index db5469a339..fd310dfc54 100644 --- a/src/NuGetGallery/Services/IMessageService.cs +++ b/src/NuGetGallery/Services/IMessageService.cs @@ -1,8 +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 NuGetGallery.Services; +using System.Collections.Generic; using System.Net.Mail; +using NuGetGallery.Services; namespace NuGetGallery { @@ -27,5 +28,6 @@ public interface IMessageService void SendPackageUploadedNotice(Package package, string packageUrl, string packageSupportUrl, string emailSettingsUrl); void SendAccountDeleteNotice(MailAddress mailAddress, string userName); void SendPackageDeletedNotice(Package package, string packageUrl, string packageSupportUrl); + void SendSigninAssistanceEmail(MailAddress emailAddress, IEnumerable credentials); } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/IPackageFileService.cs b/src/NuGetGallery/Services/IPackageFileService.cs index 8104e1bbfc..4cc8e7b9cb 100644 --- a/src/NuGetGallery/Services/IPackageFileService.cs +++ b/src/NuGetGallery/Services/IPackageFileService.cs @@ -20,11 +20,6 @@ public interface IPackageFileService : ICorePackageFileService /// Task CreateDownloadPackageActionResultAsync(Uri requestUrl, string unsafeId, string unsafeVersion); - /// - /// Copies the contents of the package represented by the stream into the file storage backup location. - /// - Task StorePackageFileInBackupLocationAsync(Package package, Stream packageFile); - /// /// Deletes the package readme.md file from storage. /// diff --git a/src/NuGetGallery/Services/ITelemetryClient.cs b/src/NuGetGallery/Services/ITelemetryClient.cs index f0f481f997..99c7ff37f7 100644 --- a/src/NuGetGallery/Services/ITelemetryClient.cs +++ b/src/NuGetGallery/Services/ITelemetryClient.cs @@ -11,8 +11,6 @@ namespace NuGetGallery /// public interface ITelemetryClient { - void TrackEvent(string eventName, IDictionary properties = null, IDictionary metrics = null); - void TrackMetric(string metricName, double value, IDictionary properties = null); void TrackException(Exception exception, IDictionary properties = null, IDictionary metrics = null); diff --git a/src/NuGetGallery/Services/ITelemetryService.cs b/src/NuGetGallery/Services/ITelemetryService.cs index a094f2844a..0031abeac9 100644 --- a/src/NuGetGallery/Services/ITelemetryService.cs +++ b/src/NuGetGallery/Services/ITelemetryService.cs @@ -13,6 +13,18 @@ public interface ITelemetryService void TrackPackagePushEvent(Package package, User user, IIdentity identity); + void TrackPackageUnlisted(Package package); + + void TrackPackageListed(Package package); + + void TrackPackageDelete(Package package, bool isHardDelete); + + void TrackPackageReflow(Package package); + + void TrackPackageHardDeleteReflow(string packageId, string packageVersion); + + void TrackPackageRevalidate(Package package); + void TrackPackageReadMeChangeEvent(Package package, string readMeSourceType, PackageEditReadMeState readMeState); void TrackCreatePackageVerificationKeyEvent(string packageId, string packageVersion, User user, IIdentity identity); diff --git a/src/NuGetGallery/Services/IUserService.cs b/src/NuGetGallery/Services/IUserService.cs index d53fe8111f..260caab607 100644 --- a/src/NuGetGallery/Services/IUserService.cs +++ b/src/NuGetGallery/Services/IUserService.cs @@ -41,5 +41,9 @@ public interface IUserService Task RequestTransformToOrganizationAccount(User accountToTransform, User adminUser); Task TransformUserToOrganization(User accountToTransform, User adminUser, string token); + + Task TransferApiKeysScopedToUser(User userWithKeys, User userToOwnKeys); + + Task AddOrganizationAsync(string username, string emailAddress, User adminUser); } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs b/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs index e5c4a8bd23..6b373651dd 100644 --- a/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs +++ b/src/NuGetGallery/Services/LoginDiscontinuationConfiguration.cs @@ -11,12 +11,16 @@ namespace NuGetGallery { public class LoginDiscontinuationConfiguration : ILoginDiscontinuationConfiguration { - public HashSet DiscontinuedForEmailAddresses { get; } - public HashSet DiscontinuedForDomains { get; } - public HashSet ExceptionsForEmailAddresses { get; } + internal HashSet DiscontinuedForEmailAddresses { get; } + internal HashSet DiscontinuedForDomains { get; } + internal HashSet ExceptionsForEmailAddresses { get; } + internal HashSet ForceTransformationToOrganizationForEmailAddresses { get; } public LoginDiscontinuationConfiguration() - : this(Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty()) + : this(Enumerable.Empty(), + Enumerable.Empty(), + Enumerable.Empty(), + Enumerable.Empty()) { } @@ -24,15 +28,22 @@ public LoginDiscontinuationConfiguration() public LoginDiscontinuationConfiguration( IEnumerable discontinuedForEmailAddresses, IEnumerable discontinuedForDomains, - IEnumerable exceptionsForEmailAddresses) + IEnumerable exceptionsForEmailAddresses, + IEnumerable forceTransformationToOrganizationForEmailAddresses) { DiscontinuedForEmailAddresses = new HashSet(discontinuedForEmailAddresses); DiscontinuedForDomains = new HashSet(discontinuedForDomains); ExceptionsForEmailAddresses = new HashSet(exceptionsForEmailAddresses); + ForceTransformationToOrganizationForEmailAddresses = new HashSet(forceTransformationToOrganizationForEmailAddresses); } public bool IsLoginDiscontinued(AuthenticatedUser authUser) { + if (authUser == null || authUser.User == null) + { + return false; + } + var email = authUser.User.ToMailAddress(); return authUser.CredentialUsed.IsPassword() && @@ -42,16 +53,33 @@ public bool IsLoginDiscontinued(AuthenticatedUser authUser) public bool AreOrganizationsSupportedForUser(User user) { + if (user == null) + { + return false; + } + var email = user.ToMailAddress(); return DiscontinuedForDomains.Contains(email.Host, StringComparer.OrdinalIgnoreCase) || DiscontinuedForEmailAddresses.Contains(email.Address); } + + public bool ShouldUserTransformIntoOrganization(User user) + { + if (user == null) + { + return false; + } + + var email = user.ToMailAddress(); + return ForceTransformationToOrganizationForEmailAddresses.Contains(email.Address); + } } public interface ILoginDiscontinuationConfiguration { bool IsLoginDiscontinued(AuthenticatedUser authUser); bool AreOrganizationsSupportedForUser(User user); + bool ShouldUserTransformIntoOrganization(User user); } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/MessageService.cs b/src/NuGetGallery/Services/MessageService.cs index 0caa2dddce..38c44b53a8 100644 --- a/src/NuGetGallery/Services/MessageService.cs +++ b/src/NuGetGallery/Services/MessageService.cs @@ -2,6 +2,7 @@ // 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.Globalization; using System.Linq; using System.Net.Mail; @@ -202,6 +203,48 @@ We can't wait to see what packages you'll upload. } } + public void SendSigninAssistanceEmail(MailAddress emailAddress, IEnumerable credentials) + { + string body = @"Hi there, + +We heard you were looking for Microsoft logins associated with your account on {0}. + +{1} + +Thanks, + +The {0} Team"; + + string msaIdentity; + if (credentials.Any()) + { + var identities = string.Join("; ", credentials.Select(cred => cred.Identity).ToArray()); + msaIdentity = string.Format(@"Our records indicate the associated Microsoft login(s): {0}.", identities); + } + else + { + msaIdentity = "No associated Microsoft logins were found."; + } + + body = String.Format( + CultureInfo.CurrentCulture, + body, + Config.GalleryOwner.DisplayName, + msaIdentity); + + using (var mailMessage = new MailMessage()) + { + mailMessage.Subject = String.Format( + CultureInfo.CurrentCulture, "[{0}] Sign-In Assistance.", Config.GalleryOwner.DisplayName); + mailMessage.Body = body; + mailMessage.From = Config.GalleryNoReplyAddress; + + mailMessage.To.Add(emailAddress); + SendMessage(mailMessage); + } + + } + public void SendEmailChangeConfirmationNotice(MailAddress newEmailAddress, string confirmationUrl) { string body = @"You recently changed your {0} email address. diff --git a/src/NuGetGallery/Services/PackageDeleteService.cs b/src/NuGetGallery/Services/PackageDeleteService.cs index da46b9e722..a0a516730d 100644 --- a/src/NuGetGallery/Services/PackageDeleteService.cs +++ b/src/NuGetGallery/Services/PackageDeleteService.cs @@ -270,6 +270,8 @@ await _packageService.UpdatePackageStatusAsync( packageDelete.Packages.Add(package); await _auditingService.SaveAuditRecordAsync(CreateAuditRecord(package, package.PackageRegistration, AuditedPackageAction.SoftDelete, reason)); + + _telemetryService.TrackPackageDelete(package, isHardDelete: false); } _packageDeletesRepository.InsertOnCommit(packageDelete); @@ -314,6 +316,8 @@ await ExecuteSqlCommandAsync(_entitiesContext.GetDatabase(), await _auditingService.SaveAuditRecordAsync(CreateAuditRecord(package, package.PackageRegistration, AuditedPackageAction.Delete, reason)); + _telemetryService.TrackPackageDelete(package, isHardDelete: true); + package.PackageRegistration.Packages.Remove(package); _packageRepository.DeleteOnCommit(package); } @@ -338,7 +342,7 @@ await ExecuteSqlCommandAsync(_entitiesContext.GetDatabase(), UpdateSearchIndex(); } - public Task ReflowHardDeletedPackageAsync(string id, string version) + public async Task ReflowHardDeletedPackageAsync(string id, string version) { if (string.IsNullOrEmpty(id)) { @@ -381,7 +385,10 @@ public Task ReflowHardDeletedPackageAsync(string id, string version) registrationRecord: null, action: AuditedPackageAction.Delete, reason: "reflow hard-deleted package"); - return _auditingService.SaveAuditRecordAsync(auditRecord); + + await _auditingService.SaveAuditRecordAsync(auditRecord); + + _telemetryService.TrackPackageHardDeleteReflow(normalizedId, normalizedVersionString); } protected virtual async Task ExecuteSqlCommandAsync(IDatabase database, string sql, params object[] parameters) diff --git a/src/NuGetGallery/Services/PackageFileService.cs b/src/NuGetGallery/Services/PackageFileService.cs index d77de4b8f4..f32c64ce24 100644 --- a/src/NuGetGallery/Services/PackageFileService.cs +++ b/src/NuGetGallery/Services/PackageFileService.cs @@ -39,30 +39,6 @@ public Task CreateDownloadPackageActionResultAsync(Uri requestUrl, return _fileStorageService.CreateDownloadFileActionResultAsync(requestUrl, CoreConstants.PackagesFolderName, fileName); } - public Task StorePackageFileInBackupLocationAsync(Package package, Stream packageFile) - { - if (package == null) - { - throw new ArgumentNullException(nameof(package)); - } - - if (packageFile == null) - { - throw new ArgumentNullException(nameof(packageFile)); - } - - if (package.PackageRegistration == null || - string.IsNullOrWhiteSpace(package.PackageRegistration.Id) || - (string.IsNullOrWhiteSpace(package.NormalizedVersion) && string.IsNullOrWhiteSpace(package.Version))) - { - throw new ArgumentException(CoreStrings.PackageIsMissingRequiredData, nameof(package)); - } - - var fileName = BuildBackupFileName(package.PackageRegistration.Id, string.IsNullOrEmpty(package.NormalizedVersion) - ? NuGetVersion.Parse(package.Version).ToNormalizedString() : package.NormalizedVersion, package.Hash); - return _fileStorageService.SaveFileAsync(CoreConstants.PackageBackupsFolderName, fileName, packageFile); - } - /// /// Deletes the package readme.md file from storage. /// @@ -126,33 +102,5 @@ public async Task DownloadReadMeMdFileAsync(Package package) return null; } - - private static string BuildBackupFileName(string id, string version, string hash) - { - if (id == null) - { - throw new ArgumentNullException(nameof(id)); - } - - if (version == null) - { - throw new ArgumentNullException(nameof(version)); - } - - if (hash == null) - { - throw new ArgumentNullException(nameof(hash)); - } - - var hashBytes = Convert.FromBase64String(hash); - - return string.Format( - CultureInfo.InvariantCulture, - Constants.PackageFileBackupSavePathTemplate, - id, - version, - HttpServerUtility.UrlTokenEncode(hashBytes), - CoreConstants.NuGetPackageFileExtension); - } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/PackageService.cs b/src/NuGetGallery/Services/PackageService.cs index 4b992a452a..08620ad8b8 100644 --- a/src/NuGetGallery/Services/PackageService.cs +++ b/src/NuGetGallery/Services/PackageService.cs @@ -20,16 +20,19 @@ public class PackageService : CorePackageService, IPackageService private readonly IEntityRepository _packageRegistrationRepository; private readonly IPackageNamingConflictValidator _packageNamingConflictValidator; private readonly IAuditingService _auditingService; + private readonly ITelemetryService _telemetryService; public PackageService( IEntityRepository packageRegistrationRepository, IEntityRepository packageRepository, IPackageNamingConflictValidator packageNamingConflictValidator, - IAuditingService auditingService) : base(packageRepository) + IAuditingService auditingService, + ITelemetryService telemetryService) : base(packageRepository) { _packageRegistrationRepository = packageRegistrationRepository ?? throw new ArgumentNullException(nameof(packageRegistrationRepository)); _packageNamingConflictValidator = packageNamingConflictValidator ?? throw new ArgumentNullException(nameof(packageNamingConflictValidator)); _auditingService = auditingService ?? throw new ArgumentNullException(nameof(auditingService)); + _telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); } /// @@ -45,7 +48,9 @@ public void EnsureValid(PackageArchiveReader packageArchiveReader) { try { - var packageMetadata = PackageMetadata.FromNuspecReader(packageArchiveReader.GetNuspecReader()); + var packageMetadata = PackageMetadata.FromNuspecReader( + packageArchiveReader.GetNuspecReader(), + strict: true); ValidateNuGetPackageMetadata(packageMetadata); @@ -82,7 +87,9 @@ public async Task CreatePackageAsync(PackageArchiveReader nugetPackage, try { - packageMetadata = PackageMetadata.FromNuspecReader(nugetPackage.GetNuspecReader()); + packageMetadata = PackageMetadata.FromNuspecReader( + nugetPackage.GetNuspecReader(), + strict: true); ValidateNuGetPackageMetadata(packageMetadata); @@ -223,16 +230,47 @@ public IEnumerable FindPackagesByOwner(User user, bool includeUnlisted, /// /// Find packages by owner, including organization owners that the user belongs to. /// - public IEnumerable FindPackagesByAnyMatchingOwner(User user, bool includeUnlisted, bool includeVersions = false) + public IEnumerable FindPackagesByAnyMatchingOwner( + User user, + bool includeUnlisted, + bool includeVersions = false) { var ownerKeys = user.Organizations.Select(org => org.OrganizationKey).ToList(); ownerKeys.Insert(0, user.Key); - var packages = GetPackagesForOwners(ownerKeys, includeUnlisted); + IQueryable packages = _packageRepository.GetAll() + .Where(p => p.PackageRegistration.Owners.Any(o => ownerKeys.Contains(o.Key))); - return includeVersions - ? packages - : GetLatestPackageForEachRegistration(packages.ToList()); + if (!includeUnlisted) + { + packages = packages.Where(p => p.Listed); + } + + if (includeVersions) + { + return packages + .Include(p => p.PackageRegistration) + .Include(p => p.PackageRegistration.Owners) + .ToList(); + } + + // Do a best effort of retrieving the latest version. Note that UpdateIsLatest has had concurrency issues + // where sometimes packages no rows with IsLatest set. In this case, we'll just select the last inserted + // row (descending [Key]) as opposed to reading all rows into memory and sorting on NuGetVersion. + return packages + .GroupBy(p => p.PackageRegistrationKey) + .Select(g => g + // order booleans desc so that true (1) comes first + .OrderByDescending(p => p.IsLatestStableSemVer2) + .ThenByDescending(p => p.IsLatestStable) + .ThenByDescending(p => p.IsLatestSemVer2) + .ThenByDescending(p => p.IsLatest) + .ThenByDescending(p => p.Listed) + .ThenByDescending(p => p.Key) + .FirstOrDefault()) + .Include(p => p.PackageRegistration) + .Include(p => p.PackageRegistration.Owners) + .ToList(); } private IEnumerable GetLatestPackageForEachRegistration(IReadOnlyCollection packages) @@ -412,6 +450,8 @@ public async Task MarkPackageListedAsync(Package package, bool commitChanges = t await _auditingService.SaveAuditRecordAsync(new PackageAuditRecord(package, AuditedPackageAction.List)); + _telemetryService.TrackPackageListed(package); + if (commitChanges) { await _packageRepository.CommitChangesAsync(); @@ -441,6 +481,8 @@ public async Task MarkPackageUnlistedAsync(Package package, bool commitChanges = await _auditingService.SaveAuditRecordAsync(new PackageAuditRecord(package, AuditedPackageAction.Unlist)); + _telemetryService.TrackPackageUnlisted(package); + if (commitChanges) { await _packageRepository.CommitChangesAsync(); diff --git a/src/NuGetGallery/Services/ReflowPackageService.cs b/src/NuGetGallery/Services/ReflowPackageService.cs index 87c3113aab..db75d65179 100644 --- a/src/NuGetGallery/Services/ReflowPackageService.cs +++ b/src/NuGetGallery/Services/ReflowPackageService.cs @@ -14,15 +14,18 @@ public class ReflowPackageService private readonly IEntitiesContext _entitiesContext; private readonly IPackageService _packageService; private readonly IPackageFileService _packageFileService; + private readonly ITelemetryService _telemetryService; public ReflowPackageService( IEntitiesContext entitiesContext, IPackageService packageService, - IPackageFileService packageFileService) + IPackageFileService packageFileService, + ITelemetryService telemetryService) { - _entitiesContext = entitiesContext; - _packageService = packageService; - _packageFileService = packageFileService; + _entitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); + _packageService = packageService ?? throw new ArgumentNullException(nameof(packageService)); + _packageFileService = packageFileService ?? throw new ArgumentNullException(nameof(packageFileService)); + _telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); } public async Task ReflowAsync(string id, string version) @@ -50,7 +53,9 @@ public async Task ReflowAsync(string id, string version) Size = packageStream.Length, }; - var packageMetadata = PackageMetadata.FromNuspecReader(packageArchive.GetNuspecReader()); + var packageMetadata = PackageMetadata.FromNuspecReader( + packageArchive.GetNuspecReader(), + strict: false); // 3) Clear referenced objects that will be reflowed ClearSupportedFrameworks(package); @@ -73,7 +78,10 @@ public async Task ReflowAsync(string id, string version) // 5) Update IsLatest so that reflow can correct concurrent updates (see Gallery #2514) await _packageService.UpdateIsLatestAsync(package.PackageRegistration, commitChanges: false); - // 6) Save and profit + // 6) Emit telemetry. + _telemetryService.TrackPackageReflow(package); + + // 7) Save and profit await _entitiesContext.SaveChangesAsync(); } } diff --git a/src/NuGetGallery/Services/TelemetryClientWrapper.cs b/src/NuGetGallery/Services/TelemetryClientWrapper.cs index e5266d42e4..d5d7f1fdbe 100644 --- a/src/NuGetGallery/Services/TelemetryClientWrapper.cs +++ b/src/NuGetGallery/Services/TelemetryClientWrapper.cs @@ -29,18 +29,6 @@ private TelemetryClientWrapper() internal TelemetryClient UnderlyingClient { get; } - public void TrackEvent(string eventName, IDictionary properties = null, IDictionary metrics = null) - { - try - { - UnderlyingClient.TrackEvent(eventName, properties, metrics); - } - catch - { - // logging failed, don't allow exception to escape - } - } - public void TrackException(Exception exception, IDictionary properties = null, IDictionary metrics = null) { try diff --git a/src/NuGetGallery/Services/TelemetryService.cs b/src/NuGetGallery/Services/TelemetryService.cs index 66298333c2..154a181cb3 100644 --- a/src/NuGetGallery/Services/TelemetryService.cs +++ b/src/NuGetGallery/Services/TelemetryService.cs @@ -24,6 +24,12 @@ internal class Events public const string CredentialAdded = "CredentialAdded"; public const string UserPackageDeleteCheckedAfterHours = "UserPackageDeleteCheckedAfterHours"; public const string UserPackageDeleteExecuted = "UserPackageDeleteExecuted"; + public const string PackageReflow = "PackageReflow"; + public const string PackageUnlisted = "PackageUnlisted"; + public const string PackageListed = "PackageListed"; + public const string PackageDelete = "PackageDelete"; + public const string PackageHardDeleteReflow = "PackageHardDeleteReflow"; + public const string PackageRevalidate = "PackageRevalidate"; } private IDiagnosticsSource _diagnosticsSource; @@ -71,6 +77,9 @@ internal class Events // User package delete executed properties public const string Success = "Success"; + // Package delete properties + public const string IsHardDelete = "IsHardDelete"; + public TelemetryService(IDiagnosticsService diagnosticsService, ITelemetryClient telemetryClient = null) { if (diagnosticsService == null) @@ -100,7 +109,7 @@ public void TraceException(Exception exception) public void TrackODataQueryFilterEvent(string callContext, bool isEnabled, bool isAllowed, string queryPattern) { - TrackEvent(Events.ODataQueryFilter, properties => + TrackMetric(Events.ODataQueryFilter, 1, properties => { properties.Add(CallContext, callContext); properties.Add(IsEnabled, $"{isEnabled}"); @@ -122,9 +131,9 @@ public void TrackPackageReadMeChangeEvent(Package package, string readMeSourceTy throw new ArgumentNullException(nameof(readMeSourceType)); } - TrackEvent(Events.PackageReadMeChanged, properties => { + TrackMetric(Events.PackageReadMeChanged, 1, properties => { properties.Add(PackageId, package.PackageRegistration.Id); - properties.Add(PackageVersion, package.Version); + properties.Add(PackageVersion, package.NormalizedVersion); properties.Add(ReadMeSourceType, readMeSourceType); properties.Add(ReadMeState, Enum.GetName(typeof(PackageEditReadMeState), readMeState)); }); @@ -137,17 +146,17 @@ public void TrackPackagePushEvent(Package package, User user, IIdentity identity throw new ArgumentNullException(nameof(package)); } - TrackPackageForEvent(Events.PackagePush, package.PackageRegistration.Id, package.Version, user, identity); + TrackMetricForPackage(Events.PackagePush, package.PackageRegistration.Id, package.NormalizedVersion, user, identity); } public void TrackPackagePushNamespaceConflictEvent(string packageId, string packageVersion, User user, IIdentity identity) { - TrackPackageForEvent(Events.PackagePushNamespaceConflict, packageId, packageVersion, user, identity); + TrackMetricForPackage(Events.PackagePushNamespaceConflict, packageId, packageVersion, user, identity); } public void TrackCreatePackageVerificationKeyEvent(string packageId, string packageVersion, User user, IIdentity identity) { - TrackPackageForEvent(Events.CreatePackageVerificationKey, packageId, packageVersion, user, identity); + TrackMetricForPackage(Events.CreatePackageVerificationKey, packageId, packageVersion, user, identity); } public void TrackVerifyPackageKeyEvent(string packageId, string packageVersion, User user, IIdentity identity, int statusCode) @@ -163,7 +172,7 @@ public void TrackVerifyPackageKeyEvent(string packageId, string packageVersion, } var hasVerifyScope = identity.HasScopeThatAllowsActions(NuGetScopes.PackageVerify).ToString(); - TrackEvent(Events.VerifyPackageKey, properties => + TrackMetric(Events.VerifyPackageKey, 1, properties => { properties.Add(PackageId, packageId); properties.Add(PackageVersion, packageVersion); @@ -175,15 +184,78 @@ public void TrackVerifyPackageKeyEvent(string packageId, string packageVersion, public void TrackNewUserRegistrationEvent(User user, Credential credential) { - TrackAccountActivityForEvent(Events.NewUserRegistration, user, credential); + TrackMetricForAccountActivity(Events.NewUserRegistration, user, credential); } public void TrackNewCredentialCreated(User user, Credential credential) { - TrackAccountActivityForEvent(Events.CredentialAdded, user, credential); + TrackMetricForAccountActivity(Events.CredentialAdded, user, credential); } - private void TrackAccountActivityForEvent(string eventName, User user, Credential credential) + public void TrackUserPackageDeleteExecuted(int packageKey, string packageId, string packageVersion, ReportPackageReason reason, bool success) + { + if (packageId == null) + { + throw new ArgumentNullException(nameof(packageId)); + } + + if (packageVersion == null) + { + throw new ArgumentNullException(nameof(packageVersion)); + } + + TrackMetric(Events.UserPackageDeleteExecuted, 1, properties => { + properties.Add(PackageKey, packageKey.ToString()); + properties.Add(PackageId, packageId); + properties.Add(PackageVersion, packageVersion); + properties.Add(ReportPackageReason, reason.ToString()); + properties.Add(Success, success.ToString()); + }); + } + + public void TrackPackageUnlisted(Package package) + { + TrackMetricForPackage(Events.PackageUnlisted, package); + } + + public void TrackPackageListed(Package package) + { + TrackMetricForPackage(Events.PackageListed, package); + } + + public void TrackPackageDelete(Package package, bool isHardDelete) + { + TrackMetricForPackage(Events.PackageDelete, package, properties => + { + properties.Add(IsHardDelete, isHardDelete.ToString()); + }); + } + + public void TrackPackageReflow(Package package) + { + TrackMetricForPackage(Events.PackageReflow, package); + } + + public void TrackPackageHardDeleteReflow(string packageId, string packageVersion) + { + TrackMetricForPackage(Events.PackageHardDeleteReflow, packageId, packageVersion); + } + + public void TrackPackageRevalidate(Package package) + { + TrackMetricForPackage(Events.PackageRevalidate, package); + } + + public void TrackException(Exception exception, Action> addProperties) + { + var telemetryProperties = new Dictionary(); + + addProperties(telemetryProperties); + + _telemetryClient.TrackException(exception, telemetryProperties, metrics: null); + } + + private void TrackMetricForAccountActivity(string eventName, User user, Credential credential) { if (user == null) { @@ -195,7 +267,7 @@ private void TrackAccountActivityForEvent(string eventName, User user, Credentia throw new ArgumentNullException(nameof(credential)); } - TrackEvent(eventName, properties => { + TrackMetric(eventName, 1, properties => { properties.Add(ClientVersion, GetClientVersion()); properties.Add(ProtocolVersion, GetProtocolVersion()); properties.Add(AccountCreationDate, GetAccountCreationDate(user)); @@ -240,7 +312,34 @@ private static string GetApiKeyCreationDate(User user, IIdentity identity) return apiKey?.Created.ToString("O") ?? "N/A"; } - private void TrackPackageForEvent(string eventValue, string packageId, string packageVersion, User user, IIdentity identity) + private void TrackMetricForPackage( + string metricName, + Package package, + User user, + IIdentity identity, + Action> addProperties = null) + { + if (package == null) + { + throw new ArgumentNullException(nameof(package)); + } + + TrackMetricForPackage( + metricName, + package.PackageRegistration.Id, + package.NormalizedVersion, + user, + identity, + addProperties); + } + + private void TrackMetricForPackage( + string metricName, + string packageId, + string packageVersion, + User user, + IIdentity identity, + Action> addProperties = null) { if (user == null) { @@ -252,7 +351,7 @@ private void TrackPackageForEvent(string eventValue, string packageId, string pa throw new ArgumentNullException(nameof(identity)); } - TrackEvent(eventValue, properties => { + TrackMetric(metricName, 1, properties => { properties.Add(ClientVersion, GetClientVersion()); properties.Add(ProtocolVersion, GetProtocolVersion()); properties.Add(ClientInformation, GetClientInformation()); @@ -262,6 +361,40 @@ private void TrackPackageForEvent(string eventValue, string packageId, string pa properties.Add(AuthenticationMethod, identity.GetAuthenticationType()); properties.Add(KeyCreationDate, GetApiKeyCreationDate(user, identity)); properties.Add(IsScoped, identity.IsScopedAuthentication().ToString()); + addProperties?.Invoke(properties); + }); + } + + private void TrackMetricForPackage( + string metricName, + Package package, + Action> addProperties = null) + { + if (package == null) + { + throw new ArgumentNullException(nameof(package)); + } + + TrackMetricForPackage( + metricName, + package.PackageRegistration.Id, + package.NormalizedVersion, + addProperties); + } + + private void TrackMetricForPackage( + string metricName, + string packageId, + string packageVersion, + Action> addProperties = null) + { + TrackMetric(metricName, 1, properties => { + properties.Add(ClientVersion, GetClientVersion()); + properties.Add(ProtocolVersion, GetProtocolVersion()); + properties.Add(ClientInformation, GetClientInformation()); + properties.Add(PackageId, packageId); + properties.Add(PackageVersion, packageVersion); + addProperties?.Invoke(properties); }); } @@ -288,36 +421,11 @@ public void TrackUserPackageDeleteChecked(UserPackageDeleteEvent details, UserPa }); } - public void TrackUserPackageDeleteExecuted(int packageKey, string packageId, string packageVersion, ReportPackageReason reason, bool success) - { - if (packageId == null) - { - throw new ArgumentNullException(nameof(packageId)); - } - - if (packageVersion == null) - { - throw new ArgumentNullException(nameof(packageVersion)); - } - - TrackMetric(Events.UserPackageDeleteExecuted, 1, properties => { - properties.Add(PackageKey, packageKey.ToString()); - properties.Add(PackageId, packageId); - properties.Add(PackageVersion, packageVersion); - properties.Add(ReportPackageReason, reason.ToString()); - properties.Add(Success, success.ToString()); - }); - } - - protected virtual void TrackEvent(string eventName, Action> addProperties) - { - var telemetryProperties = new Dictionary(); - - addProperties(telemetryProperties); - - _telemetryClient.TrackEvent(eventName, telemetryProperties, metrics: null); - } - + /// + /// We use instead of + /// + /// because events don't flow properly into our internal metrics and monitoring solution. + /// protected virtual void TrackMetric(string metricName, double value, Action> addProperties) { var telemetryProperties = new Dictionary(); @@ -326,14 +434,5 @@ protected virtual void TrackMetric(string metricName, double value, Action> addProperties) - { - var telemetryProperties = new Dictionary(); - - addProperties(telemetryProperties); - - _telemetryClient.TrackException(exception, telemetryProperties, metrics: null); - } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/UserService.cs b/src/NuGetGallery/Services/UserService.cs index 97a024afe1..20f6fa67a8 100644 --- a/src/NuGetGallery/Services/UserService.cs +++ b/src/NuGetGallery/Services/UserService.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using NuGetGallery.Auditing; using NuGetGallery.Configuration; +using NuGetGallery.Infrastructure.Authentication; using NuGetGallery.Security; using Crypto = NuGetGallery.CryptographyService; @@ -23,32 +24,42 @@ public class UserService : IUserService public IEntityRepository CredentialRepository { get; protected set; } + public IEntityRepository OrganizationRepository { get; protected set; } + public IAuditingService Auditing { get; protected set; } public IEntitiesContext EntitiesContext { get; protected set; } + public IContentObjectService ContentObjectService { get; protected set; } public ISecurityPolicyService SecurityPolicyService { get; set; } + public IDateTimeProvider DateTimeProvider { get; protected set; } + protected UserService() { } public UserService( IAppConfiguration config, IEntityRepository userRepository, IEntityRepository credentialRepository, + IEntityRepository organizationRepository, IAuditingService auditing, IEntitiesContext entitiesContext, IContentObjectService contentObjectService, - ISecurityPolicyService securityPolicyService) + ISecurityPolicyService securityPolicyService, + IDateTimeProvider dateTimeProvider, + ICredentialBuilder credentialBuilder) : this() { Config = config; UserRepository = userRepository; CredentialRepository = credentialRepository; + OrganizationRepository = organizationRepository; Auditing = auditing; EntitiesContext = entitiesContext; ContentObjectService = contentObjectService; SecurityPolicyService = securityPolicyService; + DateTimeProvider = dateTimeProvider; } public async Task AddMemberAsync(Organization organization, string memberName, bool isAdmin) @@ -314,7 +325,7 @@ public bool CanTransformUserToOrganization(User accountToTransform, out string e else if (!ContentObjectService.LoginDiscontinuationConfiguration.AreOrganizationsSupportedForUser(accountToTransform)) { errorReason = String.Format(CultureInfo.CurrentCulture, - Strings.TransformAccount_FailedReasonNotInDomainWhitelist, accountToTransform.Username); + Strings.Organizations_NotInDomainWhitelist, accountToTransform.Username); } return errorReason == null; @@ -344,11 +355,11 @@ public bool CanTransformUserToOrganization(User accountToTransform, User adminUs } else { - var tenantId = adminUser.Credentials.GetAzureActiveDirectoryCredential()?.TenantId; + var tenantId = GetAzureActiveDirectoryCredentialTenant(adminUser); if (string.IsNullOrWhiteSpace(tenantId)) { errorReason = String.Format(CultureInfo.CurrentCulture, - Strings.TransformAccount_AdminAccountDoesNotHaveTenant, adminUser.Username); + Strings.Organizations_AdminAccountDoesNotHaveTenant, adminUser.Username); } } @@ -357,19 +368,121 @@ public bool CanTransformUserToOrganization(User accountToTransform, User adminUs public async Task TransformUserToOrganization(User accountToTransform, User adminUser, string token) { - var tenantId = adminUser.Credentials.GetAzureActiveDirectoryCredential()?.TenantId; + if (!await SubscribeOrganizationToTenantPolicy(accountToTransform, adminUser)) + { + return false; + } + + await TransferApiKeysScopedToUser(accountToTransform, adminUser); + + return await EntitiesContext.TransformUserToOrganization(accountToTransform, adminUser, token); + } + + public async Task TransferApiKeysScopedToUser(User userWithKeys, User userToOwnKeys) + { + var eligibleApiKeys = userWithKeys.Credentials + .Where(c => c.IsApiKey() && c.Scopes.All(k => k.Owner == null || k.Owner == userWithKeys)).ToArray(); + foreach (var originalApiKey in eligibleApiKeys) + { + var scopes = originalApiKey.Scopes.Select(s => + new Scope(userWithKeys, s.Subject, s.AllowedAction)); + + var clonedApiKey = new Credential(originalApiKey.Type, originalApiKey.Value) + { + Description = originalApiKey.Description, + ExpirationTicks = originalApiKey.ExpirationTicks, + Expires = originalApiKey.Expires, + Scopes = scopes.ToArray(), + User = userToOwnKeys, + UserKey = userToOwnKeys.Key, + Value = originalApiKey.Value + }; + + userToOwnKeys.Credentials.Add(clonedApiKey); + } + + if (eligibleApiKeys.Any()) + { + await EntitiesContext.SaveChangesAsync(); + } + } + + public async Task AddOrganizationAsync(string username, string emailAddress, User adminUser) + { + if (!ContentObjectService.LoginDiscontinuationConfiguration.AreOrganizationsSupportedForUser(adminUser)) + { + throw new EntityException(String.Format(CultureInfo.CurrentCulture, + Strings.Organizations_NotInDomainWhitelist, adminUser.Username)); + } + + var existingUserWithIdentity = EntitiesContext.Users + .FirstOrDefault(u => u.Username == username || u.EmailAddress == emailAddress); + if (existingUserWithIdentity != null) + { + if (existingUserWithIdentity.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) + { + throw new EntityException(Strings.UsernameNotAvailable, username); + } + + if (string.Equals(existingUserWithIdentity.EmailAddress, emailAddress, StringComparison.OrdinalIgnoreCase)) + { + throw new EntityException(Strings.EmailAddressBeingUsed, emailAddress); + } + } + + var organization = new Organization(username) + { + EmailAllowed = true, + UnconfirmedEmailAddress = emailAddress, + EmailConfirmationToken = Crypto.GenerateToken(), + NotifyPackagePushed = true, + CreatedUtc = DateTimeProvider.UtcNow, + Members = new List() + }; + + var membership = new Membership { Organization = organization, Member = adminUser, IsAdmin = true }; + + organization.Members.Add(membership); + adminUser.Organizations.Add(membership); + + OrganizationRepository.InsertOnCommit(organization); + + if (string.IsNullOrEmpty(GetAzureActiveDirectoryCredentialTenant(adminUser))) + { + throw new EntityException(String.Format(CultureInfo.CurrentCulture, + Strings.Organizations_AdminAccountDoesNotHaveTenant, adminUser.Username)); + } + + if (!await SubscribeOrganizationToTenantPolicy(organization, adminUser, commitChanges: false)) + { + throw new EntityException(Strings.DefaultUserSafeExceptionMessage); + } + + await EntitiesContext.SaveChangesAsync(); + + return organization; + } + + private async Task SubscribeOrganizationToTenantPolicy(User organization, User adminUser, bool commitChanges = true) + { + var tenantId = GetAzureActiveDirectoryCredentialTenant(adminUser); if (string.IsNullOrWhiteSpace(tenantId)) { return false; } var tenantPolicy = RequireOrganizationTenantPolicy.Create(tenantId); - if (!await SecurityPolicyService.SubscribeAsync(accountToTransform, tenantPolicy)) + if (!await SecurityPolicyService.SubscribeAsync(organization, tenantPolicy, commitChanges)) { return false; } - - return await EntitiesContext.TransformUserToOrganization(accountToTransform, adminUser, token); + + return true; + } + + private string GetAzureActiveDirectoryCredentialTenant(User user) + { + return user.Credentials.GetAzureActiveDirectoryCredential()?.TenantId; } } } \ No newline at end of file diff --git a/src/NuGetGallery/Services/ValidationService.cs b/src/NuGetGallery/Services/ValidationService.cs index 1d4bf81a33..8394e62a09 100644 --- a/src/NuGetGallery/Services/ValidationService.cs +++ b/src/NuGetGallery/Services/ValidationService.cs @@ -16,15 +16,18 @@ public class ValidationService : IValidationService private readonly IPackageService _packageService; private readonly IPackageValidationInitiator _initiator; private readonly IEntityRepository _validationSets; + private readonly ITelemetryService _telemetryService; public ValidationService( IPackageService packageService, IPackageValidationInitiator initiator, - IEntityRepository validationSets) + IEntityRepository validationSets, + ITelemetryService telemetryService) { _packageService = packageService ?? throw new ArgumentNullException(nameof(packageService)); _initiator = initiator ?? throw new ArgumentNullException(nameof(initiator)); _validationSets = validationSets ?? throw new ArgumentNullException(nameof(validationSets)); + _telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); } public async Task StartValidationAsync(Package package) @@ -40,6 +43,8 @@ await _packageService.UpdatePackageStatusAsync( public async Task RevalidateAsync(Package package) { await _initiator.StartValidationAsync(package); + + _telemetryService.TrackPackageRevalidate(package); } public IReadOnlyList GetLatestValidationIssues(Package package) diff --git a/src/NuGetGallery/Strings.Designer.cs b/src/NuGetGallery/Strings.Designer.cs index b88108c7b4..42863fb99e 100644 --- a/src/NuGetGallery/Strings.Designer.cs +++ b/src/NuGetGallery/Strings.Designer.cs @@ -721,7 +721,7 @@ public static string Emails_CredentialRemoved_Subject { /// ///[{0}]({0}) /// - ///Note that NuGet.org password login is being deprecated. Please use Microsoft account to sign into {1}. + ///Note that NuGet.org password login is deprecated. Please use Microsoft account to sign into {1}. /// ///Thanks, ///The {1} Team. @@ -749,7 +749,7 @@ public static string Emails_ForgotPassword_Subject { /// ///[{0}]({0}) /// - ///Note that NuGet.org password login is being deprecated. Please use Microsoft account to sign into {1}. + ///Note that NuGet.org password login is deprecated. Please use Microsoft account to sign into {1}. /// ///Thanks, ///The {1} Team. @@ -1022,29 +1022,29 @@ public static string OrganizationEmailUpdateCancelled { } /// - /// Looks up a localized string similar to The new organization email address was saved!. + /// Looks up a localized string similar to Member name is required.. /// - public static string OrganizationEmailUpdated { + public static string OrganizationMemberNameIsRequired { get { - return ResourceManager.GetString("OrganizationEmailUpdated", resourceCulture); + return ResourceManager.GetString("OrganizationMemberNameIsRequired", resourceCulture); } } /// - /// Looks up a localized string similar to The organization email address has been changed! We sent a confirmation email to verify the new email. When the new email address is confirmed, it will take effect and we will forget the old one.. + /// Looks up a localized string similar to Administrator account '{0}' is not linked to an AAD credential with an organization tenant.. /// - public static string OrganizationEmailUpdatedWithConfirmationRequired { + public static string Organizations_AdminAccountDoesNotHaveTenant { get { - return ResourceManager.GetString("OrganizationEmailUpdatedWithConfirmationRequired", resourceCulture); + return ResourceManager.GetString("Organizations_AdminAccountDoesNotHaveTenant", resourceCulture); } } /// - /// Looks up a localized string similar to Member name is required.. + /// Looks up a localized string similar to Account '{0}' does not support organizations.. /// - public static string OrganizationMemberNameIsRequired { + public static string Organizations_NotInDomainWhitelist { get { - return ResourceManager.GetString("OrganizationMemberNameIsRequired", resourceCulture); + return ResourceManager.GetString("Organizations_NotInDomainWhitelist", resourceCulture); } } @@ -1472,42 +1472,47 @@ public static string ScopeDescription_VerifyPackage { } /// - /// Looks up a localized string similar to 1. All co-owners must use client version {0} or higher to push all of their packages. - ///2. All existing push API keys for co-owners new to this policy will expire in {1} days.. + /// Looks up a localized string similar to A package verification key is required to push symbols. Please contact support@nuget.org to get more details.. + /// + public static string SecurityPolicy_RequireApiKeyWithPackageVerifyScope { + get { + return ResourceManager.GetString("SecurityPolicy_RequireApiKeyWithPackageVerifyScope", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A client version '{0}' or higher is required to be able to push packages. Please contact support@nuget.org to get more details.. /// - public static string SecurePushPolicyDescriptions { + public static string SecurityPolicy_RequireMinProtocolVersionForPush { get { - return ResourceManager.GetString("SecurePushPolicyDescriptions", resourceCulture); + return ResourceManager.GetString("SecurityPolicy_RequireMinProtocolVersionForPush", resourceCulture); } } /// - /// Looks up a localized string similar to <ol> - ///<li>All co-owners must use client version {0} or higher to push all of their packages.</li> - ///<li>All existing push API keys for co-owners new to this policy will expire in {1} days.</li> - ///</ol>. + /// Looks up a localized string similar to The email address you provided does not match with the email address linked to the account. /// - public static string SecurePushPolicyDescriptionsHtml { + public static string SigninAssistance_EmailMismatched { get { - return ResourceManager.GetString("SecurePushPolicyDescriptionsHtml", resourceCulture); + return ResourceManager.GetString("SigninAssistance_EmailMismatched", resourceCulture); } } /// - /// Looks up a localized string similar to A package verification key is required to push symbols. Please contact support@nuget.org to get more details.. + /// Looks up a localized string similar to Please enter a valid email address. /// - public static string SecurityPolicy_RequireApiKeyWithPackageVerifyScope { + public static string SigninAssistance_InvalidEmail { get { - return ResourceManager.GetString("SecurityPolicy_RequireApiKeyWithPackageVerifyScope", resourceCulture); + return ResourceManager.GetString("SigninAssistance_InvalidEmail", resourceCulture); } } /// - /// Looks up a localized string similar to A client version '{0}' or higher is required to be able to push packages. Please contact support@nuget.org to get more details.. + /// Looks up a localized string similar to Please enter a valid username. /// - public static string SecurityPolicy_RequireMinProtocolVersionForPush { + public static string SigninAssistance_InvalidUser { get { - return ResourceManager.GetString("SecurityPolicy_RequireMinProtocolVersionForPush", resourceCulture); + return ResourceManager.GetString("SigninAssistance_InvalidUser", resourceCulture); } } @@ -1592,15 +1597,6 @@ public static string TransformAccount_AdminAccountDoesNotExist { } } - /// - /// Looks up a localized string similar to Administrator account '{0}' is not linked to an AAD credential with an organization tenant.. - /// - public static string TransformAccount_AdminAccountDoesNotHaveTenant { - get { - return ResourceManager.GetString("TransformAccount_AdminAccountDoesNotHaveTenant", resourceCulture); - } - } - /// /// Looks up a localized string similar to Administrator account '{0}' cannot be an organization.. /// @@ -1629,7 +1625,7 @@ public static string TransformAccount_AdminMustBeDifferentAccount { } /// - /// Looks up a localized string similar to An unexpected error occurred while transforming this account. Contact support for assistance.. + /// Looks up a localized string similar to An unexpected error occurred while transforming this account. Contact support for assistance.. /// public static string TransformAccount_Failed { get { @@ -1637,15 +1633,6 @@ public static string TransformAccount_Failed { } } - /// - /// Looks up a localized string similar to Account '{0}' does not support organizations.. - /// - public static string TransformAccount_FailedReasonNotInDomainWhitelist { - get { - return ResourceManager.GetString("TransformAccount_FailedReasonNotInDomainWhitelist", resourceCulture); - } - } - /// /// Looks up a localized string similar to Organization account '{0}' does not exist.. /// @@ -1862,24 +1849,6 @@ public static string UserEmailUpdateCancelled { } } - /// - /// Looks up a localized string similar to Your new email address was saved!. - /// - public static string UserEmailUpdated { - get { - return ResourceManager.GetString("UserEmailUpdated", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Your email address has been changed! We sent a confirmation email to verify your new email. When you confirm the new email address, it will take effect and we will forget the old one.. - /// - public static string UserEmailUpdatedWithConfirmationRequired { - get { - return ResourceManager.GetString("UserEmailUpdatedWithConfirmationRequired", resourceCulture); - } - } - /// /// Looks up a localized string similar to You cannot reset your password until you confirm your account.. /// diff --git a/src/NuGetGallery/Strings.resx b/src/NuGetGallery/Strings.resx index d6007d4301..d5e3f8565d 100644 --- a/src/NuGetGallery/Strings.resx +++ b/src/NuGetGallery/Strings.resx @@ -245,7 +245,7 @@ Click the following link within the next hour to reset your password: [{0}]({0}) -Note that NuGet.org password login is being deprecated. Please use Microsoft account to sign into {1}. +Note that NuGet.org password login is deprecated. Please use Microsoft account to sign into {1}. Thanks, The {1} Team @@ -261,7 +261,7 @@ Click the following link within the next hour to set your password: [{0}]({0}) -Note that NuGet.org password login is being deprecated. Please use Microsoft account to sign into {1}. +Note that NuGet.org password login is deprecated. Please use Microsoft account to sign into {1}. Thanks, The {1} Team @@ -294,12 +294,6 @@ The {1} Team Your email preferences have been updated. - - Your new email address was saved! - - - Your email address has been changed! We sent a confirmation email to verify your new email. When you confirm the new email address, it will take effect and we will forget the old one. - This package requires version '{0}' of NuGet, which this gallery does not currently support. Please contact us if you have questions. @@ -459,10 +453,6 @@ The {1} Team Package not found. - - 1. All co-owners must use client version {0} or higher to push all of their packages. -2. All existing push API keys for co-owners new to this policy will expire in {1} days. - User '{0}' has the following requirements that will be enforced for all co-owners once the user accepts ownership of this package: {1} @@ -510,12 +500,6 @@ For more information, please contact '{2}'. Owner(s) '{0}' require(s) that all co-owners use client version {1} or higher to push all of their packages. For more information, contact {2}. - - - <ol> -<li>All co-owners must use client version {0} or higher to push all of their packages.</li> -<li>All existing push API keys for co-owners new to this policy will expire in {1} days.</li> -</ol> Cannot upload file because an upload is already in progress. @@ -708,7 +692,7 @@ For more information, please contact '{2}'. The user '{0}' doesn't exist. You cannot upload a package as a user that doesn't exist. - An unexpected error occurred while transforming this account. Contact support for assistance. + An unexpected error occurred while transforming this account. Contact support for assistance. Organization account '{0}' does not exist. @@ -722,7 +706,7 @@ For more information, please contact '{2}'. Account '{0}' should be a confirmed user. - + Account '{0}' does not support organizations. @@ -774,12 +758,6 @@ If you wish to update the linked Microsoft account you can do so from the accoun You canceled your organization email address change request. - - The new organization email address was saved! - - - The organization email address has been changed! We sent a confirmation email to verify the new email. When the new email address is confirmed, it will take effect and we will forget the old one. - You have successfully confirmed your email address! @@ -813,10 +791,19 @@ If you wish to update the linked Microsoft account you can do so from the accoun User '{0}' has not linked their account to an AAD credential matching this organization. - + Administrator account '{0}' is not linked to an AAD credential with an organization tenant. You can't leave the organization. In order to leave the organization, another collaborator must be assigned as an administrator. + + The email address you provided does not match with the email address linked to the account + + + Please enter a valid email address + + + Please enter a valid username + \ No newline at end of file diff --git a/src/NuGetGallery/UrlExtensions.cs b/src/NuGetGallery/UrlExtensions.cs index 879422e2a3..b43d538c9f 100644 --- a/src/NuGetGallery/UrlExtensions.cs +++ b/src/NuGetGallery/UrlExtensions.cs @@ -447,6 +447,22 @@ public static string Register(this UrlHelper url, bool relativeUrl = true) return GetActionLink(url, "LogOn", "Authentication", relativeUrl); } + public static RouteUrlTemplate SearchTemplate(this UrlHelper url, bool relativeUrl = true) + { + var routesGenerator = new Dictionary> + { + { "q", s => s } + }; + + Func linkGenerator = rv => GetRouteLink( + url, + RouteName.ListPackages, + relativeUrl, + routeValues: rv); + + return new RouteUrlTemplate(linkGenerator, routesGenerator); + } + public static string Search(this UrlHelper url, string searchTerm, bool relativeUrl = true) { return GetRouteLink( @@ -764,6 +780,14 @@ public static string ManageMyOrganizations(this UrlHelper url, bool relativeUrl return GetActionLink(url, "Organizations", "Users", relativeUrl); } + public static string AddOrganization(this UrlHelper url, bool relativeUrl = true) + { + return GetActionLink(url, + "Add", + "Organizations", + relativeUrl); + } + public static string ManageMyOrganization(this UrlHelper url, string accountName, bool relativeUrl = true) { return GetActionLink(url, @@ -860,11 +884,22 @@ public static string AddPackageOwner(this UrlHelper url, bool relativeUrl = true return GetActionLink(url, "AddPackageOwner", "JsonApi", relativeUrl); } + public static string SigninAssistance(this UrlHelper url, bool relativeUrl = true) + { + return GetRouteLink(url, RouteName.SigninAssistance, relativeUrl); + } + public static string RemovePackageOwner(this UrlHelper url, bool relativeUrl = true) { return GetActionLink(url, "RemovePackageOwner", "JsonApi", relativeUrl); } + public static RouteUrlTemplate ConfirmPendingOwnershipRequestTemplate( + this UrlHelper url, bool relativeUrl = true) + { + return HandlePendingOwnershipRequestTemplate(url, RouteName.ConfirmPendingOwnershipRequest, relativeUrl); + } + public static string ConfirmPendingOwnershipRequest( this UrlHelper url, string packageId, @@ -872,18 +907,50 @@ public static string ConfirmPendingOwnershipRequest( string confirmationCode, bool relativeUrl = true) { - var routeValues = new RouteValueDictionary + return HandlePendingOwnershipRequest(url, RouteName.ConfirmPendingOwnershipRequest, packageId, username, confirmationCode, relativeUrl); + } + + public static RouteUrlTemplate RejectPendingOwnershipRequestTemplate( + this UrlHelper url, bool relativeUrl = true) + { + return HandlePendingOwnershipRequestTemplate(url, RouteName.RejectPendingOwnershipRequest, relativeUrl); + } + + public static string RejectPendingOwnershipRequest( + this UrlHelper url, + string packageId, + string username, + string confirmationCode, + bool relativeUrl = true) + { + return HandlePendingOwnershipRequest(url, RouteName.RejectPendingOwnershipRequest, packageId, username, confirmationCode, relativeUrl); + } + + private static RouteUrlTemplate HandlePendingOwnershipRequestTemplate( + this UrlHelper url, + string routeName, + bool relativeUrl = true) + { + var routesGenerator = new Dictionary> { - ["id"] = packageId, - ["username"] = username, - ["token"] = confirmationCode + { "id", r => r.Package.Id }, + { "username", r => r.Request.NewOwner.Username }, + { "token", r => r.Request.ConfirmationCode } }; - return GetActionLink(url, "ConfirmPendingOwnershipRequest", "Packages", relativeUrl, routeValues); + Func linkGenerator = rv => GetActionLink( + url, + routeName, + "Packages", + relativeUrl, + routeValues: rv); + + return new RouteUrlTemplate(linkGenerator, routesGenerator); } - public static string RejectPendingOwnershipRequest( + private static string HandlePendingOwnershipRequest( this UrlHelper url, + string routeName, string packageId, string username, string confirmationCode, @@ -896,7 +963,27 @@ public static string RejectPendingOwnershipRequest( ["token"] = confirmationCode }; - return GetActionLink(url, "RejectPendingOwnershipRequest", "Packages", relativeUrl, routeValues); + return GetActionLink(url, routeName, "Packages", relativeUrl, routeValues); + } + + public static RouteUrlTemplate CancelPendingOwnershipRequestTemplate( + this UrlHelper url, bool relativeUrl = true) + { + var routesGenerator = new Dictionary> + { + { "id", r => r.Package.Id }, + { "requestingUsername", r => r.Request.RequestingOwner.Username }, + { "pendingUsername", r => r.Request.NewOwner.Username } + }; + + Func linkGenerator = rv => GetActionLink( + url, + "CancelPendingOwnershipRequest", + "Packages", + relativeUrl, + routeValues: rv); + + return new RouteUrlTemplate(linkGenerator, routesGenerator); } public static string CancelPendingOwnershipRequest( diff --git a/src/NuGetGallery/ViewModels/AddOrganizationViewModel.cs b/src/NuGetGallery/ViewModels/AddOrganizationViewModel.cs new file mode 100644 index 0000000000..18d358862c --- /dev/null +++ b/src/NuGetGallery/ViewModels/AddOrganizationViewModel.cs @@ -0,0 +1,20 @@ +// 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.ComponentModel.DataAnnotations; + +namespace NuGetGallery +{ + public class AddOrganizationViewModel + { + [Required] + [StringLength(64)] + [Display(Name = "Organization Name")] + public string OrganizationName { get; set; } + + [Required] + [StringLength(255)] + [Display(Name = "Organization Email Address")] + public string OrganizationEmailAddress { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/ChangeEmailViewModel.cs b/src/NuGetGallery/ViewModels/ChangeEmailViewModel.cs index a06d3a6d46..3ae1f534a4 100644 --- a/src/NuGetGallery/ViewModels/ChangeEmailViewModel.cs +++ b/src/NuGetGallery/ViewModels/ChangeEmailViewModel.cs @@ -11,7 +11,7 @@ public class ChangeEmailViewModel [Required] [StringLength(255)] [Display(Name = "New Email Address")] - [RegularExpression(RegisterViewModel.EmailValidationRegex, ErrorMessage = RegisterViewModel.EmailValidationErrorMessage)] + [RegularExpression(Constants.EmailValidationRegex, ErrorMessage = RegisterViewModel.EmailValidationErrorMessage)] public string NewEmail { get; set; } [Required] diff --git a/src/NuGetGallery/ViewModels/GalleryHomeViewModel.cs b/src/NuGetGallery/ViewModels/GalleryHomeViewModel.cs index 68909058fd..20ca8afbca 100644 --- a/src/NuGetGallery/ViewModels/GalleryHomeViewModel.cs +++ b/src/NuGetGallery/ViewModels/GalleryHomeViewModel.cs @@ -9,13 +9,16 @@ public class GalleryHomeViewModel { public bool ShowTransformModal { get; set; } + public bool TransformIntoOrganization { get; set; } + public IList Providers { get; set; } - public GalleryHomeViewModel() : this(showTransformModal: false) { } + public GalleryHomeViewModel() : this(showTransformModal: false, transformIntoOrganization: false) { } - public GalleryHomeViewModel(bool showTransformModal) + public GalleryHomeViewModel(bool showTransformModal, bool transformIntoOrganization) { ShowTransformModal = showTransformModal; + TransformIntoOrganization = transformIntoOrganization; } } } \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/LogOnViewModel.cs b/src/NuGetGallery/ViewModels/LogOnViewModel.cs index fde56c1163..7962941f10 100644 --- a/src/NuGetGallery/ViewModels/LogOnViewModel.cs +++ b/src/NuGetGallery/ViewModels/LogOnViewModel.cs @@ -67,17 +67,6 @@ public SignInViewModel(string userNameOrEmail, string password) public class RegisterViewModel { - // Note: regexes must be tested to work in JavaScript - // We do NOT follow strictly the RFCs at this time, and we choose not to support many obscure email address variants. - // Specifically the following are not supported by-design: - // * Addresses containing () or [] - // * Second parts with no dots (i.e. foo@localhost or foo@com) - // * Addresses with quoted (" or ') first parts - // * Addresses with IP Address second parts (foo@[127.0.0.1]) - internal const string FirstPart = @"[-A-Za-z0-9!#$%&'*+\/=?^_`{|}~\.]+"; - internal const string SecondPart = @"[A-Za-z0-9]+[\w\.-]*[A-Za-z0-9]*\.[A-Za-z0-9][A-Za-z\.]*[A-Za-z]"; - internal const string EmailValidationRegex = "^" + FirstPart + "@" + SecondPart + "$"; - internal const string EmailValidationErrorMessage = "This doesn't appear to be a valid email address."; public const string EmailHint = "Your email will not be public unless you choose to disclose it. " + "It is required to verify your registration and for password retrieval, important notifications, etc. "; @@ -93,7 +82,7 @@ public class RegisterViewModel [Required] [StringLength(255)] [Display(Name = "Email")] - [RegularExpression(EmailValidationRegex, ErrorMessage = EmailValidationErrorMessage)] + [RegularExpression(Constants.EmailValidationRegex, ErrorMessage = EmailValidationErrorMessage)] [Subtext("We use Gravatar to get your profile picture", AllowHtml = true)] public string EmailAddress { get; set; } diff --git a/src/NuGetGallery/ViewModels/ManageOrganizationsItemViewModel.cs b/src/NuGetGallery/ViewModels/ManageOrganizationsItemViewModel.cs index 548afe12ee..8caa8b48bd 100644 --- a/src/NuGetGallery/ViewModels/ManageOrganizationsItemViewModel.cs +++ b/src/NuGetGallery/ViewModels/ManageOrganizationsItemViewModel.cs @@ -17,7 +17,7 @@ public ManageOrganizationsItemViewModel(Membership membership, IPackageService p { var organization = membership.Organization; Username = organization.Username; - EmailAddress = organization.EmailAddress; + EmailAddress = organization.EmailAddress ?? organization.UnconfirmedEmailAddress; CurrentUserIsAdmin = membership.IsAdmin; MemberCount = organization.Members.Count(); PackagesCount = packageService.FindPackageRegistrationsByOwner(membership.Organization).Count(); diff --git a/src/NuGetGallery/ViewModels/OwnerRequestsListItemViewModel.cs b/src/NuGetGallery/ViewModels/OwnerRequestsListItemViewModel.cs index 0d694cf7bb..9a503b29df 100644 --- a/src/NuGetGallery/ViewModels/OwnerRequestsListItemViewModel.cs +++ b/src/NuGetGallery/ViewModels/OwnerRequestsListItemViewModel.cs @@ -5,14 +5,23 @@ namespace NuGetGallery { public class OwnerRequestsListItemViewModel { - public OwnerRequestsListItemViewModel(PackageOwnerRequest request, IPackageService packageService) + public OwnerRequestsListItemViewModel(PackageOwnerRequest request, IPackageService packageService, User currentUser) { Request = request; - Package = packageService.FindPackageByIdAndVersion(request.PackageRegistration.Id, version: null, semVerLevelKey: SemVerLevelKey.SemVer2, allowPrerelease: true); + + var package = packageService.FindPackageByIdAndVersion(request.PackageRegistration.Id, version: null, semVerLevelKey: SemVerLevelKey.SemVer2, allowPrerelease: true); + Package = new ListPackageItemViewModel(package, currentUser); + + CanAccept = ActionsRequiringPermissions.HandlePackageOwnershipRequest.CheckPermissions(currentUser, Request.NewOwner) == PermissionsCheckResult.Allowed; + CanCancel = Package.CanManageOwners; } public PackageOwnerRequest Request { get; } - public Package Package { get; } + public ListPackageItemViewModel Package { get; } + + public bool CanAccept { get; } + + public bool CanCancel { get; } } } \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/OwnerRequestsListViewModel.cs b/src/NuGetGallery/ViewModels/OwnerRequestsListViewModel.cs index 5d985c5fd4..f5e748e1bc 100644 --- a/src/NuGetGallery/ViewModels/OwnerRequestsListViewModel.cs +++ b/src/NuGetGallery/ViewModels/OwnerRequestsListViewModel.cs @@ -8,17 +8,11 @@ namespace NuGetGallery { public class OwnerRequestsListViewModel { - public OwnerRequestsListViewModel(IEnumerable requests, string name, User currentUser, IPackageService packageService) + public OwnerRequestsListViewModel(IEnumerable requests, User currentUser, IPackageService packageService) { - RequestItems = requests.Select(r => new OwnerRequestsListItemViewModel(r, packageService)).ToArray(); - Name = name; - CurrentUser = currentUser; + Requests = requests.Select(r => new OwnerRequestsListItemViewModel(r, packageService, currentUser)).ToArray(); } - public IEnumerable RequestItems { get; } - - public string Name { get; } - - public User CurrentUser { get; } + public IEnumerable Requests { get; } } } \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/OwnerRequestsViewModel.cs b/src/NuGetGallery/ViewModels/OwnerRequestsViewModel.cs index 7058a59498..58f44b1fac 100644 --- a/src/NuGetGallery/ViewModels/OwnerRequestsViewModel.cs +++ b/src/NuGetGallery/ViewModels/OwnerRequestsViewModel.cs @@ -23,8 +23,8 @@ public OwnerRequestsViewModel( User currentUser, IPackageService packageService) { - Received = new OwnerRequestsListViewModel(received, nameof(Received), currentUser, packageService); - Sent = new OwnerRequestsListViewModel(sent, nameof(Sent), currentUser, packageService); + Received = new OwnerRequestsListViewModel(received, currentUser, packageService); + Sent = new OwnerRequestsListViewModel(sent, currentUser, packageService); } } } \ No newline at end of file diff --git a/src/NuGetGallery/ViewModels/PasswordResetRequestViewModel.cs b/src/NuGetGallery/ViewModels/PasswordResetRequestViewModel.cs index 7f214a0684..d94c0aa6be 100644 --- a/src/NuGetGallery/ViewModels/PasswordResetRequestViewModel.cs +++ b/src/NuGetGallery/ViewModels/PasswordResetRequestViewModel.cs @@ -7,6 +7,7 @@ namespace NuGetGallery public class ForgotPasswordViewModel { [Required] + [StringLength(255)] [Display(Name = "Email")] public string Email { get; set; } } diff --git a/src/NuGetGallery/ViewModels/ReportAbuseViewModel.cs b/src/NuGetGallery/ViewModels/ReportAbuseViewModel.cs index d3d431c184..a947d4c557 100644 --- a/src/NuGetGallery/ViewModels/ReportAbuseViewModel.cs +++ b/src/NuGetGallery/ViewModels/ReportAbuseViewModel.cs @@ -14,7 +14,7 @@ public class ReportAbuseViewModel : ReportViewModel [Required(ErrorMessage = "Please enter your email address.")] [StringLength(4000)] [Display(Name = "Your Email Address")] - [RegularExpression(RegisterViewModel.EmailValidationRegex, + [RegularExpression(Constants.EmailValidationRegex, ErrorMessage = "This doesn't appear to be a valid email address.")] public string Email { get; set; } diff --git a/src/NuGetGallery/ViewModels/TransformAccountViewModel.cs b/src/NuGetGallery/ViewModels/TransformAccountViewModel.cs index a09a7107d2..fd3f4f2ca3 100644 --- a/src/NuGetGallery/ViewModels/TransformAccountViewModel.cs +++ b/src/NuGetGallery/ViewModels/TransformAccountViewModel.cs @@ -8,7 +8,7 @@ namespace NuGetGallery public class TransformAccountViewModel { [Required] - [StringLength(255)] + [StringLength(64)] [Display(Name = "Administrator")] public string AdminUsername { get; set; } } diff --git a/src/NuGetGallery/Views/Authentication/Register.cshtml b/src/NuGetGallery/Views/Authentication/Register.cshtml index ee32744191..9f05c79851 100644 --- a/src/NuGetGallery/Views/Authentication/Register.cshtml +++ b/src/NuGetGallery/Views/Authentication/Register.cshtml @@ -8,9 +8,12 @@ } - - \ No newline at end of file + + +@section bottomScripts { + + @ViewHelpers.SectionsScript(this) + @Scripts.Render("~/Scripts/gallery/page-signin.min.js") +} diff --git a/src/NuGetGallery/Views/Authentication/SignInNugetAccount.cshtml b/src/NuGetGallery/Views/Authentication/SignInNugetAccount.cshtml index 78ac015196..f56f4d837d 100644 --- a/src/NuGetGallery/Views/Authentication/SignInNugetAccount.cshtml +++ b/src/NuGetGallery/Views/Authentication/SignInNugetAccount.cshtml @@ -8,9 +8,12 @@ } @section bottomScripts { - @ViewHelpers.SectionsScript(this); + @ViewHelpers.SectionsScript(this) @Scripts.Render("~/Scripts/gallery/page-account.min.js") } diff --git a/src/NuGetGallery/Views/Users/ForgotPassword.cshtml b/src/NuGetGallery/Views/Users/ForgotPassword.cshtml index d67d7c7445..69d84bfcc0 100644 --- a/src/NuGetGallery/Views/Users/ForgotPassword.cshtml +++ b/src/NuGetGallery/Views/Users/ForgotPassword.cshtml @@ -7,9 +7,13 @@ }
-
- @ViewHelpers.AlertPasswordDeprecation() -
+ @if (this.Config.Current.DeprecateNuGetPasswordLogins) + { +
+ @ViewHelpers.AlertPasswordDeprecation() +
+ } +

Forgot Password

@@ -26,6 +30,9 @@ @Html.ShowTextBoxFor(m => m.Email) @Html.ShowValidationMessagesFor(m => m.Email)
+
+ @Html.ShowValidationMessagesForEmpty() +
diff --git a/src/NuGetGallery/Views/Users/Organizations.cshtml b/src/NuGetGallery/Views/Users/Organizations.cshtml index 2f63c3a112..54ef93505e 100644 --- a/src/NuGetGallery/Views/Users/Organizations.cshtml +++ b/src/NuGetGallery/Views/Users/Organizations.cshtml @@ -4,16 +4,28 @@ Layout = "~/Views/Shared/Gallery/Layout.cshtml"; } -
+
-

Organizations

+ +
+

Organizations

+ @if (Model.Organizations.Any()) + { +
+ +
+ } +
+ @if (Model.Organizations.Any()) { var orgCount = Model.Organizations.Count(); var orgCountString = orgCount > 1 ? orgCount + " organizations" : orgCount + " organization"; -

You have @(orgCountString).

+
+

You have @(orgCountString).

+
@@ -58,7 +70,19 @@ } else { -

You do not have any organizations.

+
+ Learn to use packages +
+
+

Organizations allow you to manage and publish packages as a team or a group.

+
+
+
+ + +
} diff --git a/src/NuGetGallery/Views/Users/Packages.cshtml b/src/NuGetGallery/Views/Users/Packages.cshtml index c70befc131..206d47aab2 100644 --- a/src/NuGetGallery/Views/Users/Packages.cshtml +++ b/src/NuGetGallery/Views/Users/Packages.cshtml @@ -4,16 +4,12 @@ ViewBag.Title = "Manage My Package"; ViewBag.Tab = "Packages"; Layout = "~/Views/Shared/Gallery/Layout.cshtml"; - - var namespacesCount = Model.ReservedNamespaces.ReservedNamespaces.Count(); - var ownerRequestsReceivedCount = Model.OwnerRequests.Received.RequestItems.Count(); - var ownerRequestsSentCount = Model.OwnerRequests.Sent.RequestItems.Count(); }
- +

Manage Packages

@@ -44,36 +40,49 @@
, expanded: false) - @if (namespacesCount > 0) + @if (Model.ReservedNamespaces.ReservedNamespaces.Any()) { @ViewHelpers.Section(this, "namespaces", @Reserved Namespaces, - @@Html.Raw(namespacesCount + " namespace" + (namespacesCount == 1 ? "" : "s")), @ - @Html.Partial("_ReservedNamespacesList", Model.ReservedNamespaces) + + , + @ +
+
+
, expanded: false) } - @if (Model.OwnerRequests.Received.RequestItems.Any()) + @if (Model.OwnerRequests.Received.Requests.Any()) { @ViewHelpers.Section(this, "requests-received", @Ownership Requests (Received), - @@Html.Raw(ownerRequestsReceivedCount + " request" + (ownerRequestsReceivedCount == 1 ? "" : "s")), @ - @Html.Partial("_OwnerRequestsList", Model.OwnerRequests.Received) + + , + @ +
+
+
, expanded: false) } - @if (Model.OwnerRequests.Sent.RequestItems.Any()) + @if (Model.OwnerRequests.Sent.Requests.Any()) { @ViewHelpers.Section(this, "requests-sent", @Ownership Requests (Sent), - @@Html.Raw(ownerRequestsSentCount + " request" + (ownerRequestsSentCount == 1 ? "" : "s")), @ - @Html.Partial("_OwnerRequestsList", Model.OwnerRequests.Sent) + + , + @ +
+
+
, expanded: false) }
+
+ + + + @functions { + // Performance: RouteCollection.VirtualPath is expensive, so resolve the path once and save as a template. + // Then substitute route values into the template path when rendering links for each package on the page. private RouteUrlTemplate userUrlTemplate; + private RouteUrlTemplate packageUrlTemplate; private RouteUrlTemplate editUrlTemplate; private RouteUrlTemplate manageOwnersUrlTemplate; private RouteUrlTemplate deleteUrlTemplate; + private RouteUrlTemplate searchUrlTemplate; + + private RouteUrlTemplate confirmUrlTemplate; + private RouteUrlTemplate rejectUrlTemplate; + private RouteUrlTemplate cancelUrlTemplate; + dynamic GetSerializablePackage(ListPackageItemViewModel p) { - if (deleteUrlTemplate == null) + if (packageUrlTemplate == null) { - // Performance: RouteCollection.VirtualPath is expensive, so resolve the path once and save as a template. - // Then substitute route values into the template path when rendering links for each package on the page. - userUrlTemplate = Url.UserTemplate(); packageUrlTemplate = Url.PackageRegistrationTemplate(); + } + + if (editUrlTemplate == null) + { editUrlTemplate = Url.EditPackageTemplate(); + } + + if (manageOwnersUrlTemplate == null) + { manageOwnersUrlTemplate = Url.ManagePackageOwnersTemplate(); + } + + if (deleteUrlTemplate == null) + { deleteUrlTemplate = Url.DeletePackageTemplate(); } return new { p.Id, - Owners = p.Owners.Select(o => new - { - o.Username, - ProfileUrl = userUrlTemplate.Resolve(o), - IsOrganization = o is Organization - }), + Owners = GetSerializableOwners(p.Owners), p.TotalDownloadCount, LatestVersion = p.FullVersion.Abbreviate(25), PackageIconUrl = PackageHelper.ShouldRenderUrl(p.IconUrl) ? p.IconUrl : null, @@ -184,6 +300,80 @@ CanDelete = p.CanUnlistOrRelist }; } + + dynamic GetSerializableNamespace(ReservedNamespaceListItemViewModel n) + { + if (searchUrlTemplate == null) + { + searchUrlTemplate = Url.SearchTemplate(); + } + + return new + { + Pattern = n.GetPattern(), + SearchUrl = searchUrlTemplate.Resolve(n.Value), + Owners = GetSerializableOwners(n.Owners), + n.IsPublic + }; + } + + dynamic GetSerializableOwnerRequest(OwnerRequestsListItemViewModel r) + { + if (packageUrlTemplate == null) + { + packageUrlTemplate = Url.PackageRegistrationTemplate(); + } + + if (confirmUrlTemplate == null) + { + confirmUrlTemplate = Url.ConfirmPendingOwnershipRequestTemplate(); + } + + if (rejectUrlTemplate == null) + { + rejectUrlTemplate = Url.RejectPendingOwnershipRequestTemplate(); + } + + if (cancelUrlTemplate == null) + { + cancelUrlTemplate = Url.CancelPendingOwnershipRequestTemplate(); + } + + return new + { + r.Request.PackageRegistration.Id, + PackageIconUrl = PackageHelper.ShouldRenderUrl(r.Package.IconUrl) ? r.Package.IconUrl : null, + PackageUrl = packageUrlTemplate.Resolve(r.Package), + Owners = GetSerializableOwners(r.Package.Owners), + Requesting = GetSerializableOwner(r.Request.RequestingOwner), + New = GetSerializableOwner(r.Request.NewOwner), + CanAccept = r.CanAccept, + CanCancel = r.CanCancel, + ConfirmUrl = confirmUrlTemplate.Resolve(r), + RejectUrl = rejectUrlTemplate.Resolve(r), + CancelUrl = cancelUrlTemplate.Resolve(r) + }; + } + + dynamic GetSerializableOwners(IEnumerable owners) + { + return owners.Select(o => GetSerializableOwner(o)); + } + + dynamic GetSerializableOwner(User user) + { + if (userUrlTemplate == null) + { + userUrlTemplate = Url.UserTemplate(); + } + + return new + { + user.Username, + ProfileUrl = userUrlTemplate.Resolve(user), + IsOrganization = user is Organization + }; + } } @section BottomScripts { @@ -195,6 +385,12 @@ .Select(p => GetSerializablePackage(p)), UnlistedPackages = Model.UnlistedPackages .Select(p => GetSerializablePackage(p)), + ReservedNamespaces = Model.ReservedNamespaces.ReservedNamespaces + .Select(n => GetSerializableNamespace(n)), + RequestsReceived = Model.OwnerRequests.Received.Requests + .Select(r => GetSerializableOwnerRequest(r)), + RequestsSent = Model.OwnerRequests.Sent.Requests + .Select(r => GetSerializableOwnerRequest(r)), DefaultPackageIconUrl = Url.Absolute("~/Content/gallery/img/default-package-icon.svg"), PackageIconUrlFallback = Url.Absolute("~/Content/gallery/img/default-package-icon-256x256.png") })); diff --git a/src/NuGetGallery/Views/Users/Transform.cshtml b/src/NuGetGallery/Views/Users/Transform.cshtml index f5fd1a5bec..4bbd569551 100644 --- a/src/NuGetGallery/Views/Users/Transform.cshtml +++ b/src/NuGetGallery/Views/Users/Transform.cshtml @@ -9,7 +9,7 @@ var transformConfirmation = "You have added '{0}' as an administrator. " + transformNote; } -
- - - - - - - - - - - @foreach (var requestItem in @Model.RequestItems) - { - var packageId = requestItem.Request.PackageRegistration.Id; - - - - - - - - - } - -
Package IDExisting OwnerNew Owner
@Html.BreakWord(packageId) - @requestItem.Request.RequestingOwner.Username - - @requestItem.Request.NewOwner.Username - - @if (CurrentUser.Key == requestItem.Request.NewOwnerKey) - { - - - - - - - - } - else - { - - - - } -
-
-
-
- \ No newline at end of file diff --git a/src/NuGetGallery/Views/Users/_ReservedNamespacesList.cshtml b/src/NuGetGallery/Views/Users/_ReservedNamespacesList.cshtml deleted file mode 100644 index ea7dc4ee33..0000000000 --- a/src/NuGetGallery/Views/Users/_ReservedNamespacesList.cshtml +++ /dev/null @@ -1,50 +0,0 @@ -@model ReservedNamespaceListViewModel - -
-
-
-
- - - - - - - - - - - @foreach (var reservedNamespace in @Model.ReservedNamespaces) - { - - - - - @if (reservedNamespace.IsPublic) - { - - } - else - { - - } - - } - -
Package ID or PrefixOwnersUpload Restrictions
- @reservedNamespace.GetPattern() - - @foreach (var owner in reservedNamespace.Owners) - { - @owner.Username - } - Any NuGet.org AccountPrefix or ID Owners Only
-
-
-
-
\ No newline at end of file diff --git a/src/NuGetGallery/Web.config b/src/NuGetGallery/Web.config index ebd677c464..9560f184d6 100644 --- a/src/NuGetGallery/Web.config +++ b/src/NuGetGallery/Web.config @@ -40,6 +40,8 @@ + + diff --git a/tests/NuGetGallery.Core.Facts/Packaging/PackageMetadataFacts.cs b/tests/NuGetGallery.Core.Facts/Packaging/PackageMetadataFacts.cs index 5bdca379d8..84f2cbebcd 100644 --- a/tests/NuGetGallery.Core.Facts/Packaging/PackageMetadataFacts.cs +++ b/tests/NuGetGallery.Core.Facts/Packaging/PackageMetadataFacts.cs @@ -23,7 +23,9 @@ public static void CanReadBasicMetadataProperties() var nuspec = nupkg.GetNuspecReader(); // Act - var packageMetadata = PackageMetadata.FromNuspecReader(nuspec); + var packageMetadata = PackageMetadata.FromNuspecReader( + nuspec, + strict: true); // Assert Assert.Equal("TestPackage", packageMetadata.Id); @@ -47,7 +49,39 @@ public static void ThrowsPackagingExceptionWhenInvalidDepencencyVersionRangeDete var nuspec = nupkg.GetNuspecReader(); // Act & Assert - Assert.Throws(() => PackageMetadata.FromNuspecReader(nuspec)); + Assert.Throws(() => PackageMetadata.FromNuspecReader( + nuspec, + strict: true)); + } + + [Fact] + public static void DoesNotThrowInvalidDepencencyVersionRangeDetectedAndParsingIsNotStrict() + { + var packageStream = CreateTestPackageStreamWithInvalidDependencyVersion(); + var nupkg = new PackageArchiveReader(packageStream, leaveStreamOpen: false); + var nuspec = nupkg.GetNuspecReader(); + + // Act + var packageMetadata = PackageMetadata.FromNuspecReader( + nuspec, + strict: false); + + // Assert + Assert.Equal("TestPackage", packageMetadata.Id); + Assert.Equal(NuGetVersion.Parse("0.0.0.1"), packageMetadata.Version); + Assert.Equal("Package A", packageMetadata.Title); + Assert.Equal(2, packageMetadata.Authors.Count); + Assert.Equal("ownera, ownerb", packageMetadata.Owners); + Assert.False(packageMetadata.RequireLicenseAcceptance); + Assert.Equal("package A description.", packageMetadata.Description); + Assert.Equal("en-US", packageMetadata.Language); + Assert.Equal("http://www.nuget.org/", packageMetadata.ProjectUrl.ToString()); + Assert.Equal("http://www.nuget.org/", packageMetadata.IconUrl.ToString()); + Assert.Equal("http://www.nuget.org/", packageMetadata.LicenseUrl.ToString()); + var dependencyGroup = Assert.Single(packageMetadata.GetDependencyGroups()); + var dependency = Assert.Single(dependencyGroup.Packages); + Assert.Equal("SampleDependency", dependency.Id); + Assert.Equal(VersionRange.All, dependency.VersionRange); } private static Stream CreateTestPackageStream() diff --git a/tests/NuGetGallery.Core.Facts/Services/CloudBlobCoreFileStorageServiceFacts.cs b/tests/NuGetGallery.Core.Facts/Services/CloudBlobCoreFileStorageServiceFacts.cs index d2529020b0..c75fbb2a92 100644 --- a/tests/NuGetGallery.Core.Facts/Services/CloudBlobCoreFileStorageServiceFacts.cs +++ b/tests/NuGetGallery.Core.Facts/Services/CloudBlobCoreFileStorageServiceFacts.cs @@ -625,5 +625,317 @@ private static Tuple, Mock, Uri> Setup( return Tuple.Create(fakeBlobClient, fakeBlob, blobUri); } } + + public class TheCopyFileAsyncMethod + { + private string _srcFolderName; + private string _srcFileName; + private string _srcETag; + private BlobProperties _srcProperties; + private string _destFolderName; + private string _destFileName; + private string _destETag; + private BlobProperties _destProperties; + private CopyState _destCopyState; + private Mock _blobClient; + private Mock _srcContainer; + private Mock _destContainer; + private Mock _srcBlobMock; + private Mock _destBlobMock; + private CloudBlobCoreFileStorageService _target; + + public TheCopyFileAsyncMethod() + { + _srcFolderName = "validation"; + _srcFileName = "4b6f16cc-7acd-45eb-ac21-33f0d927ec14/nuget.versioning.4.5.0.nupkg"; + _srcETag = "\"src-etag\""; + _srcProperties = new BlobProperties(); + _destFolderName = "packages"; + _destFileName = "nuget.versioning.4.5.0.nupkg"; + _destETag = "\"dest-etag\""; + _destProperties = new BlobProperties(); + _destCopyState = new CopyState(); + SetDestCopyStatus(CopyStatus.Success); + + _blobClient = new Mock(); + _srcContainer = new Mock(); + _destContainer = new Mock(); + _srcBlobMock = new Mock(); + _destBlobMock = new Mock(); + _blobClient + .Setup(x => x.GetContainerReference(_srcFolderName)) + .Returns(() => _srcContainer.Object); + _blobClient + .Setup(x => x.GetContainerReference(_destFolderName)) + .Returns(() => _destContainer.Object); + _srcContainer + .Setup(x => x.GetBlobReference(_srcFileName)) + .Returns(() => _srcBlobMock.Object); + _destContainer + .Setup(x => x.GetBlobReference(_destFileName)) + .Returns(() => _destBlobMock.Object); + _srcBlobMock + .Setup(x => x.Name) + .Returns(() => _srcFileName); + _srcBlobMock + .Setup(x => x.ETag) + .Returns(() => _srcETag); + _srcBlobMock + .Setup(x => x.Properties) + .Returns(() => _srcProperties); + _destBlobMock + .Setup(x => x.ETag) + .Returns(() => _destETag); + _destBlobMock + .Setup(x => x.Properties) + .Returns(() => _destProperties); + _destBlobMock + .Setup(x => x.CopyState) + .Returns(() => _destCopyState); + + _target = CreateService(fakeBlobClient: _blobClient); + } + + [Fact] + public async Task WillCopyTheFileIfDestinationDoesNotExist() + { + // Arrange + AccessCondition srcAccessCondition = null; + AccessCondition destAccessCondition = null; + ISimpleCloudBlob srcBlob = null; + + _destBlobMock + .Setup(x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(0)) + .Callback((b, s, d) => + { + srcBlob = b; + srcAccessCondition = s; + destAccessCondition = d; + }); + + // Act + await _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + AccessConditionWrapper.GenerateIfNotExistsCondition()); + + // Assert + _destBlobMock.Verify( + x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + Assert.Equal(_srcFileName, srcBlob.Name); + Assert.Equal(_srcETag, srcAccessCondition.IfMatchETag); + Assert.Equal("*", destAccessCondition.IfNoneMatchETag); + } + + [Fact] + public async Task WillThrowInvalidOperationExceptionForConflict() + { + // Arrange + _destBlobMock + .Setup(x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new StorageException(new RequestResult { HttpStatusCode = (int)HttpStatusCode.Conflict }, "Conflict!", inner: null)); + + // Act & Assert + await Assert.ThrowsAsync( + () => _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + AccessConditionWrapper.GenerateIfNotExistsCondition())); + } + + [Fact] + public async Task WillCopyTheFileIfDestinationHasFailedCopy() + { + // Arrange + AccessCondition srcAccessCondition = null; + AccessCondition destAccessCondition = null; + ISimpleCloudBlob srcBlob = null; + + SetDestCopyStatus(CopyStatus.Failed); + + _destBlobMock + .Setup(x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(0)) + .Callback((b, s, d) => + { + srcBlob = b; + srcAccessCondition = s; + destAccessCondition = d; + SetDestCopyStatus(CopyStatus.Pending); + }); + + _destBlobMock + .Setup(x => x.ExistsAsync()) + .ReturnsAsync(true); + + _destBlobMock + .Setup(x => x.FetchAttributesAsync()) + .Returns(Task.FromResult(0)) + .Callback(() => SetDestCopyStatus(CopyStatus.Success)); + + // Act + var srcETag = await _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + AccessConditionWrapper.GenerateIfNotExistsCondition()); + + // Assert + _destBlobMock.Verify( + x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + Assert.Equal(_srcETag, srcETag); + Assert.Equal(_srcFileName, srcBlob.Name); + Assert.Equal(_srcETag, srcAccessCondition.IfMatchETag); + Assert.Equal(_destETag, destAccessCondition.IfMatchETag); + } + + [Fact] + public async Task WillDefaultToIfNotExists() + { + // Arrange + AccessCondition srcAccessCondition = null; + AccessCondition destAccessCondition = null; + ISimpleCloudBlob srcBlob = null; + _destBlobMock + .Setup(x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(0)) + .Callback((b, s, d) => + { + srcBlob = b; + srcAccessCondition = s; + destAccessCondition = d; + SetDestCopyStatus(CopyStatus.Success); + }); + + // Act + await _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + destAccessCondition: null); + + // Assert + _destBlobMock.Verify( + x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + Assert.Null(destAccessCondition.IfMatchETag); + Assert.Equal("*", destAccessCondition.IfNoneMatchETag); + } + + [Fact] + public async Task UsesProvidedMatchETag() + { + // Arrange + AccessCondition srcAccessCondition = null; + AccessCondition destAccessCondition = null; + ISimpleCloudBlob srcBlob = null; + _destBlobMock + .Setup(x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(0)) + .Callback((b, s, d) => + { + srcBlob = b; + srcAccessCondition = s; + destAccessCondition = d; + SetDestCopyStatus(CopyStatus.Success); + }); + + // Act + await _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + AccessConditionWrapper.GenerateIfMatchCondition("etag!")); + + // Assert + _destBlobMock.Verify( + x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + Assert.NotEqual("etag!", destAccessCondition.IfMatchETag); + Assert.Null(destAccessCondition.IfNoneMatchETag); + } + + [Fact] + public async Task NoOpsIfPackageLengthAndHashMatch() + { + // Arrange + SetBlobContentMD5(_srcProperties, "mwgwUC0MwohHxgMmvQzO7A=="); + SetBlobLength(_srcProperties, 42); + SetBlobContentMD5(_destProperties, _srcProperties.ContentMD5); + SetBlobLength(_destProperties, _srcProperties.Length); + + _destBlobMock + .Setup(x => x.ExistsAsync()) + .ReturnsAsync(true); + + // Act + await _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + AccessConditionWrapper.GenerateIfNotExistsCondition()); + + // Assert + _destBlobMock.Verify( + x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ThrowsIfCopyOperationFails() + { + // Arrange + _destBlobMock + .Setup(x => x.StartCopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(0)) + .Callback((_, __, ___) => + { + SetDestCopyStatus(CopyStatus.Failed); + }); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + AccessConditionWrapper.GenerateIfNotExistsCondition())); + Assert.Contains("The blob copy operation had copy status Failed", ex.Message); + } + + private void SetDestCopyStatus(CopyStatus copyStatus) + { + // We have to use reflection because the setter is not public. + typeof(CopyState) + .GetProperty(nameof(CopyState.Status)) + .SetValue(_destCopyState, copyStatus, null); + } + + private void SetBlobLength(BlobProperties properties, long length) + { + typeof(BlobProperties) + .GetProperty(nameof(BlobProperties.Length)) + .SetValue(properties, length, null); + } + + private void SetBlobContentMD5(BlobProperties properties, string contentMD5) + { + typeof(BlobProperties) + .GetProperty(nameof(BlobProperties.ContentMD5)) + .SetValue(properties, contentMD5, null); + } + } } } diff --git a/tests/NuGetGallery.Core.Facts/Services/CorePackageFileServiceFacts.cs b/tests/NuGetGallery.Core.Facts/Services/CorePackageFileServiceFacts.cs index 9035875076..f24b8ebb20 100644 --- a/tests/NuGetGallery.Core.Facts/Services/CorePackageFileServiceFacts.cs +++ b/tests/NuGetGallery.Core.Facts/Services/CorePackageFileServiceFacts.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using System.Web; using Moq; using Xunit; @@ -495,6 +496,219 @@ public async Task WillUseFileStorageService() } } + public class TheStorePackageFileInBackupLocationAsyncMethod + { + private string packageHashForTests = "NzMzMS1QNENLNEczSDQ1SA=="; + + [Fact] + public async Task WillThrowIfPackageIsNull() + { + var service = CreateService(); + + var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(null, Stream.Null)); + + Assert.Equal("package", ex.ParamName); + } + + [Fact] + public async Task WillThrowIfPackageFileIsNull() + { + var service = CreateService(); + + var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(new Package { PackageRegistration = new PackageRegistration() }, null)); + + Assert.Equal("packageFile", ex.ParamName); + } + + [Fact] + public async Task WillThrowIfPackageIsMissingPackageRegistration() + { + var service = CreateService(); + var package = new Package { PackageRegistration = null }; + + var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream())); + + Assert.True(ex.Message.StartsWith("The package is missing required data.")); + Assert.Equal("package", ex.ParamName); + } + + [Fact] + public async Task WillThrowIfPackageIsMissingPackageRegistrationId() + { + var service = CreateService(); + var packageRegistraion = new PackageRegistration { Id = null }; + var package = new Package { PackageRegistration = packageRegistraion }; + + var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream())); + + Assert.True(ex.Message.StartsWith("The package is missing required data.")); + Assert.Equal("package", ex.ParamName); + } + + [Fact] + public async Task WillThrowIfPackageIsMissingNormalizedVersionAndVersion() + { + var service = CreateService(); + var packageRegistraion = new PackageRegistration { Id = "theId" }; + var package = new Package { PackageRegistration = packageRegistraion, NormalizedVersion = null, Version = null }; + + var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream())); + + Assert.True(ex.Message.StartsWith("The package is missing required data.")); + Assert.Equal("package", ex.ParamName); + } + + [Fact] + public async Task WillUseNormalizedRegularVersionIfNormalizedVersionMissing() + { + var fileStorageSvc = new Mock(); + var service = CreateService(fileStorageService: fileStorageSvc); + var packageRegistraion = new PackageRegistration { Id = "theId" }; + var package = new Package { PackageRegistration = packageRegistraion, NormalizedVersion = null, Version = "01.01.01", Hash = packageHashForTests }; + package.Hash = packageHashForTests; + + fileStorageSvc.Setup(x => x.SaveFileAsync(It.IsAny(), BuildBackupFileName("theId", "1.1.1", packageHashForTests), It.IsAny(), It.Is(b => b))) + .Completes() + .Verifiable(); + + await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); + + fileStorageSvc.VerifyAll(); + } + + [Fact] + public async Task WillUseLowercaseNormalizedIdAndVersion() + { + var fileStorageSvc = new Mock(); + var service = CreateService(fileStorageService: fileStorageSvc); + string path = null; + fileStorageSvc + .Setup(x => x.SaveFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)) + .Callback((_, p, __, ___) => path = p); + + var package = CreatePackage(); + package.PackageRegistration.Id = Id; + package.NormalizedVersion = NormalizedVersion; + package.Hash = packageHashForTests; + + await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); + + Assert.Equal("nuget.versioning/4.3.0-beta/NzMzMS1QNENLNEczSDQ1SA2..nupkg", path); + } + + [Fact] + public async Task WillSaveTheFileViaTheFileStorageServiceUsingThePackagesFolder() + { + var fileStorageSvc = new Mock(); + var service = CreateService(fileStorageService: fileStorageSvc); + fileStorageSvc.Setup(x => x.SaveFileAsync(CoreConstants.PackageBackupsFolderName, It.IsAny(), It.IsAny(), It.Is(b => b))) + .Completes() + .Verifiable(); + + var package = CreatePackage(); + package.Hash = packageHashForTests; + + await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); + + fileStorageSvc.VerifyAll(); + } + + [Fact] + public async Task WillSaveTheFileViaTheFileStorageServiceUsingAFileNameWithIdAndNormalizedersion() + { + var fileStorageSvc = new Mock(); + var service = CreateService(fileStorageService: fileStorageSvc); + fileStorageSvc.Setup(x => x.SaveFileAsync(It.IsAny(), BuildBackupFileName("theId", "theNormalizedVersion", packageHashForTests), It.IsAny(), It.Is(b => b))) + .Completes() + .Verifiable(); + + var package = CreatePackage(); + package.Hash = packageHashForTests; + + await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); + + fileStorageSvc.VerifyAll(); + } + + [Fact] + public async Task WillSaveTheFileStreamViaTheFileStorageService() + { + var fileStorageSvc = new Mock(); + var fakeStream = new MemoryStream(); + var service = CreateService(fileStorageService: fileStorageSvc); + fileStorageSvc.Setup(x => x.SaveFileAsync(It.IsAny(), It.IsAny(), fakeStream, It.Is(b => b))) + .Completes() + .Verifiable(); + + var package = CreatePackage(); + package.Hash = packageHashForTests; + + await service.StorePackageFileInBackupLocationAsync(package, fakeStream); + + fileStorageSvc.VerifyAll(); + } + + [Fact] + public async Task WillNotUploadThePackageIfItAlreadyExists() + { + var fileStorageSvc = new Mock(); + var fakeStream = new MemoryStream(); + var service = CreateService(fileStorageService: fileStorageSvc); + fileStorageSvc + .Setup(x => x.FileExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var package = CreatePackage(); + package.Hash = packageHashForTests; + + await service.StorePackageFileInBackupLocationAsync(package, fakeStream); + + fileStorageSvc.Verify( + x => x.SaveFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WillSwallowInvalidOperationException() + { + var fileStorageSvc = new Mock(); + var fakeStream = new MemoryStream(); + var service = CreateService(fileStorageService: fileStorageSvc); + fileStorageSvc + .Setup(x => x.SaveFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("File already exists.")); + + var package = CreatePackage(); + package.Hash = packageHashForTests; + + await service.StorePackageFileInBackupLocationAsync(package, fakeStream); + + fileStorageSvc.Verify( + x => x.SaveFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task WillNotSwallowOtherExceptions() + { + var fileStorageSvc = new Mock(); + var fakeStream = new MemoryStream(); + var service = CreateService(fileStorageService: fileStorageSvc); + var exception = new ArgumentException("Bad!"); + fileStorageSvc + .Setup(x => x.SaveFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(exception); + + var package = CreatePackage(); + package.Hash = packageHashForTests; + + var actual = await Assert.ThrowsAsync( + () => service.StorePackageFileInBackupLocationAsync(package, fakeStream)); + Assert.Same(exception, actual); + } + } + static string BuildFileName( string id, string version, string extension, string path) @@ -506,6 +720,18 @@ static string BuildFileName( extension); } + private static string BuildBackupFileName(string id, string version, string hash) + { + var hashBytes = Convert.FromBase64String(hash); + + return string.Format( + CoreConstants.PackageFileBackupSavePathTemplate, + id.ToLowerInvariant(), + version.ToLowerInvariant(), + HttpServerUtility.UrlTokenEncode(hashBytes), + CoreConstants.NuGetPackageFileExtension); + } + static Package CreatePackage() { var packageRegistration = new PackageRegistration { Id = "theId", Packages = new HashSet() }; diff --git a/tests/NuGetGallery.Core.Facts/Services/CorePackageServiceFacts.cs b/tests/NuGetGallery.Core.Facts/Services/CorePackageServiceFacts.cs index 97c0ddc934..b2d4dd9b99 100644 --- a/tests/NuGetGallery.Core.Facts/Services/CorePackageServiceFacts.cs +++ b/tests/NuGetGallery.Core.Facts/Services/CorePackageServiceFacts.cs @@ -5,12 +5,119 @@ using System.Collections.Generic; using System.Threading.Tasks; using Moq; +using NuGetGallery.Packaging; using Xunit; namespace NuGetGallery { public class CorePackageServiceFacts { + public class TheUpdatePackageStreamMetadataMethod + { + [Fact] + public async Task RejectsNullPackage() + { + // Arrange + Package package = null; + var metadata = new PackageStreamMetadata(); + var service = CreateService(); + + // Act & Assert + await Assert.ThrowsAsync( + () => service.UpdatePackageStreamMetadataAsync(package, metadata, commitChanges: true)); + } + + [Fact] + public async Task RejectsNullStreamMetadata() + { + // Arrange + var package = new Package(); + PackageStreamMetadata metadata = null; + var service = CreateService(); + + // Act & Assert + await Assert.ThrowsAsync( + () => service.UpdatePackageStreamMetadataAsync(package, metadata, commitChanges: true)); + } + + [Theory] + [InlineData(false, 0)] + [InlineData(true, 1)] + public async Task CommitsTheCorrectNumberOfTimes(bool commitChanges, int commitCount) + { + // Arrange + var packageRepository = new Mock>(); + var package = new Package(); + var metadata = new PackageStreamMetadata(); + var service = CreateService(packageRepository: packageRepository); + + // Act + await service.UpdatePackageStreamMetadataAsync(package, metadata, commitChanges); + + // Assert + packageRepository.Verify(x => x.CommitChangesAsync(), Times.Exactly(commitCount)); + } + + [Fact] + public async Task UpdatesTheStreamMetadata() + { + // Arrange + var package = new Package + { + Hash = "hash-before", + HashAlgorithm = "hash-algorithm-before", + PackageFileSize = 23, + LastUpdated = new DateTime(2017, 1, 1, 8, 30, 0), + LastEdited = new DateTime(2017, 1, 1, 7, 30, 0), + PackageStatusKey = PackageStatus.Available, + }; + var metadata = new PackageStreamMetadata + { + Hash = "hash-after", + HashAlgorithm = "hash-algorithm-after", + Size = 42, + }; + var service = CreateService(); + + // Act + var before = DateTime.UtcNow; + await service.UpdatePackageStreamMetadataAsync(package, metadata, commitChanges: true); + var after = DateTime.UtcNow; + + // Assert + Assert.Equal("hash-after", package.Hash); + Assert.Equal("hash-algorithm-after", package.HashAlgorithm); + Assert.Equal(42, package.PackageFileSize); + Assert.InRange(package.LastUpdated, before, after); + Assert.NotNull(package.LastEdited); + Assert.InRange(package.LastEdited.Value, before, after); + Assert.Equal(package.LastUpdated, package.LastEdited); + } + + [Theory] + [InlineData(PackageStatus.Deleted)] + [InlineData(PackageStatus.Validating)] + [InlineData(PackageStatus.FailedValidation)] + public async Task DoesNotUpdateLastEditedWhenNotAvailable(PackageStatus packageStatus) + { + // Arrange + var originalLastEdited = new DateTime(2017, 1, 1, 7, 30, 0); + var package = new Package + { + LastEdited = originalLastEdited, + PackageStatusKey = packageStatus, + }; + var metadata = new PackageStreamMetadata(); + var service = CreateService(); + + // Act + await service.UpdatePackageStreamMetadataAsync(package, metadata, commitChanges: true); + + // Assert + Assert.Equal(originalLastEdited, package.LastEdited); + } + } + public class TheUpdatePackageStatusMethod { [Fact] diff --git a/tests/NuGetGallery.Facts/Controllers/AccountsControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/AccountsControllerFacts.cs index 207f2a8aa9..e3cec9f2e3 100644 --- a/tests/NuGetGallery.Facts/Controllers/AccountsControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/AccountsControllerFacts.cs @@ -234,8 +234,6 @@ public virtual async Task WhenNewEmailIsDifferentAndWasConfirmed_SavesChanges() GetMock().Verify(u => u.ChangeEmailAddress(It.IsAny(), It.IsAny()), Times.Once); ResultAssert.IsRedirectToRoute(result, new { action = controller.AccountAction }); - Assert.Equal(controller.Messages.EmailUpdatedWithConfirmationRequired, controller.TempData["Message"]); - GetMock() .Verify(m => m.SendEmailChangeConfirmationNotice(It.IsAny(), It.IsAny()), Times.Once); @@ -260,8 +258,6 @@ public virtual async Task WhenNewEmailIsDifferentAndWasUnconfirmed_SavesChanges( GetMock().Verify(u => u.ChangeEmailAddress(It.IsAny(), It.IsAny()), Times.Once); ResultAssert.IsRedirectToRoute(result, new { action = controller.AccountAction }); - Assert.Equal(controller.Messages.EmailUpdated, controller.TempData["Message"]); - GetMock() .Verify(m => m.SendEmailChangeConfirmationNotice(It.IsAny(), It.IsAny()), Times.Never); diff --git a/tests/NuGetGallery.Facts/Controllers/ApiControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/ApiControllerFacts.cs index dad3a64e66..93add14028 100644 --- a/tests/NuGetGallery.Facts/Controllers/ApiControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/ApiControllerFacts.cs @@ -96,7 +96,9 @@ public TestableApiController( MockPackageUploadService.Setup(x => x.GeneratePackageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string id, PackageArchiveReader nugetPackage, PackageStreamMetadata packageStreamMetadata, User owner, User currentUser) => { - var packageMetadata = PackageMetadata.FromNuspecReader(nugetPackage.GetNuspecReader()); + var packageMetadata = PackageMetadata.FromNuspecReader( + nugetPackage.GetNuspecReader(), + strict: true); var package = new Package(); package.PackageRegistration = new PackageRegistration { Id = packageMetadata.Id, IsVerified = false }; diff --git a/tests/NuGetGallery.Facts/Controllers/AuthenticationControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/AuthenticationControllerFacts.cs index 2c4c2542dd..89589c595d 100644 --- a/tests/NuGetGallery.Facts/Controllers/AuthenticationControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/AuthenticationControllerFacts.cs @@ -16,6 +16,7 @@ using NuGetGallery.Authentication.Providers.MicrosoftAccount; using NuGetGallery.Infrastructure.Authentication; using Xunit; +using System.Collections.Generic; namespace NuGetGallery.Controllers { @@ -98,6 +99,88 @@ public void WillNotRedirectToTheReturnUrlWhenReturnUrlContainsAccount() } } + public class TheSigninAssistanceAction : TestContainer + { + [Fact] + public void NullUsernameReturnsFalse() + { + var controller = GetController(); + + var result = controller.SignInAssistance(username: null, providedEmailAddress: null); + dynamic data = result.Data; + Assert.False(data.success); + } + + [Theory] + [InlineData("random@address.com", "r**********m@address.com")] + [InlineData("rm@address.com", "r**********m@address.com")] + [InlineData("r@address.com", "r**********@address.com")] + [InlineData("random.very.long.address@address.com", "r**********s@address.com")] + public void NullProvidedEmailReturnsFormattedEmail(string email, string expectedEmail) + { + var cred = new CredentialBuilder().CreateExternalCredential("MicrosoftAccount", "blorg", identity: "John Doe "); + var existingUser = new User("existingUser") { EmailAddress = email, Credentials = new[] { cred } }; + + GetMock(); // Force a mock to be created + GetMock() + .Setup(u => u.FindByUsername(It.IsAny())) + .Returns(existingUser); + + var controller = GetController(); + + var result = controller.SignInAssistance(username: "existingUser", providedEmailAddress: null); + dynamic data = result.Data; + Assert.True(data.success); + Assert.Equal(expectedEmail, data.EmailAddress); + } + + [Theory] + [InlineData("blarg")] + [InlineData("wrong@email")] + [InlineData("nonmatching@emailaddress.com")] + public void InvalidProvidedEmailReturnsFalse(string providedEmail) + { + var cred = new CredentialBuilder().CreateExternalCredential("MicrosoftAccount", "blorg", identity: "existing@example.com"); + var existingUser = new User("existingUser") { EmailAddress = "existing@example.com", Credentials = new[] { cred } }; + + GetMock(); // Force a mock to be created + GetMock() + .Setup(u => u.FindByUsername(It.IsAny())) + .Returns(existingUser); + + var controller = GetController(); + + var result = controller.SignInAssistance(username: "existingUser", providedEmailAddress: providedEmail); + dynamic data = result.Data; + Assert.False(data.success); + } + + [Fact] + public void SendsNotificationForAssistance() + { + var email = "existing@example.com"; + var fakes = Get(); + var cred = new CredentialBuilder().CreateExternalCredential("MicrosoftAccount", "blorg", identity: "existing@example.com"); + var existingUser = new User("existingUser") { EmailAddress = email, Credentials = new[] { cred } }; + + GetMock(); // Force a mock to be created + GetMock() + .Setup(u => u.FindByUsername(It.IsAny())) + .Returns(existingUser); + var messageServiceMock = GetMock(); + messageServiceMock + .Setup(m => m.SendSigninAssistanceEmail(It.IsAny(), It.IsAny>())) + .Verifiable(); + + var controller = GetController(); + + var result = controller.SignInAssistance(username: "existingUser", providedEmailAddress: email); + dynamic data = result.Data; + Assert.True(data.success); + messageServiceMock.Verify(); + } + } + public class TheSignInAction : TestContainer { [Fact] diff --git a/tests/NuGetGallery.Facts/Controllers/JsonApiControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/JsonApiControllerFacts.cs index be88008f72..bff18deed1 100644 --- a/tests/NuGetGallery.Facts/Controllers/JsonApiControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/JsonApiControllerFacts.cs @@ -2,17 +2,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Net.Mail; using System.Threading.Tasks; -using System.Web; using System.Web.Mvc; using Moq; -using NuGetGallery.Configuration; using NuGetGallery.Framework; -using NuGetGallery.Security; using Xunit; namespace NuGetGallery.Controllers @@ -344,11 +339,10 @@ public async Task ReturnsFailureIfNewOwnerIsAlreadyPending(InvokePackageOwnerMod public class TheGetAddPackageOwnerConfirmationMethod : TestContainer { public static IEnumerable AllCanManagePackageOwnersPairedWithCanBeAdded_Data => TheAddPackageOwnerMethods.AllCanManagePackageOwnersPairedWithCanBeAdded_Data; - public static IEnumerable PendingOwnerPropagatesPolicy_Data => TheAddPackageOwnerMethods.PendingOwnerPropagatesPolicy_Data; [Theory] [MemberData(nameof(AllCanManagePackageOwnersPairedWithCanBeAdded_Data))] - public void ReturnsDefaultConfirmationIfNoPolicyPropagation(Func getCurrentUser, Func getUserToAdd) + public void ReturnsConfirmation(Func getCurrentUser, Func getUserToAdd) { // Arrange var fakes = Get(); @@ -365,172 +359,6 @@ public void ReturnsDefaultConfirmationIfNoPolicyPropagation(Func ge Assert.True(data.success); Assert.Equal($"Please confirm if you would like to proceed adding '{userToAdd.Username}' as a co-owner of this package.", data.confirmation); } - - [Theory] - [MemberData(nameof(AllCanManagePackageOwnersPairedWithCanBeAdded_Data))] - public void ReturnsDetailedConfirmationIfNewOwnerPropagatesPolicy(Func getCurrentUser, Func getUserToAdd) - { - // Arrange - var fakes = Get(); - var currentUser = getCurrentUser(fakes); - var userToAdd = getUserToAdd(fakes); - var controller = GetController(); - controller.SetCurrentUser(currentUser); - - userToAdd.SecurityPolicies = (new RequireSecurePushForCoOwnersPolicy().Policies).ToList(); - - // Act - var result = controller.GetAddPackageOwnerConfirmation(fakes.Package.Id, userToAdd.Username); - dynamic data = ((JsonResult)result).Data; - - // Assert - Assert.True(data.success); - Assert.StartsWith( - $"User '{userToAdd.Username}' has the following requirements that will be enforced for all co-owners once the user accepts ownership of this package:", - data.policyMessage); - } - - public static IEnumerable ReturnsDetailedConfirmationIfCurrentOwnerPropagatesPolicy_Data - { - get - { - foreach (var canManagePackageOwnersUser in _canManagePackageOwnersUsers) - { - foreach (var canBeAddedUser in _canBeAddedUsers) - { - foreach (var cannotBeAddedUser in _cannotBeAddedUsers) - { - yield return new object[] - { - canManagePackageOwnersUser, - canBeAddedUser, - cannotBeAddedUser, - }; - } - } - } - } - } - - [Theory] - [MemberData(nameof(ReturnsDetailedConfirmationIfCurrentOwnerPropagatesPolicy_Data))] - public void ReturnsDetailedConfirmationIfCurrentOwnerPropagatesPolicy(Func getCurrentUser, Func getUserToAdd, Func getExistingOwner) - { - // Arrange - var fakes = Get(); - var currentUser = getCurrentUser(fakes); - var userToAdd = getUserToAdd(fakes); - var existingOwner = getExistingOwner(fakes); - var controller = GetController(); - controller.SetCurrentUser(currentUser); - existingOwner.SecurityPolicies = (new RequireSecurePushForCoOwnersPolicy().Policies).ToList(); - - // Act - var result = controller.GetAddPackageOwnerConfirmation(fakes.Package.Id, userToAdd.Username); - dynamic data = ((JsonResult)result).Data; - - // Assert - Assert.True(data.success); - Assert.StartsWith( - $"Owner(s) '{existingOwner.Username}' has (have) the following requirements that will be enforced for user '{userToAdd.Username}' once the user accepts ownership of this package:", - data.policyMessage); - } - - [Theory] - [MemberData(nameof(AllCanManagePackageOwnersPairedWithCanBeAdded_Data))] - public void DoesNotReturnConfirmationIfCurrentOwnerPropagatesButNewOwnerIsSubscribed(Func getCurrentUser, Func getUserToAdd) - { - // Arrange - var fakes = Get(); - var currentUser = getCurrentUser(fakes); - var userToAdd = getUserToAdd(fakes); - var controller = GetController(); - controller.SetCurrentUser(currentUser); - GetMock().Setup(s => s.IsSubscribed(userToAdd, SecurePushSubscription.Name)).Returns(true); - currentUser.SecurityPolicies = (new RequireSecurePushForCoOwnersPolicy().Policies).ToList(); - - // Act - var result = controller.GetAddPackageOwnerConfirmation(fakes.Package.Id, userToAdd.Username); - dynamic data = ((JsonResult)result).Data; - - // Assert - Assert.True(data.success); - Assert.StartsWith($"Please confirm if you would like to proceed adding '{userToAdd.Username}' as a co-owner of this package.", - data.confirmation); - } - - [Theory] - [MemberData(nameof(PendingOwnerPropagatesPolicy_Data))] - public void ReturnsDetailedConfirmationIfPendingOwnerPropagatesPolicy(Func getCurrentUser, Func getUserToAdd, Func getPendingUser) - { - // Arrange - var fakes = Get(); - var currentUser = getCurrentUser(fakes); - var userToAdd = getUserToAdd(fakes); - var pendingUser = getPendingUser(fakes); - var controller = GetController(); - controller.SetCurrentUser(currentUser); - - pendingUser.SecurityPolicies = (new RequireSecurePushForCoOwnersPolicy().Policies).ToList(); - - GetMock() - .Setup(p => p.GetPackageOwnershipRequests(fakes.Package, null, null)) - .Returns((new[] - { - new PackageOwnerRequest() - { - PackageRegistration = fakes.Package, - PackageRegistrationKey = fakes.Package.Key, - NewOwner = pendingUser, - NewOwnerKey = pendingUser.Key - } - })); - - // Act - var result = controller.GetAddPackageOwnerConfirmation(fakes.Package.Id, userToAdd.Username); - dynamic data = ((JsonResult)result).Data; - - // Assert - Assert.True(data.success); - Assert.StartsWith( - $"Pending owner(s) '{pendingUser.Username}' has (have) the following requirements that will be enforced for all co-owners, including '{userToAdd.Username}', once ownership requests are accepted:", - data.policyMessage); - } - - [Theory] - [MemberData(nameof(PendingOwnerPropagatesPolicy_Data))] - public void DoesNotReturnConfirmationIfPendingOwnerPropagatesButNewOwnerIsSubscribed(Func getCurrentUser, Func getUserToAdd, Func getPendingUser) - { - // Arrange - var fakes = Get(); - var currentUser = getCurrentUser(fakes); - var userToAdd = getUserToAdd(fakes); - var pendingUser = getPendingUser(fakes); - var controller = GetController(); - controller.SetCurrentUser(currentUser); - GetMock().Setup(s => s.IsSubscribed(userToAdd, SecurePushSubscription.Name)).Returns(true); - - pendingUser.SecurityPolicies = (new RequireSecurePushForCoOwnersPolicy().Policies).ToList(); - var pendingOwnerRequest = new PackageOwnerRequest() - { - PackageRegistrationKey = fakes.Package.Key, - NewOwner = pendingUser - }; - - GetMock() - .Setup(p => p.GetPackageOwnershipRequests(fakes.Package, null, null)) - .Returns((new[] { pendingOwnerRequest })); - - // Act - var result = controller.GetAddPackageOwnerConfirmation(fakes.Package.Id, userToAdd.Username); - dynamic data = ((JsonResult)result).Data; - - // Assert - Assert.True(data.success); - Assert.StartsWith($"Please confirm if you would like to proceed adding '{userToAdd.Username}' as a co-owner of this package.", - data.confirmation); - } - } public class TheAddPackageOwnerMethod : TestContainer @@ -580,114 +408,6 @@ public async Task CreatesPackageOwnerRequestSendsEmailAndReturnsPendingState(Fun packageOwnershipManagementServiceMock.Verify(); messageServiceMock.Verify(); } - - [Theory] - [MemberData(nameof(AllCanManagePackageOwnersPairedWithCanBeAdded_Data))] - public async Task SendsPackageOwnerRequestEmailWhereNewOwnerPropagatesPolicy(Func getCurrentUser, Func getUserToAdd) - { - // Arrange & Act - var fakes = Get(); - var policyMessage = await GetSendPackageOwnerRequestPolicyMessage(fakes, getCurrentUser(fakes), getUserToAdd(fakes), getUserToAdd(fakes)); - - // Assert - Assert.StartsWith( - "Note: The following policies will be enforced on package co-owners once you accept this request.", - policyMessage); - } - - [Theory] - [MemberData(nameof(AllCanManagePackageOwnersPairedWithCanBeAdded_Data))] - public async Task SendsPackageOwnerRequestEmailWhereCurrentOwnerPropagatesPolicy(Func getCurrentUser, Func getUserToAdd) - { - // Arrange & Act - var fakes = Get(); - var policyMessage = await GetSendPackageOwnerRequestPolicyMessage(fakes, getCurrentUser(fakes), getUserToAdd(fakes), fakes.Owner); - - // Assert - Assert.StartsWith( - "Note: Owner(s) 'testPackageOwner' has (have) the following policies that will be enforced on your account once you accept this request.", - policyMessage); - } - - [Theory] - [MemberData(nameof(PendingOwnerPropagatesPolicy_Data))] - public async Task SendsPackageOwnerRequestEmailWherePendingOwnerPropagatesPolicy(Func getCurrentUser, Func getUserToAdd, Func getPendingUser) - { - // Arrange & Act - var fakes = Get(); - - var currentUser = getCurrentUser(fakes); - var userToAdd = getUserToAdd(fakes); - var pendingUser = getPendingUser(fakes); - - var packageOwnershipManagementServiceMock = GetMock(); - packageOwnershipManagementServiceMock - .Setup(p => p.GetPackageOwnershipRequests(fakes.Package, null, null)) - .Returns( - new[] - { - new PackageOwnerRequest - { - PackageRegistration = fakes.Package, - RequestingOwner = currentUser, - NewOwner = pendingUser, - ConfirmationCode = "confirmation-code" - } - }) - .Verifiable(); - - var policyMessage = await GetSendPackageOwnerRequestPolicyMessage(fakes, currentUser, userToAdd, pendingUser); - - // Assert - Assert.StartsWith( - $"Note: Pending owner(s) '{pendingUser.Username}' has (have) the following policies that will be enforced on your account once ownership requests are accepted.", - policyMessage); - } - - private async Task GetSendPackageOwnerRequestPolicyMessage(Fakes fakes, User currentUser, User userToAdd, User userToSubscribe) - { - // Arrange - var controller = GetController(); - controller.SetCurrentUser(currentUser); - - userToSubscribe.SecurityPolicies = (new RequireSecurePushForCoOwnersPolicy().Policies).ToList(); - - var packageOwnershipManagementServiceMock = GetMock(); - packageOwnershipManagementServiceMock - .Setup(p => p.AddPackageOwnershipRequestAsync(fakes.Package, currentUser, userToAdd)) - .Returns(Task.FromResult( - new PackageOwnerRequest - { - PackageRegistration = fakes.Package, - RequestingOwner = currentUser, - NewOwner = userToAdd, - ConfirmationCode = "confirmation-code" - })) - .Verifiable(); - - string actualMessage = string.Empty; - GetMock() - .Setup(m => m.SendPackageOwnerRequest( - currentUser, - userToAdd, - fakes.Package, - TestUtility.GallerySiteRootHttps + "packages/FakePackage/", - TestUtility.GallerySiteRootHttps + $"packages/FakePackage/owners/{userToAdd.Username}/confirm/confirmation-code", - TestUtility.GallerySiteRootHttps + $"packages/FakePackage/owners/{userToAdd.Username}/reject/confirmation-code", - string.Empty, - It.IsAny())) - .Callback( - (from, to, pkg, pkgUrl, cnfUrl, rjUrl, msg, policyMsg) => actualMessage = policyMsg); - - // Act - JsonResult result = await controller.AddPackageOwner(fakes.Package.Id, userToAdd.Username, string.Empty); - dynamic data = result.Data; - - // Assert - Assert.True(data.success); - Assert.False(String.IsNullOrEmpty(actualMessage)); - return actualMessage; - } } } diff --git a/tests/NuGetGallery.Facts/Controllers/OrganizationsControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/OrganizationsControllerFacts.cs index aec6d9c7c4..d66eada8dd 100644 --- a/tests/NuGetGallery.Facts/Controllers/OrganizationsControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/OrganizationsControllerFacts.cs @@ -2,9 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Net; +using System.Net.Mail; using System.Threading.Tasks; using System.Web.Mvc; using Moq; +using NuGetGallery.Framework; using Xunit; namespace NuGetGallery @@ -312,6 +314,76 @@ public async Task WhenUserIsNotMember_ReturnsNonSuccess() } } + public class TheCreateAction : TestContainer + { + private const string OrgName = "TestOrg"; + private const string OrgEmail = "TestOrg@testorg.com"; + + private AddOrganizationViewModel Model = + new AddOrganizationViewModel { OrganizationName = OrgName, OrganizationEmailAddress = OrgEmail }; + + private User Admin; + + private Fakes Fakes; + + public TheCreateAction() + { + Fakes = Get(); + Admin = Fakes.User; + } + + [Fact] + public async Task WhenAddOrganizationThrowsEntityException_ReturnsViewWithMessage() + { + var message = "message"; + + var mockUserService = GetMock(); + mockUserService + .Setup(x => x.AddOrganizationAsync(OrgName, OrgEmail, Admin)) + .Throws(new EntityException(message)); + + var controller = GetController(); + controller.SetCurrentUser(Admin); + + var result = await controller.Add(Model); + + ResultAssert.IsView(result); + Assert.Equal(message, controller.TempData["ErrorMessage"]); + } + + [Fact] + public async Task WhenAddOrganizationSucceeds_RedirectsToManageOrganization() + { + var token = "token"; + var org = new Organization("newlyCreated") + { + UnconfirmedEmailAddress = OrgEmail, + EmailConfirmationToken = token + }; + + var mockUserService = GetMock(); + mockUserService + .Setup(x => x.AddOrganizationAsync(OrgName, OrgEmail, Admin)) + .Returns(Task.FromResult(org)); + + var messageService = GetMock(); + + var controller = GetController(); + controller.SetCurrentUser(Admin); + + var result = await controller.Add(Model); + + ResultAssert.IsRedirectToRoute(result, + new { accountName = org.Username, action = nameof(OrganizationsController.ManageOrganization) }); + + messageService.Verify( + x => x.SendNewAccountEmail( + It.Is(m => m.Address == OrgEmail && m.DisplayName == org.Username), + It.Is(s => s.Contains(token))), + Times.Once()); + } + } + public class TheAddMemberAction : AccountsControllerTestContainer { private const string defaultMemberName = "member"; diff --git a/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs index 5c7ed7fd17..b81c5f6a15 100644 --- a/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/PackagesControllerFacts.cs @@ -854,11 +854,13 @@ public async Task WithOwnerReturnsAlreadyOwnerResult(InvokeOwnershipRequest invo packageService.Setup(p => p.FindPackageRegistrationById(package.Id)).Returns(package); var userService = new Mock(); userService.Setup(x => x.FindByUsername(user.Username)).Returns(user); + var packageOwnershipManagementService = new Mock(); var controller = CreateController( GetConfigurationService(), httpContext: mockHttpContext, packageService: packageService, - userService: userService); + userService: userService, + packageOwnershipManagementService: packageOwnershipManagementService); controller.SetCurrentUser(user); TestUtility.SetupHttpContextMockForUrlGeneration(mockHttpContext, controller); @@ -868,6 +870,7 @@ public async Task WithOwnerReturnsAlreadyOwnerResult(InvokeOwnershipRequest invo // Assert var model = ResultAssert.IsView(result, "ConfirmOwner"); Assert.Equal(ConfirmOwnershipResult.AlreadyOwner, model.Result); + packageOwnershipManagementService.Verify(x => x.DeletePackageOwnershipRequestAsync(package, user)); } public delegate Expression> PackageOwnershipManagementServiceRequestExpression(PackageRegistration package, User user); @@ -1005,48 +1008,75 @@ public async Task ReturnsSuccessIfTokenIsValid( public class TheCancelPendingOwnershipRequestMethod : TestContainer { + + public static IEnumerable NotOwner_Data + { + get + { + yield return MemberDataHelper.AsData((User)null, TestUtility.FakeUser); + yield return MemberDataHelper.AsData(TestUtility.FakeUser, new User { Key = 1553 }); + yield return MemberDataHelper.AsData(TestUtility.FakeOrganizationCollaborator, TestUtility.FakeOrganization); + } + } + + public static IEnumerable Owner_Data + { + get + { + yield return MemberDataHelper.AsData(TestUtility.FakeUser, TestUtility.FakeUser); + yield return MemberDataHelper.AsData(TestUtility.FakeAdminUser, TestUtility.FakeUser); + yield return MemberDataHelper.AsData(TestUtility.FakeOrganizationAdmin, TestUtility.FakeOrganization); + } + } + [Fact] - public async Task WithIdentityNotMatchingUserInRequestReturnsViewWithMessage() + public async Task WithNonExistentPackageIdReturnsHttpNotFound() { // Arrange var controller = CreateController(GetConfigurationService()); - controller.SetCurrentUser(new User("userA")); + controller.SetCurrentUser(new User { Username = "userA" }); // Act - var result = await controller.CancelPendingOwnershipRequest("foo", "userB", "userC"); + var result = await controller.CancelPendingOwnershipRequest("foo", "userA", "userB"); // Assert - var model = ResultAssert.IsView(result, "ConfirmOwner"); - Assert.Equal(ConfirmOwnershipResult.NotYourRequest, model.Result); - Assert.Equal("userB", model.Username); + Assert.IsType(result); } - [Fact] - public async Task WithNonExistentPackageIdReturnsHttpNotFound() + [Theory] + [MemberData(nameof(NotOwner_Data))] + public async Task WithNonOwningCurrentUserReturnsNotYourRequest(User currentUser, User owner) { // Arrange - var controller = CreateController(GetConfigurationService()); - controller.SetCurrentUser(new User { Username = "userA" }); + var package = new PackageRegistration { Id = "foo", Owners = new[] { owner } }; + var packageService = new Mock(); + packageService.Setup(p => p.FindPackageRegistrationById("foo")).Returns(package); + var controller = CreateController( + GetConfigurationService(), + packageService: packageService); + controller.SetCurrentUser(currentUser); // Act var result = await controller.CancelPendingOwnershipRequest("foo", "userA", "userB"); // Assert - Assert.IsType(result); + var model = ResultAssert.IsView(result, "ConfirmOwner"); + Assert.Equal(ConfirmOwnershipResult.NotYourRequest, model.Result); + Assert.Equal("userA", model.Username); } - [Fact] - public async Task WithNonExistentPendingUserReturnsHttpNotFound() + [Theory] + [MemberData(nameof(Owner_Data))] + public async Task WithNonExistentPendingUserReturnsHttpNotFound(User currentUser, User owner) { // Arrange - var package = new PackageRegistration { Id = "foo" }; - var user = new User { Username = "userA" }; + var package = new PackageRegistration { Id = "foo", Owners = new[] { owner } }; var packageService = new Mock(); packageService.Setup(p => p.FindPackageRegistrationById("foo")).Returns(package); var controller = CreateController( GetConfigurationService(), packageService: packageService); - controller.SetCurrentUser(user); + controller.SetCurrentUser(currentUser); // Act var result = await controller.CancelPendingOwnershipRequest("foo", "userA", "userB"); @@ -1055,12 +1085,13 @@ public async Task WithNonExistentPendingUserReturnsHttpNotFound() Assert.IsType(result); } - [Fact] - public async Task WithNonExistentPackageOwnershipRequestReturnsHttpNotFound() + [Theory] + [MemberData(nameof(Owner_Data))] + public async Task WithNonExistentPackageOwnershipRequestReturnsHttpNotFound(User currentUser, User owner) { // Arrange var packageId = "foo"; - var package = new PackageRegistration { Id = packageId }; + var package = new PackageRegistration { Id = packageId, Owners = new[] { owner } }; var packageService = new Mock(); packageService.Setup(p => p.FindPackageRegistrationById(packageId)).Returns(package); @@ -1079,7 +1110,7 @@ public async Task WithNonExistentPackageOwnershipRequestReturnsHttpNotFound() GetConfigurationService(), userService: userService, packageService: packageService); - controller.SetCurrentUser(userA); + controller.SetCurrentUser(owner); // Act var result = await controller.CancelPendingOwnershipRequest(packageId, userAName, userBName); @@ -1088,22 +1119,23 @@ public async Task WithNonExistentPackageOwnershipRequestReturnsHttpNotFound() Assert.IsType(result); } - [Fact] - public async Task ReturnsCancelledIfPackageOwnershipRequestExists() + [Theory] + [MemberData(nameof(Owner_Data))] + public async Task ReturnsCancelledIfPackageOwnershipRequestExists(User currentUser, User owner) { // Arrange - var packageId = "foo"; - var package = new PackageRegistration { Id = packageId }; - - var packageService = new Mock(); - packageService.Setup(p => p.FindPackageRegistrationById(packageId)).Returns(package); - var userAName = "userA"; var userA = new User { Username = userAName }; var userBName = "userB"; var userB = new User { Username = userBName }; + var packageId = "foo"; + var package = new PackageRegistration { Id = packageId, Owners = new[] { owner } }; + + var packageService = new Mock(); + packageService.Setup(p => p.FindPackageRegistrationById(packageId)).Returns(package); + var userService = new Mock(); userService.Setup(u => u.FindByUsername(userAName)).Returns(userA); userService.Setup(u => u.FindByUsername(userBName)).Returns(userB); @@ -1121,7 +1153,7 @@ public async Task ReturnsCancelledIfPackageOwnershipRequestExists() packageService: packageService, packageOwnershipManagementService: packageOwnershipManagementRequestService, messageService: messageService); - controller.SetCurrentUser(userA); + controller.SetCurrentUser(currentUser); // Act var result = await controller.CancelPendingOwnershipRequest(packageId, userAName, userBName); @@ -1136,118 +1168,6 @@ public async Task ReturnsCancelledIfPackageOwnershipRequestExists() messageService.Verify(m => m.SendPackageOwnerRequestCancellationNotice(userA, userB, package)); } } - - public class TheConfirmOwnerMethod_SecurePushPropagation : TestContainer - { - [Fact] - public async Task SubscribesOwnersToSecurePushAndSendsEmailIfNewOwnerRequires() - { - // Arrange - var fakes = Get(); - fakes.Package.Owners.Add(fakes.ShaUser); - fakes.User.SecurityPolicies = new RequireSecurePushForCoOwnersPolicy().Policies.ToList(); - - Assert.Equal(0, fakes.Owner.SecurityPolicies.Count); - - // Act & Assert - var policyMessages = await AssertConfirmOwnerSubscribesUser(fakes, fakes.Owner, fakes.ShaUser, fakes.OrganizationOwner); - Assert.Equal(4, policyMessages.Count); - - // subscribed notification - Assert.StartsWith("Owner(s) 'testUser' has (have) the following requirements that are now enforced for your account:", - policyMessages[fakes.Owner.Username]); - Assert.StartsWith("Owner(s) 'testUser' has (have) the following requirements that are now enforced for your account:", - policyMessages[fakes.ShaUser.Username]); - Assert.StartsWith("Owner(s) 'testUser' has (have) the following requirements that are now enforced for your account:", - policyMessages[fakes.OrganizationOwner.Username]); - - // propagator notification - Assert.StartsWith("Owner(s) 'testUser' has (have) the following requirements that are now enforced for co-owner(s) '", - policyMessages[fakes.User.Username]); - } - - [Fact] - public async Task SubscribesNewOwnerToSecurePushAndSendsEmailIfOwnerRequires() - { - // Arrange - var fakes = Get(); - fakes.Package.Owners.Add(fakes.ShaUser); - fakes.Owner.SecurityPolicies = new RequireSecurePushForCoOwnersPolicy().Policies.ToList(); - - Assert.Equal(0, fakes.User.SecurityPolicies.Count); - - // Act & Assert - var policyMessages = await AssertConfirmOwnerSubscribesUser(fakes, fakes.User); - - Assert.False(policyMessages.ContainsKey(fakes.User.Username)); - Assert.Equal(3, policyMessages.Count); - Assert.StartsWith("Owner(s) 'testPackageOwner' has (have) the following requirements that are now enforced for co-owner(s) 'testUser':", - policyMessages[fakes.Owner.Username]); - Assert.Equal("", policyMessages[fakes.ShaUser.Username]); - Assert.Equal("", policyMessages[fakes.OrganizationOwner.Username]); - } - - private async Task> AssertConfirmOwnerSubscribesUser(Fakes fakes, params User[] usersSubscribed) - { - // Arrange - var mockHttpContext = new Mock(); - - var packageService = new Mock(); - packageService.Setup(p => p.FindPackageRegistrationById(It.IsAny())).Returns(fakes.Package); - - var packageOwnershipManagementService = new Mock(); - packageOwnershipManagementService.Setup(p => p.GetPackageOwnershipRequest(fakes.Package, fakes.User, "token")).Returns( - new PackageOwnerRequest - { - PackageRegistration = fakes.Package, - NewOwner = fakes.User, - ConfirmationCode = "token" - }); - - var policyService = new Mock(); - foreach (var user in usersSubscribed) - { - policyService.Setup(s => s.SubscribeAsync(user, "SecurePush")) - .Returns(Task.FromResult(true)) - .Verifiable(); - } - - var userService = new Mock(); - userService.Setup(x => x.FindByUsername(fakes.User.Username)).Returns(fakes.User); - - var policyMessages = new Dictionary(); - var messageService = new Mock(); - messageService.Setup(s => s.SendPackageOwnerAddedNotice( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((toUser, newOwner, pkg, pkgUrl, policyMessage) => - { - policyMessages.Add(toUser.Username, policyMessage); - }); - - var controller = CreateController( - GetConfigurationService(), - httpContext: mockHttpContext, - packageService: packageService, - packageOwnershipManagementService: packageOwnershipManagementService, - messageService: messageService, - securityPolicyService: policyService, - userService: userService); - - controller.SetCurrentUser(fakes.User); - TestUtility.SetupHttpContextMockForUrlGeneration(mockHttpContext, controller); - - // Act - await controller.ConfirmPendingOwnershipRequest(fakes.Package.Id, fakes.User.Username, "token"); - - // Assert - foreach (var user in usersSubscribed) - { - policyService.Verify(s => s.SubscribeAsync(user, "SecurePush"), Times.Once); - } - - return policyMessages; - } - } } public class TheContactOwnersMethod diff --git a/tests/NuGetGallery.Facts/Controllers/UsersControllerFacts.cs b/tests/NuGetGallery.Facts/Controllers/UsersControllerFacts.cs index 7358e6495d..847ee24706 100644 --- a/tests/NuGetGallery.Facts/Controllers/UsersControllerFacts.cs +++ b/tests/NuGetGallery.Facts/Controllers/UsersControllerFacts.cs @@ -283,7 +283,7 @@ public async Task ShowsErrorIfUserWasNotFound() Assert.NotNull(result); Assert.IsNotType(typeof(RedirectResult), result); - Assert.Contains(Strings.CouldNotFindAnyoneWithThatUsernameOrEmail, result.ViewData.ModelState["Email"].Errors.Select(e => e.ErrorMessage)); + Assert.Contains(Strings.CouldNotFindAnyoneWithThatUsernameOrEmail, result.ViewData.ModelState[string.Empty].Errors.Select(e => e.ErrorMessage)); } [Fact] @@ -301,7 +301,7 @@ public async Task ShowsErrorIfUnconfirmedAccount() Assert.NotNull(result); Assert.IsNotType(typeof(RedirectResult), result); - Assert.Contains(Strings.UserIsNotYetConfirmed, result.ViewData.ModelState["Email"].Errors.Select(e => e.ErrorMessage)); + Assert.Contains(Strings.UserIsNotYetConfirmed, result.ViewData.ModelState[string.Empty].Errors.Select(e => e.ErrorMessage)); } [Fact] @@ -2275,8 +2275,8 @@ public async Task WhenCanTransformReturnsFalse_ShowsError() // Assert Assert.NotNull(result); - Assert.Equal(1, controller.ModelState["AdminUsername"].Errors.Count); - Assert.Equal("error", controller.ModelState["AdminUsername"].Errors.First().ErrorMessage); + Assert.Equal(1, controller.ModelState[string.Empty].Errors.Count); + Assert.Equal("error", controller.ModelState[string.Empty].Errors.First().ErrorMessage); } [Fact] @@ -2294,11 +2294,11 @@ public async Task WhenAdminIsNotFound_ShowsError() // Assert Assert.NotNull(result); - Assert.Equal(1, controller.ModelState["AdminUsername"].Errors.Count); + Assert.Equal(1, controller.ModelState[string.Empty].Errors.Count); Assert.Equal( String.Format(CultureInfo.CurrentCulture, Strings.TransformAccount_AdminAccountDoesNotExist, "AdminThatDoesNotExist"), - controller.ModelState["AdminUsername"].Errors.First().ErrorMessage); + controller.ModelState[string.Empty].Errors.First().ErrorMessage); } [Fact] diff --git a/tests/NuGetGallery.Facts/EmailValidationRegex.cs b/tests/NuGetGallery.Facts/EmailValidationRegex.cs index 2d4e08fb4e..33b00124fc 100644 --- a/tests/NuGetGallery.Facts/EmailValidationRegex.cs +++ b/tests/NuGetGallery.Facts/EmailValidationRegex.cs @@ -19,7 +19,7 @@ public class EmailValidationRegex [InlineData("a@b.c.d.e.f")] public void TheWholeAllows(string address) { - var match = new Regex(RegisterViewModel.EmailValidationRegex).IsMatch(address); + var match = new Regex(Constants.EmailValidationRegex).IsMatch(address); Assert.True(match); } @@ -29,7 +29,7 @@ public void TheWholeAllows(string address) [InlineData("fred@.com")] public void TheWholeDoesntAllow(string testWhole) { - var match = new Regex(RegisterViewModel.EmailValidationRegex).IsMatch(testWhole); + var match = new Regex(Constants.EmailValidationRegex).IsMatch(testWhole); Assert.False(match); } @@ -43,7 +43,7 @@ public void TheWholeDoesntAllow(string testWhole) [InlineData("fred~`'.baz")] public void TheFirstPartMatches(string testFirstPart) { - var match = new Regex("^" + RegisterViewModel.FirstPart + "$").IsMatch(testFirstPart); + var match = new Regex("^" + Constants.EmailValidationRegexFirstPart + "$").IsMatch(testFirstPart); Assert.True(match); } @@ -58,7 +58,7 @@ public void TheFirstPartMatches(string testFirstPart) [InlineData("abc.\"def\\\"\"ghi\".xyz")] // thanks Wikipedia, but in practice nobody uses these email addresses. public void TheFirstPartDoesntAllow(string testFirstPart) { - var match = new Regex("^" + RegisterViewModel.FirstPart + "$").IsMatch(testFirstPart); + var match = new Regex("^" + Constants.EmailValidationRegexFirstPart + "$").IsMatch(testFirstPart); Assert.False(match); } @@ -71,7 +71,7 @@ public void TheFirstPartDoesntAllow(string testFirstPart) [InlineData("a.b.c.d.e.f")] public void TheSecondPartMatches(string testSecondPart) { - var match = new Regex("^" + RegisterViewModel.SecondPart + "$").IsMatch(testSecondPart); + var match = new Regex("^" + Constants.EmailValidationRegexSecondPart + "$").IsMatch(testSecondPart); Assert.True(match); } @@ -83,7 +83,7 @@ public void TheSecondPartMatches(string testSecondPart) [InlineData("[IPv6:2001:db8:1ff::a0b:dbd0]")] //no IP addresses public void TheSecondPartDoesntAllow(string testSecondPart) { - var match = new Regex("^" + RegisterViewModel.SecondPart + "$").IsMatch(testSecondPart); + var match = new Regex("^" + Constants.EmailValidationRegexSecondPart + "$").IsMatch(testSecondPart); Assert.False(match); } } diff --git a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj index 6482879b1a..654fce1407 100644 --- a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj +++ b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj @@ -465,10 +465,8 @@ - - - + diff --git a/tests/NuGetGallery.Facts/Security/DefaultSubscriptionFacts.cs b/tests/NuGetGallery.Facts/Security/DefaultSubscriptionFacts.cs new file mode 100644 index 0000000000..1c46155fb7 --- /dev/null +++ b/tests/NuGetGallery.Facts/Security/DefaultSubscriptionFacts.cs @@ -0,0 +1,48 @@ +// 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 System.Threading.Tasks; +using Moq; +using NuGetGallery.Auditing; +using Xunit; + +namespace NuGetGallery.Security +{ + public class DefaultSubscriptionFacts + { + [Fact] + public void Policies_ReturnsMinClientAndPackageVerifyScopePolicies() + { + // Arrange. + var subscription = CreateSecurityPolicyService().UserSubscriptions.Single(); + var policy1 = subscription.Policies.FirstOrDefault(p => p.Name.Equals(RequireMinProtocolVersionForPushPolicy.PolicyName)); + var policy2 = subscription.Policies.FirstOrDefault(p => p.Name.Equals(RequirePackageVerifyScopePolicy.PolicyName)); + + // Act & Assert. + Assert.Equal(2, subscription.Policies.Count()); + Assert.NotNull(policy1); + Assert.NotNull(policy2); + Assert.Equal("{\"v\":\"4.1.0\"}", policy1.Value); + } + + private TestSecurityPolicyService CreateSecurityPolicyService() + { + var auditing = new Mock(); + auditing.Setup(s => s.SaveAuditRecordAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + + var subscription = new DefaultSubscription(); + + var service = new TestSecurityPolicyService( + mockAuditing: auditing, + userHandlers: new UserSecurityPolicyHandler[] + { + new RequireMinProtocolVersionForPushPolicy(), + new RequirePackageVerifyScopePolicy() + }, + userSubscriptions: new[] { subscription }); + + return service; + } + } +} diff --git a/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs b/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs deleted file mode 100644 index 18f2a82259..0000000000 --- a/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs +++ /dev/null @@ -1,138 +0,0 @@ -// 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.Specialized; -using System.Linq; -using System.Web; -using Moq; -using NuGet.Versioning; -using Xunit; - -namespace NuGetGallery.Security -{ - /// - /// This code should be removed soon: https://github.com/NuGet/Engineering/issues/800 - /// - public class RequireMinClientVersionForPushPolicyFacts - { - [Theory] - [InlineData("4.1.0")] - [InlineData("3.0.0")] - [InlineData("2.0.0,4.1.0")] - [InlineData("4.1.0-beta1")] - public void Evaluate_ReturnsSuccessIfClientVersionEqualOrHigherThanRequired(string minClientVersions) - { - // Arrange & Act - var result = Evaluate(minClientVersions, actualClientVersion: "4.1.0", actualProtocolVersion: string.Empty); - - // Assert - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - } - - [Theory] - [InlineData("4.1.0")] - [InlineData("3.0.0")] - [InlineData("2.0.0,4.1.0")] - [InlineData("2.5.0")] - public void Evaluate_ReturnsFailureIfClientVersionLowerThanRequired(string minClientVersions) - { - // Arrange & Act - var result = Evaluate(minClientVersions, actualClientVersion: "2.5.0-beta1", actualProtocolVersion: string.Empty); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - } - - [Theory] - [InlineData("4.1.0")] - [InlineData("3.0.0")] - [InlineData("2.0.0,4.1.0")] - [InlineData("4.1.0-beta1")] - public void Evaluate_ReturnsSuccessIfProtocolVersionEqualOrHigherThanRequired(string minClientVersions) - { - // Arrange & Act - var result = Evaluate(minClientVersions, actualClientVersion: string.Empty, actualProtocolVersion: "4.1.0"); - - // Assert - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - } - - [Theory] - [InlineData("4.1.0")] - [InlineData("3.0.0")] - [InlineData("2.0.0,4.1.0")] - [InlineData("2.5.0")] - public void Evaluate_ReturnsFailureIfProtocolVersionLowerThanRequired(string minClientVersions) - { - // Arrange & Act - var result = Evaluate(minClientVersions, actualClientVersion: string.Empty, actualProtocolVersion: "2.5.0-beta1"); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - } - - [Fact] - public void Evaluate_ReturnsFailureIfProtocolVersionLowerThenRequiredAndClientVersionHigher() - { - // Arrange & Act - var result = Evaluate("4.1.0", actualClientVersion: "4.1.0", actualProtocolVersion: "2.5.0-beta1"); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - } - - [Fact] - public void Evaluate_ReturnsSuccessIfProtocolVersionHigherThenRequiredAndClientVersionLower() - { - // Arrange & Act - var result = Evaluate("4.1.0", actualClientVersion: "2.5.0-beta1", actualProtocolVersion: "4.1.0"); - - // Assert - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - } - - [Fact] - public void Evaluate_ReturnsFailureIfClientVersionHeaderIsMissing() - { - // Arrange & Act - var result = Evaluate(minClientVersions: "4.1.0", actualClientVersion: string.Empty, actualProtocolVersion: string.Empty); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - } - - private SecurityPolicyResult Evaluate(string minClientVersions, string actualClientVersion, string actualProtocolVersion) - { - var headers = new NameValueCollection(); - if (!string.IsNullOrEmpty(actualClientVersion)) - { - headers[Constants.ClientVersionHeaderName] = actualClientVersion; - } - - if (!string.IsNullOrEmpty(actualProtocolVersion)) - { - headers[Constants.NuGetProtocolHeaderName] = actualProtocolVersion; - } - - var httpRequest = new Mock(); - httpRequest.Setup(r => r.Headers).Returns(headers); - - var httpContext = new Mock(); - httpContext.Setup(c => c.Request).Returns(httpRequest.Object); - - var policies = minClientVersions.Split(',').Select( - v => RequireMinClientVersionForPushPolicy.CreatePolicy("Subscription", new NuGetVersion(v)) - ).ToArray(); - var context = new UserSecurityPolicyEvaluationContext(policies, httpContext.Object); - - return new RequireMinClientVersionForPushPolicy().Evaluate(context); - } - } -} diff --git a/tests/NuGetGallery.Facts/Security/RequireSecurePushForCoOwnersPolicyFacts.cs b/tests/NuGetGallery.Facts/Security/RequireSecurePushForCoOwnersPolicyFacts.cs deleted file mode 100644 index d5fd194402..0000000000 --- a/tests/NuGetGallery.Facts/Security/RequireSecurePushForCoOwnersPolicyFacts.cs +++ /dev/null @@ -1,60 +0,0 @@ -// 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 NuGetGallery.Framework; -using Xunit; - -namespace NuGetGallery.Security -{ - public class RequireSecurePushForCoOwnersPolicyFacts : TestContainer - { - private RequireSecurePushForCoOwnersPolicy _policy; - - public RequireSecurePushForCoOwnersPolicyFacts() - { - _policy = new RequireSecurePushForCoOwnersPolicy(); - } - - [Fact] - public void PolicyHandlerName_ReturnsExpected() - { - Assert.Equal("RequireSecurePushForCoOwnersPolicy", _policy.Name); - } - - [Fact] - public void SubscriptionName_ReturnsExpected() - { - Assert.Equal("SecurePushForCoOwners", _policy.SubscriptionName); - } - - [Fact] - public void PolicyHandlerPolicies_ReturnsExpected() - { - Assert.Equal(1, _policy.Policies.Count()); - - var policyModel = _policy.Policies.FirstOrDefault(); - Assert.NotNull(policyModel); - - Assert.Equal("RequireSecurePushForCoOwnersPolicy", policyModel.Name); - Assert.Equal("SecurePushForCoOwners", policyModel.Subscription); - } - - [Fact] - public void IsSubscribed_ReturnsFalseIfNotSubscribed() - { - var fakes = Get(); - - Assert.False(RequireSecurePushForCoOwnersPolicy.IsSubscribed(fakes.User)); - } - - [Fact] - public void IsSubscribed_ReturnsTrueIfSubscribed() - { - var fakes = Get(); - fakes.User.SecurityPolicies = _policy.Policies.ToList(); - - Assert.True(RequireSecurePushForCoOwnersPolicy.IsSubscribed(fakes.User)); - } - } -} diff --git a/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs b/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs deleted file mode 100644 index 381eff3655..0000000000 --- a/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs +++ /dev/null @@ -1,156 +0,0 @@ -// 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 System.Threading.Tasks; -using Moq; -using Newtonsoft.Json; -using NuGet.Packaging; -using NuGetGallery.Auditing; -using NuGetGallery.Diagnostics; -using Xunit; - -namespace NuGetGallery.Security -{ - public class SecurePushSubscriptionFacts - { - private int _expectedPushKeyExpiration = 30; - - [Fact] - public void SecurePushSubscription_IsRegisteredWithSecurityPolicyService() - { - // Arrange. - var subscriptions = CreateSecurityPolicyService().UserSubscriptions; - - // Act & Assert. - Assert.Equal(1, subscriptions.Count()); - Assert.Equal("SecurePush", subscriptions.Single().SubscriptionName); - } - - [Fact] - public void Policies_ReturnsMinClientAndPackageVerifyScopePolicies() - { - // Arrange. - var subscription = CreateSecurityPolicyService().UserSubscriptions.Single(); - var policy1 = subscription.Policies.FirstOrDefault(p => p.Name.Equals(RequireMinProtocolVersionForPushPolicy.PolicyName)); - var policy2 = subscription.Policies.FirstOrDefault(p => p.Name.Equals(RequirePackageVerifyScopePolicy.PolicyName)); - - // Act & Assert. - Assert.Equal(2, subscription.Policies.Count()); - Assert.NotNull(policy1); - Assert.NotNull(policy2); - Assert.Equal("{\"v\":\"4.1.0\"}", policy1.Value); - } - - [Theory] - [InlineData("")] - [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] - [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] - public async Task OnSubscribeAsync_ExpiresPushApiKeys(string scopes) - { - // Arrange & Act. - var user = (await SubscribeUserToSecurePushAsync(CredentialTypes.ApiKey.V2, scopes)).Item1; - - // Assert. - Assert.Equal(2, user.SecurityPolicies.Count()); - Assert.True(DateTime.UtcNow.AddDays(_expectedPushKeyExpiration) >= user.Credentials.Single().Expires); - } - - [Theory] - [InlineData("password.v3", "")] - [InlineData("apikey.v2", "[{\"a\":\"package:unlist\", \"s\":\"theId\"}]")] - [InlineData("apikey.verify.v1", "[{\"a\":\"package:verify\", \"s\":\"theId\"}]")] - public async Task OnSubscribeAsync_DoesNotExpireNonPushCredentials(string type, string scopes) - { - // Arrange & Act. - var user = (await SubscribeUserToSecurePushAsync(type, scopes)).Item1; - - // Assert. - Assert.Equal(2, user.SecurityPolicies.Count()); - Assert.False(DateTime.UtcNow.AddDays(_expectedPushKeyExpiration) >= user.Credentials.Single().Expires); - } - - [Theory] - [InlineData("")] - [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] - [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] - public async Task OnSubscribeAsync_DoesNotChangeExpiringPushCredentials(string scopes) - { - // Arrange & Act. - var user = (await SubscribeUserToSecurePushAsync(CredentialTypes.ApiKey.V2,scopes, expiresInDays: 2)).Item1; - - // Assert. - Assert.Equal(2, user.SecurityPolicies.Count()); - Assert.True(DateTime.UtcNow.AddDays(2) >= user.Credentials.Single().Expires); - } - - [Fact] - public async Task OnSubscribeAsync_SavesAuditRecordIfKeysSetToExpire() - { - // Arrange & Act. - var service = (await SubscribeUserToSecurePushAsync(CredentialTypes.ApiKey.V2, "")).Item2; - - // Assert. - service.MockAuditingService.Verify(s => s.SaveAuditRecordAsync(It.IsAny()), - /* subscription and key expiration */Times.Exactly(2)); - } - - [Fact] - public async Task OnSubscribeAsync_DoesNotSaveAuditRecordIfKeysNotSetToExpire() - { - // Arrange & Act. - var service = (await SubscribeUserToSecurePushAsync(CredentialTypes.ApiKey.V2, "", expiresInDays: 2)).Item2; - - // Assert. - service.MockAuditingService.Verify(s => s.SaveAuditRecordAsync(It.IsAny()), - /* subscription only */ Times.Once); - } - - private TestSecurityPolicyService CreateSecurityPolicyService() - { - var auditing = new Mock(); - auditing.Setup(s => s.SaveAuditRecordAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); - - var diagnostics = new DiagnosticsService().GetSource(nameof(SecurePushSubscriptionFacts)); - var diagnosticsService = new Mock(); - diagnosticsService.Setup(s => s.GetSource(It.IsAny())).Returns(diagnostics); - - var subscription = new SecurePushSubscription(auditing.Object, diagnosticsService.Object); - - var service = new TestSecurityPolicyService( - mockAuditing: auditing, - userHandlers: new UserSecurityPolicyHandler[] - { - new RequireMinClientVersionForPushPolicy(), - new RequirePackageVerifyScopePolicy() - }, - userSubscriptions: new[] { subscription }); - - return service; - } - - private async Task> SubscribeUserToSecurePushAsync( - string type, string scopes, int expiresInDays = 100) - { - // Arrange. - var service = CreateSecurityPolicyService(); - - var credential = new Credential(type, string.Empty, TimeSpan.FromDays(expiresInDays)); - if (!string.IsNullOrWhiteSpace(scopes)) - { - credential.Scopes.AddRange(JsonConvert.DeserializeObject>(scopes)); - } - var user = new User(); - user.Credentials.Add(credential); - - // Act. - await service.SubscribeAsync(user, service.UserSubscriptions.Single()); - - service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Once); - - return Tuple.Create(user, service); - } - } -} diff --git a/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs b/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs index 98a081ec3b..9e07ddfa1a 100644 --- a/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs @@ -55,11 +55,10 @@ public void UserHandlers_ReturnsRegisteredUserSecurityPolicyHandlers() // Assert Assert.NotNull(handlers); - Assert.Equal(4, handlers.Count); - Assert.Equal(typeof(RequireMinClientVersionForPushPolicy), handlers[0].GetType()); - Assert.Equal(typeof(RequirePackageVerifyScopePolicy), handlers[1].GetType()); - Assert.Equal(typeof(RequireMinProtocolVersionForPushPolicy), handlers[2].GetType()); - Assert.Equal(typeof(RequireOrganizationTenantPolicy), handlers[3].GetType()); + Assert.Equal(3, handlers.Count); + Assert.Equal(typeof(RequirePackageVerifyScopePolicy), handlers[0].GetType()); + Assert.Equal(typeof(RequireMinProtocolVersionForPushPolicy), handlers[1].GetType()); + Assert.Equal(typeof(RequireOrganizationTenantPolicy), handlers[2].GetType()); } [Fact] diff --git a/tests/NuGetGallery.Facts/Services/AsynchronousPackageValidationInitiatorFacts.cs b/tests/NuGetGallery.Facts/Services/AsynchronousPackageValidationInitiatorFacts.cs index b8db017fba..1e32385128 100644 --- a/tests/NuGetGallery.Facts/Services/AsynchronousPackageValidationInitiatorFacts.cs +++ b/tests/NuGetGallery.Facts/Services/AsynchronousPackageValidationInitiatorFacts.cs @@ -1,6 +1,7 @@ // 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.Threading.Tasks; using Moq; @@ -41,7 +42,7 @@ public async Task UsesProvidedPackageIdAndVersion() // Assert _enqueuer.Verify( - x => x.StartValidationAsync(It.IsAny()), + x => x.StartValidationAsync(It.IsAny(), It.IsAny()), Times.Once); Assert.Equal(1, _data.Count); Assert.NotNull(_data[0]); @@ -125,9 +126,9 @@ public FactsBase() { _enqueuer = new Mock(); _enqueuer - .Setup(x => x.StartValidationAsync(It.IsAny())) + .Setup(x => x.StartValidationAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask) - .Callback(x => _data.Add(x)); + .Callback((d, o) => _data.Add(d)); _appConfiguration = new Mock(); _appConfiguration diff --git a/tests/NuGetGallery.Facts/Services/ContentObjectServiceFacts.cs b/tests/NuGetGallery.Facts/Services/ContentObjectServiceFacts.cs index c9a79b5c4e..d50e3b6986 100644 --- a/tests/NuGetGallery.Facts/Services/ContentObjectServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/ContentObjectServiceFacts.cs @@ -32,8 +32,9 @@ public async Task RefreshRefreshesObject() var emails = new[] { "discontinued@different.com" }; var domains = new[] { "example.com" }; var exceptions = new[] { "exception@example.com" }; + var shouldTransforms = new[] { "transfomer@example.com" }; - var config = new LoginDiscontinuationConfiguration(emails, domains, exceptions); + var config = new LoginDiscontinuationConfiguration(emails, domains, exceptions, shouldTransforms); var configString = JsonConvert.SerializeObject(config); GetMock() diff --git a/tests/NuGetGallery.Facts/Services/FileSystemFileStorageServiceFacts.cs b/tests/NuGetGallery.Facts/Services/FileSystemFileStorageServiceFacts.cs index 7395046d1c..f3002b835b 100644 --- a/tests/NuGetGallery.Facts/Services/FileSystemFileStorageServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/FileSystemFileStorageServiceFacts.cs @@ -290,6 +290,117 @@ public async Task WillReturnNullWhenRequestedFileDoesNotExist() } } + public class TheCopyFileAsyncMethod : IDisposable + { + private readonly TestDirectory _directory; + private readonly string _srcFolderName; + private readonly string _srcFileName; + private readonly string _destFolderName; + private readonly string _destFileName; + private readonly Mock _appConfiguration; + private readonly Mock _fileSystemService; + private readonly FileSystemFileStorageService _target; + + public TheCopyFileAsyncMethod() + { + _directory = TestDirectory.Create(); + + _srcFolderName = "validation"; + _srcFileName = "4b6f16cc-7acd-45eb-ac21-33f0d927ec14/nuget.versioning.4.5.0.nupkg"; + _destFolderName = "packages"; + _destFileName = "nuget.versioning.4.5.0.nupkg"; + + _appConfiguration = new Mock(); + _appConfiguration + .Setup(x => x.FileStorageDirectory) + .Returns(() => _directory); + + _fileSystemService = new Mock { CallBase = true }; + + _target = new FileSystemFileStorageService( + _appConfiguration.Object, + _fileSystemService.Object); + } + + [Fact] + public async Task CopiesFileWhenDestinationDoesNotExist() + { + // Arrange + var content = "Hello, world!"; + await _target.SaveFileAsync( + _srcFolderName, + _srcFileName, + new MemoryStream(Encoding.ASCII.GetBytes(content))); + + // Act + await _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + destAccessCondition: null); + + // Assert + using (var destStream = await _target.GetFileAsync(_destFolderName, _destFileName)) + using (var reader = new StreamReader(destStream)) + { + Assert.Equal(content, reader.ReadToEnd()); + } + } + + [Fact] + public async Task ThrowsWhenDestinationIsSame() + { + // Arrange + var content = "Hello, world!"; + await _target.SaveFileAsync( + _srcFolderName, + _srcFileName, + new MemoryStream(Encoding.ASCII.GetBytes(content))); + await _target.SaveFileAsync( + _destFolderName, + _destFileName, + new MemoryStream(Encoding.ASCII.GetBytes(content))); + + await Assert.ThrowsAsync( + () => _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + destAccessCondition: null)); + } + + [Fact] + public async Task ThrowsWhenFileAlreadyExists() + { + // Arrange + var content = "Hello, world!"; + await _target.SaveFileAsync( + _srcFolderName, + _srcFileName, + new MemoryStream(Encoding.ASCII.GetBytes(content))); + await _target.SaveFileAsync( + _destFolderName, + _destFileName, + new MemoryStream(Encoding.ASCII.GetBytes("Something else."))); + + // Act & Assert + await Assert.ThrowsAsync( + () => _target.CopyFileAsync( + _srcFolderName, + _srcFileName, + _destFolderName, + _destFileName, + destAccessCondition: null)); + } + + public void Dispose() + { + _directory?.Dispose(); + } + } + public class TheSaveFileMethod { private const string FolderName = "theFolderName"; diff --git a/tests/NuGetGallery.Facts/Services/LoginDiscontinuationConfigurationFacts.cs b/tests/NuGetGallery.Facts/Services/LoginDiscontinuationConfigurationFacts.cs index 7010264f90..c90882cdfc 100644 --- a/tests/NuGetGallery.Facts/Services/LoginDiscontinuationConfigurationFacts.cs +++ b/tests/NuGetGallery.Facts/Services/LoginDiscontinuationConfigurationFacts.cs @@ -10,14 +10,44 @@ namespace NuGetGallery.Services { public class LoginDiscontinuationConfigurationFacts { - public class TheIsLoginDiscontinuedMethod : TestContainer + private const string _incorrectEmail = "incorrect@notExample.com"; + private const string _incorrectDomain = "notExample.com"; + private const string _domain = "example.com"; + private const string _incorrectException = "fake@notExample.com"; + private const string _email = "test@example.com"; + + public static IEnumerable PossibleListStates + { + get + { + foreach (var isOnWhiteList in new[] { true, false }) + { + foreach (var isOnDomainList in new[] { true, false }) + { + foreach (var isOnExceptionList in new[] { true, false }) + { + foreach (var isOnTransformList in new[] { true, false }) + { + yield return MemberDataHelper.AsData(isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList); + } + } + } + } + } + } + + public static ILoginDiscontinuationConfiguration CreateConfiguration(bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList, bool isOnTransformList) { - private const string _incorrectEmail = "incorrect@notExample.com"; - private const string _incorrectDomain = "notExample.com"; - private const string _domain = "example.com"; - private const string _incorrectException = "fake@notExample.com"; - private const string _email = "test@example.com"; + var emails = isOnWhiteList ? new[] { _email } : new[] { _incorrectEmail }; + var domains = isOnDomainList ? new[] { _domain } : new[] { _incorrectDomain }; + var exceptions = isOnExceptionList ? new[] { _email } : new[] { _incorrectException }; + var shouldTransforms = isOnTransformList ? new[] { _email } : new[] { _incorrectException }; + return new LoginDiscontinuationConfiguration(emails, domains, exceptions, shouldTransforms); + } + + public class TheIsLoginDiscontinuedMethod + { public static IEnumerable IfPasswordLoginReturnsTrueIfOnWhitelists_Data { get @@ -33,7 +63,10 @@ public static IEnumerable IfPasswordLoginReturnsTrueIfOnWhitelists_Dat { foreach (var isOnExceptionList in new[] { true, false }) { - yield return MemberDataHelper.AsData(credentialPasswordType, isOnWhiteList, isOnDomainList, isOnExceptionList); + foreach (var isOnTransformList in new[] { true, false }) + { + yield return MemberDataHelper.AsData(credentialPasswordType, isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList); + } } } } @@ -43,9 +76,9 @@ public static IEnumerable IfPasswordLoginReturnsTrueIfOnWhitelists_Dat [Theory] [MemberData(nameof(IfPasswordLoginReturnsTrueIfOnWhitelists_Data))] - public void IfPasswordLoginReturnsTrueIfOnWhitelists(string credentialPasswordType, bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList) + public void IfPasswordLoginReturnsTrueIfOnWhitelists(string credentialPasswordType, bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList, bool isOnTransformList) { - TestIsLoginDiscontinued(credentialPasswordType, isOnWhiteList, isOnDomainList, isOnExceptionList, + TestIsLoginDiscontinued(credentialPasswordType, isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList, expectedResult: (isOnWhiteList || isOnDomainList) && !isOnExceptionList); } @@ -68,7 +101,10 @@ public static IEnumerable IfNotPasswordLoginReturnsFalse_Data { foreach (var isOnExceptionList in new[] { true, false }) { - yield return MemberDataHelper.AsData(credentialType, isOnWhiteList, isOnDomainList, isOnExceptionList); + foreach (var isOnTransformList in new[] { true, false }) + { + yield return MemberDataHelper.AsData(credentialType, isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList); + } } } } @@ -78,23 +114,19 @@ public static IEnumerable IfNotPasswordLoginReturnsFalse_Data [Theory] [MemberData(nameof(IfNotPasswordLoginReturnsFalse_Data))] - public void IfNotPasswordLoginReturnsFalse(string credentialType, bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList) + public void IfNotPasswordLoginReturnsFalse(string credentialType, bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList, bool isOnTransformList) { - TestIsLoginDiscontinued(credentialType, isOnWhiteList, isOnDomainList, isOnExceptionList, expectedResult: false); + TestIsLoginDiscontinued(credentialType, isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList, expectedResult: false); } - private void TestIsLoginDiscontinued(string credentialType, bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList, bool expectedResult) + private void TestIsLoginDiscontinued(string credentialType, bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList, bool isOnTransformList, bool expectedResult) { // Arrange var credential = new Credential(credentialType, "value"); var user = new User("test") { EmailAddress = _email, Credentials = new[] { credential } }; var authUser = new AuthenticatedUser(user, credential); - var emails = isOnWhiteList ? new[] { _email } : new[] { _incorrectEmail }; - var domains = isOnDomainList ? new[] { _domain } : new[] { _incorrectDomain }; - var exceptions = isOnExceptionList ? new[] { _email } : new[] { _incorrectException }; - - var config = new LoginDiscontinuationConfiguration(emails, domains, exceptions); + var config = CreateConfiguration(isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList); // Act var result = config.IsLoginDiscontinued(authUser); @@ -103,5 +135,37 @@ private void TestIsLoginDiscontinued(string credentialType, bool isOnWhiteList, Assert.Equal(expectedResult, result); } } + + public class TheSupportedForUserMethods + { + public static IEnumerable PossibleListStates => PossibleListStates; + + public void IsSupportedAsExpected(bool isOnWhiteList, bool isOnDomainList, bool isOnExceptionList, bool isOnTransformList) + { + // Arrange + var user = new User("test") { EmailAddress = _email }; + + var config = CreateConfiguration(isOnWhiteList, isOnDomainList, isOnExceptionList, isOnTransformList); + + // Act + var areOrganizationsSupported = config.AreOrganizationsSupportedForUser(user); + var shouldTransform = config.ShouldUserTransformIntoOrganization(user); + + // Assert + Assert.Equal(isOnWhiteList || isOnDomainList, areOrganizationsSupported); + Assert.Equal(isOnTransformList, shouldTransform); + } + + public void IsUnsupportedWhenNull() + { + var config = new LoginDiscontinuationConfiguration(); + + var areOrganizationsSupported = config.AreOrganizationsSupportedForUser(null); + var shouldTransform = config.ShouldUserTransformIntoOrganization(null); + + Assert.False(areOrganizationsSupported); + Assert.False(shouldTransform); + } + } } } diff --git a/tests/NuGetGallery.Facts/Services/PackageDeleteServiceFacts.cs b/tests/NuGetGallery.Facts/Services/PackageDeleteServiceFacts.cs index 753e89928f..ac3639b5bf 100644 --- a/tests/NuGetGallery.Facts/Services/PackageDeleteServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/PackageDeleteServiceFacts.cs @@ -698,6 +698,23 @@ public async Task WillCreateAuditRecordUsingAuditService() Assert.Equal(package.Version, testService.LastAuditRecord.Version); auditingService.Verify(x => x.SaveAuditRecordAsync(testService.LastAuditRecord)); } + + [Fact] + public async Task EmitsTelemetry() + { + var telemetryService = new Mock(); + var service = CreateService(telemetryService: telemetryService); + var packageRegistration = new PackageRegistration(); + var package = new Package { PackageRegistration = packageRegistration, Version = "1.0.0", Hash = _packageHashForTests }; + packageRegistration.Packages.Add(package); + var user = new User("test"); + var reason = "Unit testing"; + var signature = "The Terminator"; + + await service.SoftDeletePackagesAsync(new[] { package }, user, reason, signature); + + telemetryService.Verify(x => x.TrackPackageDelete(package, false)); + } } public class TheHardDeletePackagesAsyncMethod @@ -953,6 +970,23 @@ public async Task WillCreateAuditRecordUsingAuditService() Assert.Equal(package.Version, testService.LastAuditRecord.Version); auditingService.Verify(x => x.SaveAuditRecordAsync(testService.LastAuditRecord)); } + + [Fact] + public async Task EmitsTelemetry() + { + var telemetryService = new Mock(); + var service = CreateService(telemetryService: telemetryService); + var packageRegistration = new PackageRegistration(); + var package = new Package { PackageRegistration = packageRegistration, Version = "1.0.0", Hash = _packageHashForTests }; + packageRegistration.Packages.Add(package); + var user = new User("test"); + var reason = "Unit testing"; + var signature = "The Terminator"; + + await service.HardDeletePackagesAsync(new[] { package }, user, reason, signature, deleteEmptyPackageRegistration: false); + + telemetryService.Verify(x => x.TrackPackageDelete(package, true)); + } } public class TheReflowHardDeletedPackagesAsyncMethod @@ -998,7 +1032,13 @@ private async Task ReflowHardDeletedPackage(string id, string version, string ex var auditingService = new Mock(); - var service = CreateService(packageRepository: packageRepository, packageRegistrationRepository: packageRegistrationRepository, auditingService: auditingService); + var telemetryService = new Mock(); + + var service = CreateService( + packageRepository: packageRepository, + packageRegistrationRepository: packageRegistrationRepository, + auditingService: auditingService, + telemetryService: telemetryService); if (succeeds) { @@ -1009,7 +1049,13 @@ private async Task ReflowHardDeletedPackage(string id, string version, string ex await Assert.ThrowsAsync(() => service.ReflowHardDeletedPackageAsync(id, version)); } - auditingService.Verify(x => x.SaveAuditRecordAsync(It.IsAny()), succeeds ? Times.Once() : Times.Never()); + auditingService.Verify( + x => x.SaveAuditRecordAsync(It.IsAny()), + succeeds ? Times.Once() : Times.Never()); + + telemetryService.Verify( + x => x.TrackPackageHardDeleteReflow(id, version), + succeeds ? Times.Once() : Times.Never()); } } } diff --git a/tests/NuGetGallery.Facts/Services/PackageFileServiceFacts.cs b/tests/NuGetGallery.Facts/Services/PackageFileServiceFacts.cs index e953fd0847..b5188b2744 100644 --- a/tests/NuGetGallery.Facts/Services/PackageFileServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/PackageFileServiceFacts.cs @@ -180,139 +180,6 @@ public async Task WillReturnTheResultFromTheFileStorageService() Assert.Equal(fakeResult, result); } } - - public class TheStorePackageFileInBackupLocationAsyncMethod - { - private string packageHashForTests = "NzMzMS1QNENLNEczSDQ1SA=="; - - [Fact] - public async Task WillThrowIfPackageIsNull() - { - var service = CreateService(); - - var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(null, Stream.Null)); - - Assert.Equal("package", ex.ParamName); - } - - [Fact] - public async Task WillThrowIfPackageFileIsNull() - { - var service = CreateService(); - - var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(new Package { PackageRegistration = new PackageRegistration() }, null)); - - Assert.Equal("packageFile", ex.ParamName); - } - - [Fact] - public async Task WillThrowIfPackageIsMissingPackageRegistration() - { - var service = CreateService(); - var package = new Package { PackageRegistration = null }; - - var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream())); - - Assert.True(ex.Message.StartsWith("The package is missing required data.")); - Assert.Equal("package", ex.ParamName); - } - - [Fact] - public async Task WillThrowIfPackageIsMissingPackageRegistrationId() - { - var service = CreateService(); - var packageRegistraion = new PackageRegistration { Id = null }; - var package = new Package { PackageRegistration = packageRegistraion }; - - var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream())); - - Assert.True(ex.Message.StartsWith("The package is missing required data.")); - Assert.Equal("package", ex.ParamName); - } - - [Fact] - public async Task WillThrowIfPackageIsMissingNormalizedVersionAndVersion() - { - var service = CreateService(); - var packageRegistraion = new PackageRegistration { Id = "theId" }; - var package = new Package { PackageRegistration = packageRegistraion, NormalizedVersion = null, Version = null }; - - var ex = await Assert.ThrowsAsync(() => service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream())); - - Assert.True(ex.Message.StartsWith("The package is missing required data.")); - Assert.Equal("package", ex.ParamName); - } - - [Fact] - public async Task WillUseNormalizedRegularVersionIfNormalizedVersionMissing() - { - var fileStorageSvc = new Mock(); - var service = CreateService(fileStorageSvc: fileStorageSvc); - var packageRegistraion = new PackageRegistration { Id = "theId" }; - var package = new Package { PackageRegistration = packageRegistraion, NormalizedVersion = null, Version = "01.01.01", Hash = packageHashForTests}; - package.Hash = packageHashForTests; - - fileStorageSvc.Setup(x => x.SaveFileAsync(It.IsAny(), BuildBackupFileName("theId", "1.1.1", packageHashForTests), It.IsAny(), It.Is(b => b))) - .Completes() - .Verifiable(); - - await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); - - fileStorageSvc.VerifyAll(); - } - - [Fact] - public async Task WillSaveTheFileViaTheFileStorageServiceUsingThePackagesFolder() - { - var fileStorageSvc = new Mock(); - var service = CreateService(fileStorageSvc: fileStorageSvc); - fileStorageSvc.Setup(x => x.SaveFileAsync(CoreConstants.PackageBackupsFolderName, It.IsAny(), It.IsAny(), It.Is(b => b))) - .Completes() - .Verifiable(); - - var package = CreatePackage(); - package.Hash = packageHashForTests; - - await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); - - fileStorageSvc.VerifyAll(); - } - - [Fact] - public async Task WillSaveTheFileViaTheFileStorageServiceUsingAFileNameWithIdAndNormalizedersion() - { - var fileStorageSvc = new Mock(); - var service = CreateService(fileStorageSvc: fileStorageSvc); - fileStorageSvc.Setup(x => x.SaveFileAsync(It.IsAny(), BuildBackupFileName("theId", "theNormalizedVersion", packageHashForTests), It.IsAny(), It.Is(b => b))) - .Completes() - .Verifiable(); - - var package = CreatePackage(); - package.Hash = packageHashForTests; - - await service.StorePackageFileInBackupLocationAsync(package, CreatePackageFileStream()); - - fileStorageSvc.VerifyAll(); - } - - [Fact] - public async Task WillSaveTheFileStreamViaTheFileStorageService() - { - var fileStorageSvc = new Mock(); - var fakeStream = new MemoryStream(); - var service = CreateService(fileStorageSvc: fileStorageSvc); - fileStorageSvc.Setup(x => x.SaveFileAsync(It.IsAny(), It.IsAny(), fakeStream, It.Is(b => b))) - .Completes() - .Verifiable(); - - var package = CreatePackage(); - package.Hash = packageHashForTests; - - await service.StorePackageFileInBackupLocationAsync(package, fakeStream); - - fileStorageSvc.VerifyAll(); - } - } public class TheDeleteReadMeMdFileAsync { @@ -478,18 +345,6 @@ static string BuildFileName( extension); } - private static string BuildBackupFileName(string id, string version, string hash) - { - var hashBytes = Convert.FromBase64String(hash); - - return string.Format( - Constants.PackageFileBackupSavePathTemplate, - id, - version, - HttpServerUtility.UrlTokenEncode(hashBytes), - CoreConstants.NuGetPackageFileExtension); - } - static Package CreatePackage() { var packageRegistration = new PackageRegistration { Id = "theId", Packages = new HashSet() }; diff --git a/tests/NuGetGallery.Facts/Services/PackageServiceFacts.cs b/tests/NuGetGallery.Facts/Services/PackageServiceFacts.cs index 72a7f10328..be1d4b0ce3 100644 --- a/tests/NuGetGallery.Facts/Services/PackageServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/PackageServiceFacts.cs @@ -24,11 +24,13 @@ private static IPackageService CreateService( Mock> packageRepository = null, IPackageNamingConflictValidator packageNamingConflictValidator = null, IAuditingService auditingService = null, + Mock telemetryService = null, Action> setup = null) { packageRegistrationRepository = packageRegistrationRepository ?? new Mock>(); packageRepository = packageRepository ?? new Mock>(); auditingService = auditingService ?? new TestAuditingService(); + telemetryService = telemetryService ?? new Mock(); if (packageNamingConflictValidator == null) { @@ -41,7 +43,8 @@ private static IPackageService CreateService( packageRegistrationRepository.Object, packageRepository.Object, packageNamingConflictValidator, - auditingService); + auditingService, + telemetryService.Object); packageService.CallBase = true; @@ -944,8 +947,19 @@ public override void ReturnsOnlyLatestStablePackageIfNoLatestStableSemVer2Exist( => base.ReturnsOnlyLatestStablePackageIfNoLatestStableSemVer2Exist(currentUser, packageOwner); [MemberData(nameof(TestData_RoleVariants))] - public override void ReturnsCorrectLatestVersionForMixedSemVer2AndNonSemVer2PackageVersions_IncludeUnlistedTrue(User currentUser, User packageOwner) - => base.ReturnsCorrectLatestVersionForMixedSemVer2AndNonSemVer2PackageVersions_IncludeUnlistedTrue(currentUser, packageOwner); + [Theory] + public virtual void ReturnsCorrectLatestVersionForMixedSemVer2AndNonSemVer2PackageVersions_IncludeUnlistedTrue(User currentUser, User packageOwner) + { + var context = GetMixedVersioningPackagesContext(currentUser, packageOwner); + + var packages = InvokeFindPackagesByOwner(currentUser, includeUnlisted: true).ToList(); + + var nugetCatalogReaderPackage = packages.Single(p => p.PackageRegistration.Id == "NuGet.CatalogReader"); + Assert.Equal("1.5.12+git.78e44a8", NuGetVersionFormatter.ToFullStringOrFallback(nugetCatalogReaderPackage.Version, fallback: nugetCatalogReaderPackage.Version)); + + var sleetLibPackage = packages.Single(p => p.PackageRegistration.Id == "SleetLib"); + Assert.Equal("2.2.24+git.f2a0cb6", NuGetVersionFormatter.ToFullStringOrFallback(sleetLibPackage.Version, fallback: sleetLibPackage.Version)); + } [MemberData(nameof(TestData_RoleVariants))] public override void ReturnsFirstIfMultiplePackagesSetToLatest(User currentUser, User packageOwner) @@ -1003,10 +1017,6 @@ public override void ReturnsOnlyLatestStableSemVer2PackageIfBothExist(User curre public override void ReturnsOnlyLatestStablePackageIfNoLatestStableSemVer2Exist(User currentUser, User packageOwner) => base.ReturnsOnlyLatestStablePackageIfNoLatestStableSemVer2Exist(currentUser, packageOwner); - [MemberData(nameof(TestData_RoleVariants))] - public override void ReturnsCorrectLatestVersionForMixedSemVer2AndNonSemVer2PackageVersions_IncludeUnlistedTrue(User currentUser, User packageOwner) - => base.ReturnsCorrectLatestVersionForMixedSemVer2AndNonSemVer2PackageVersions_IncludeUnlistedTrue(currentUser, packageOwner); - [MemberData(nameof(TestData_RoleVariants))] public override void ReturnsFirstIfMultiplePackagesSetToLatest(User currentUser, User packageOwner) => base.ReturnsFirstIfMultiplePackagesSetToLatest(currentUser, packageOwner); @@ -1033,9 +1043,9 @@ protected class TestUserRoles protected static TestUserRoles CreateTestUserRoles() { - var organization = new Organization { Username = "organization" }; + var organization = new Organization { Key = 0, Username = "organization" }; - var admin = new User { Username = "admin" }; + var admin = new User { Key = 1, Username = "admin" }; var adminMembership = new Membership { Organization = organization, @@ -1045,7 +1055,7 @@ protected static TestUserRoles CreateTestUserRoles() organization.Members.Add(adminMembership); admin.Organizations.Add(adminMembership); - var collaborator = new User { Username = "collaborator" }; + var collaborator = new User { Key = 2, Username = "collaborator" }; var collaboratorMembership = new Membership { Organization = organization, @@ -1122,10 +1132,24 @@ public virtual void ReturnsAnUnlistedPackageWhenIncludeUnlistedIsTrue(User curre [Theory] public virtual void ReturnsAPackageForEachPackageRegistration(User currentUser, User packageOwner) { - var packageRegistrationA = new PackageRegistration { Id = "idA", Owners = { packageOwner } }; - var packageRegistrationB = new PackageRegistration { Id = "idB", Owners = { packageOwner } }; - var packageA = new Package { Version = "1.0", PackageRegistration = packageRegistrationA, Listed = true, IsLatestSemVer2 = true, IsLatestStableSemVer2 = true }; - var packageB = new Package { Version = "1.0", PackageRegistration = packageRegistrationB, Listed = true, IsLatestSemVer2 = true, IsLatestStableSemVer2 = true }; + var packageRegistrationA = new PackageRegistration { Key = 0, Id = "idA", Owners = { packageOwner } }; + var packageRegistrationB = new PackageRegistration { Key = 1, Id = "idB", Owners = { packageOwner } }; + var packageA = new Package { + Version = "1.0", + PackageRegistration = packageRegistrationA, + PackageRegistrationKey = 0, + Listed = true, + IsLatestSemVer2 = true, + IsLatestStableSemVer2 = true + }; + var packageB = new Package { + Version = "1.0", + PackageRegistration = packageRegistrationB, + PackageRegistrationKey = 1, + Listed = true, + IsLatestSemVer2 = true, + IsLatestStableSemVer2 = true + }; packageRegistrationA.Packages.Add(packageA); packageRegistrationB.Packages.Add(packageB); @@ -1183,7 +1207,7 @@ public virtual void ReturnsOnlyLatestStablePackageIfNoLatestStableSemVer2Exist(U Assert.Contains(latestStablePackage, packages); } - private FakeEntitiesContext GetMixedVersioningPackagesContext(User currentUser, User packageOwner) + protected FakeEntitiesContext GetMixedVersioningPackagesContext(User currentUser, User packageOwner) { var context = GetFakeContext(); @@ -1231,20 +1255,6 @@ private FakeEntitiesContext GetMixedVersioningPackagesContext(User currentUser, return context; } - [Theory] - public virtual void ReturnsCorrectLatestVersionForMixedSemVer2AndNonSemVer2PackageVersions_IncludeUnlistedTrue(User currentUser, User packageOwner) - { - var context = GetMixedVersioningPackagesContext(currentUser, packageOwner); - - var packages = InvokeFindPackagesByOwner(currentUser, includeUnlisted: true).ToList(); - - var nugetCatalogReaderPackage = packages.Single(p => p.PackageRegistration.Id == "NuGet.CatalogReader"); - Assert.Equal("1.5.12+git.78e44a8", NuGetVersionFormatter.ToFullStringOrFallback(nugetCatalogReaderPackage.Version, fallback: nugetCatalogReaderPackage.Version)); - - var sleetLibPackage = packages.Single(p => p.PackageRegistration.Id == "SleetLib"); - Assert.Equal("2.2.24+git.f2a0cb6", NuGetVersionFormatter.ToFullStringOrFallback(sleetLibPackage.Version, fallback: sleetLibPackage.Version)); - } - [Theory] public virtual void ReturnsFirstIfMultiplePackagesSetToLatest(User currentUser, User packageOwner) { @@ -1269,11 +1279,26 @@ public virtual void ReturnsFirstIfMultiplePackagesSetToLatest(User currentUser, [Theory] public virtual void ReturnsVersionsWhenIncludedVersionsIsTrue_IncludeUnlistedTrue(User currentUser, User packageOwner) { - var packageRegistration = new PackageRegistration { Id = "theId", Owners = { packageOwner } }; - var package1 = new Package { Version = "1.0", PackageRegistration = packageRegistration, Listed = false, IsLatest = false, IsLatestStable = false }; + var packageRegistration = new PackageRegistration { Key = 0, Id = "theId", Owners = { packageOwner } }; + + var package1 = new Package { + Version = "1.0", + PackageRegistration = packageRegistration, + PackageRegistrationKey = 0, + Listed = false, + IsLatest = false, + IsLatestStable = false + }; packageRegistration.Packages.Add(package1); - var package2 = new Package { Version = "2.0", PackageRegistration = packageRegistration, Listed = true, IsLatest = true, IsLatestStable = true }; + var package2 = new Package { + Version = "2.0", + PackageRegistration = packageRegistration, + PackageRegistrationKey = 0, + Listed = true, + IsLatest = true, + IsLatestStable = true + }; packageRegistration.Packages.Add(package2); var context = GetFakeContext(); @@ -1428,6 +1453,21 @@ public async Task WritesAnAuditRecord() && ar.Id == package.PackageRegistration.Id && ar.Version == package.Version)); } + + [Fact] + public async Task EmitsTelemetry() + { + var packageRegistration = new PackageRegistration { Id = "theId" }; + var package = new Package { Version = "1.0", PackageRegistration = packageRegistration, Listed = false }; + var telemetryService = new Mock(); + var service = CreateService(telemetryService: telemetryService); + + await service.MarkPackageListedAsync(package); + + telemetryService.Verify( + x => x.TrackPackageListed(package), + Times.Once); + } } public class TheMarkPackageUnlistedMethod @@ -1536,6 +1576,21 @@ public async Task WritesAnAuditRecord() && ar.Id == package.PackageRegistration.Id && ar.Version == package.Version)); } + + [Fact] + public async Task EmitsTelemetry() + { + var packageRegistration = new PackageRegistration { Id = "theId" }; + var package = new Package { Version = "1.0", PackageRegistration = packageRegistration, Listed = true }; + var telemetryService = new Mock(); + var service = CreateService(telemetryService: telemetryService); + + await service.MarkPackageUnlistedAsync(package); + + telemetryService.Verify( + x => x.TrackPackageUnlisted(package), + Times.Once); + } } public class ThePublishPackageMethod diff --git a/tests/NuGetGallery.Facts/Services/PackageUploadServiceFacts.cs b/tests/NuGetGallery.Facts/Services/PackageUploadServiceFacts.cs index 9f99dcc6ef..a9329ddfbf 100644 --- a/tests/NuGetGallery.Facts/Services/PackageUploadServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/PackageUploadServiceFacts.cs @@ -36,7 +36,9 @@ private static PackageUploadService CreateService( .CreatePackageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((PackageArchiveReader packageArchiveReader, PackageStreamMetadata packageStreamMetadata, User owner, User currentUser, bool isVerified) => { - var packageMetadata = PackageMetadata.FromNuspecReader(packageArchiveReader.GetNuspecReader()); + var packageMetadata = PackageMetadata.FromNuspecReader( + packageArchiveReader.GetNuspecReader(), + strict: true); var newPackage = new Package(); newPackage.PackageRegistration = new PackageRegistration { Id = packageMetadata.Id, IsVerified = isVerified }; diff --git a/tests/NuGetGallery.Facts/Services/ReflowPackageServiceFacts.cs b/tests/NuGetGallery.Facts/Services/ReflowPackageServiceFacts.cs index ce437df4b3..f3ff942cc7 100644 --- a/tests/NuGetGallery.Facts/Services/ReflowPackageServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/ReflowPackageServiceFacts.cs @@ -23,6 +23,7 @@ private static ReflowPackageService CreateService( Mock entitiesContext = null, Mock packageService = null, Mock packageFileService = null, + Mock telemetryService = null, Action> setup = null) { var dbContext = new Mock(); @@ -31,11 +32,13 @@ private static ReflowPackageService CreateService( packageService = packageService ?? new Mock(); packageFileService = packageFileService ?? new Mock(); + telemetryService = telemetryService ?? new Mock(); var reflowPackageService = new Mock( entitiesContext.Object, packageService.Object, - packageFileService.Object); + packageFileService.Object, + telemetryService.Object); reflowPackageService.CallBase = true; @@ -262,6 +265,72 @@ public async Task CallsUpdateIsLatestAsync() // Assert packageService.Verify(s => s.UpdateIsLatestAsync(package.PackageRegistration, false), Times.Once); } + + [Fact] + public async Task EmitsTelemetry() + { + // Arrange + var package = PackageServiceUtility.CreateTestPackage(); + + var packageService = SetupPackageService(package); + var entitiesContext = SetupEntitiesContext(); + var packageFileService = SetupPackageFileService(package); + var telemetryService = new Mock(); + + var service = CreateService( + packageService: packageService, + entitiesContext: entitiesContext, + packageFileService: packageFileService, + telemetryService: telemetryService); + + // Act + var result = await service.ReflowAsync("test", "1.0.0"); + + // Assert + telemetryService.Verify( + x => x.TrackPackageReflow(package), + Times.Once); + } + + [Fact] + public async Task AllowsInvalidPackageDependencyVersion() + { + // Arrange + var package = PackageServiceUtility.CreateTestPackage(); + + var packageService = SetupPackageService(package); + var entitiesContext = SetupEntitiesContext(); + var packageFileService = SetupPackageFileService( + package, + CreateInvalidDependencyVersionTestPackageStream()); + + var service = CreateService( + packageService: packageService, + entitiesContext: entitiesContext, + packageFileService: packageFileService); + + // Act + var result = await service.ReflowAsync("test", "1.0.0"); + + // Assert + Assert.Equal("test", result.PackageRegistration.Id); + Assert.Equal("1.0.0", result.NormalizedVersion); + + Assert.True(result.Dependencies.Any(d => + d.Id == "WebActivator" + && d.VersionSpec == "(, )" + && d.TargetFramework == "net40")); + + Assert.True(result.Dependencies.Any(d => + d.Id == "PackageC" + && d.VersionSpec == "[1.1.0, 2.0.1)" + && d.TargetFramework == "net40")); + + Assert.True(result.Dependencies.Any(d => + d.Id == "jQuery" + && d.VersionSpec == "(, )" + && d.TargetFramework == "net451")); + } } private static Mock SetupPackageService(Package package) @@ -272,12 +341,14 @@ private static Mock SetupPackageService(Package package) packageRegistrationRepository.Object, packageRepository.Object); var auditingService = new TestAuditingService(); + var telemetryService = new Mock(); var packageService = new Mock( packageRegistrationRepository.Object, packageRepository.Object, packageNamingConflictValidator, - auditingService); + auditingService, + telemetryService.Object); packageService.CallBase = true; @@ -325,27 +396,50 @@ private static Mock SetupEntitiesContext() return entitiesContext; } - private static Mock SetupPackageFileService(Package package) + private static Mock SetupPackageFileService(Package package, Stream packageStream = null) { var packageFileService = new Mock(); packageFileService .Setup(s => s.DownloadPackageFileAsync(package)) - .Returns(Task.FromResult(CreateTestPackageStream())) + .Returns(Task.FromResult(packageStream ?? CreateTestPackageStream())) .Verifiable(); return packageFileService; } + private static Stream CreateInvalidDependencyVersionTestPackageStream() + { + return CreateTestPackageStream(@" + + + test + 1.0.0 + Test package + authora, authorb + ownera + false + package A description. + en-US + http://www.nuget.org/ + http://www.nuget.org/ + http://www.nuget.org/ + + + + + + + + + + + "); + } + private static Stream CreateTestPackageStream() { - var packageStream = new MemoryStream(); - using (var packageArchive = new ZipArchive(packageStream, ZipArchiveMode.Create, true)) - { - var nuspecEntry = packageArchive.CreateEntry("TestPackage.nuspec", CompressionLevel.Fastest); - using (var streamWriter = new StreamWriter(nuspecEntry.Open())) - { - streamWriter.WriteLine(@" + return CreateTestPackageStream(@" test @@ -370,6 +464,17 @@ private static Stream CreateTestPackageStream() "); + } + + private static Stream CreateTestPackageStream(string nuspec) + { + var packageStream = new MemoryStream(); + using (var packageArchive = new ZipArchive(packageStream, ZipArchiveMode.Create, true)) + { + var nuspecEntry = packageArchive.CreateEntry("TestPackage.nuspec", CompressionLevel.Fastest); + using (var streamWriter = new StreamWriter(nuspecEntry.Open())) + { + streamWriter.WriteLine(nuspec); } packageArchive.CreateEntry("content\\HelloWorld.cs", CompressionLevel.Fastest); diff --git a/tests/NuGetGallery.Facts/Services/SearchAdaptorFacts.cs b/tests/NuGetGallery.Facts/Services/SearchAdaptorFacts.cs index 7486c9aa0a..e1421f5f64 100644 --- a/tests/NuGetGallery.Facts/Services/SearchAdaptorFacts.cs +++ b/tests/NuGetGallery.Facts/Services/SearchAdaptorFacts.cs @@ -133,6 +133,23 @@ public void GeneratesNextLinkForComplexUrl() // Assert Assert.Equal(new Uri("https://localhost:8081/api/v2/Search()?searchTerm='foo'&$orderby=Id&$skip=200&$top=1000"), nextLink); } + + [Fact] + public void GeneratesNextLinkForComplexUrlWithSemVerLevel2() + { + // Arrange + var requestUri = new Uri("https://localhost:8081/api/v2/Search()?searchTerm='foo'&$orderby=Id&$skip=100&$top=1000&semVerLevel=2.0.0"); + var resultCount = 2000; // our result set contains 2000 elements + + // Act + var nextLink = SearchAdaptor.GetNextLink(requestUri, resultCount, new { searchTerm = "foo" }, + GetODataQueryOptionsForTest(requestUri), + GetODataQuerySettingsForTest(), + SemVerLevelKey.SemVer2); + + // Assert + Assert.Equal(new Uri("https://localhost:8081/api/v2/Search()?searchTerm='foo'&$orderby=Id&$skip=200&$top=1000&semVerLevel=2.0.0"), nextLink); + } } } } \ No newline at end of file diff --git a/tests/NuGetGallery.Facts/Services/TelemetryServiceFacts.cs b/tests/NuGetGallery.Facts/Services/TelemetryServiceFacts.cs index da8e10713d..fcd952f8dd 100644 --- a/tests/NuGetGallery.Facts/Services/TelemetryServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/TelemetryServiceFacts.cs @@ -29,7 +29,7 @@ public class TheTrackEventMethod : BaseFacts { private static Fakes fakes = new Fakes(); - public static IEnumerable TrackEventNames_Data + public static IEnumerable TrackMetricNames_Data { get { @@ -44,6 +44,30 @@ public static IEnumerable TrackEventNames_Data (TrackAction)(s => s.TrackPackagePushEvent(package, fakes.User, identity)) }; + yield return new object[] { "PackageUnlisted", + (TrackAction)(s => s.TrackPackageUnlisted(package)) + }; + + yield return new object[] { "PackageListed", + (TrackAction)(s => s.TrackPackageListed(package)) + }; + + yield return new object[] { "PackageDelete", + (TrackAction)(s => s.TrackPackageDelete(package, isHardDelete: true)) + }; + + yield return new object[] { "PackageReflow", + (TrackAction)(s => s.TrackPackageReflow(package)) + }; + + yield return new object[] { "PackageHardDeleteReflow", + (TrackAction)(s => s.TrackPackageHardDeleteReflow(fakes.Package.Id, package.Version)) + }; + + yield return new object[] { "PackageRevalidate", + (TrackAction)(s => s.TrackPackageRevalidate(package)) + }; + yield return new object[] { "CreatePackageVerificationKey", (TrackAction)(s => s.TrackCreatePackageVerificationKeyEvent(fakes.Package.Id, package.Version, fakes.User, identity)) }; @@ -67,13 +91,7 @@ public static IEnumerable TrackEventNames_Data yield return new object[] { "CredentialAdded", (TrackAction)(s => s.TrackNewCredentialCreated(fakes.User, fakes.User.Credentials.First())) }; - } - } - public static IEnumerable TrackMetricNames_Data - { - get - { yield return new object[] { "UserPackageDeleteCheckedAfterHours", (TrackAction)(s => s.TrackUserPackageDeleteChecked( new UserPackageDeleteEvent( @@ -105,28 +123,11 @@ public static IEnumerable TrackMetricNames_Data public void TrackEventNamesIncludesAllEvents() { var expectedCount = typeof(TelemetryService.Events).GetFields().Length; - var actualCount = TrackEventNames_Data.Count() + TrackMetricNames_Data.Count(); + var actualCount = TrackMetricNames_Data.Count(); Assert.Equal(expectedCount, actualCount); } - [Theory] - [MemberData(nameof(TrackEventNames_Data))] - public void TrackEventNames(string eventName, TrackAction track) - { - // Arrange - var service = CreateService(); - - // Act - track(service); - - // Assert - service.TelemetryClient.Verify(c => c.TrackEvent(eventName, - It.IsAny>(), - It.IsAny>()), - Times.Once); - } - [Theory] [MemberData(nameof(TrackMetricNames_Data))] public void TrackMetricNames(string metricName, TrackAction track) @@ -348,12 +349,6 @@ public TelemetryServiceWrapper CreateService() traceService.Setup(s => s.GetSource(It.IsAny())) .Returns(traceSource.Object); - telemetryClient.Setup(c => c.TrackEvent( - It.IsAny(), - It.IsAny>(), - It.IsAny>())) - .Verifiable(); - var telemetryService = new TelemetryServiceWrapper(traceService.Object, telemetryClient.Object); telemetryService.TraceSource = traceSource; diff --git a/tests/NuGetGallery.Facts/Services/UserServiceFacts.cs b/tests/NuGetGallery.Facts/Services/UserServiceFacts.cs index e12048c2fe..441026488d 100644 --- a/tests/NuGetGallery.Facts/Services/UserServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/UserServiceFacts.cs @@ -2,11 +2,13 @@ // 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.Globalization; using System.Linq; using System.Threading.Tasks; using Moq; using NuGetGallery.Auditing; +using NuGetGallery.Authentication; using NuGetGallery.Framework; using NuGetGallery.Infrastructure.Authentication; using NuGetGallery.Security; @@ -688,7 +690,7 @@ public void WhenAccountIsNotInWhitelist_ReturnsFalse() // Assert Assert.False(result); Assert.Equal(errorReason, String.Format(CultureInfo.CurrentCulture, - Strings.TransformAccount_FailedReasonNotInDomainWhitelist, user.Username)); + Strings.Organizations_NotInDomainWhitelist, user.Username)); } [Fact] @@ -940,13 +942,361 @@ private Task InvokeTransformUserToOrganization(int affectedRecords, User a .Returns(Task.FromResult(affectedRecords)); service.MockSecurityPolicyService - .Setup(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny())) + .Setup(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), true)) .Returns(Task.FromResult(true)); // Act return service.TransformUserToOrganization(account, admin, "token"); } } + + public class TheTransferApiKeysScopedToUserMethod + { + public static IEnumerable TransfersApiKeysAsExpected_Data + { + get + { + foreach (var hasExternalCredential in new[] { false, true }) + { + foreach (var hasPasswordCredential in new[] { false, true }) + { + foreach (var hasUnscopedApiKeyCredential in new[] { false, true }) + { + foreach (var hasApiKeyScopedToUserCredential in new[] { false, true }) + { + foreach (var hasApiKeyScopedToDifferentUser in new[] { false, true }) + { + yield return MemberDataHelper.AsData( + hasExternalCredential, + hasPasswordCredential, + hasUnscopedApiKeyCredential, + hasApiKeyScopedToUserCredential, + hasApiKeyScopedToDifferentUser); + } + } + } + } + } + } + } + + [Theory] + [MemberData(nameof(TransfersApiKeysAsExpected_Data))] + public async Task TransfersApiKeysAsExpected( + bool hasExternalCredential, + bool hasPasswordCredential, + bool hasUnscopedApiKeyCredential, + bool hasApiKeyScopedToUserCredential, + bool hasApiKeyScopedToDifferentUser) + { + // Arrange + var originalOwner = new User("originalOwner") { Key = 11111 }; + var randomUser = new User("randomUser") { Key = 57576768 }; + var newOwner = new User("newOwner") { Key = 69785, Credentials = new List() }; + + var credentials = new List(); + + var externalCredential = TestCredentialHelper.CreateExternalCredential("cred", null); + AddFieldsToCredential(externalCredential, "externalCredential", "value1", originalOwner, expiration: null); + + var passwordCredential = TestCredentialHelper.CreateSha1Password("password"); + AddFieldsToCredential(passwordCredential, "passwordCredential", "value2", originalOwner, expiration: null); + + var unscopedApiKeyCredential = TestCredentialHelper.CreateV4ApiKey(new TimeSpan(5, 5, 5, 5), out var key1); + AddFieldsToCredential(unscopedApiKeyCredential, "unscopedApiKey", "value3", originalOwner, expiration: new DateTime(2018, 3, 9)); + + var scopedToUserApiKeyCredential = TestCredentialHelper.CreateV4ApiKey(new TimeSpan(5, 5, 5, 5), out var key2) + .WithScopes(new[] { new Scope { Owner = originalOwner, OwnerKey = originalOwner.Key } }); + AddFieldsToCredential(scopedToUserApiKeyCredential, "scopedToUserApiKey", "value4", originalOwner, expiration: new DateTime(2018, 3, 10)); + + var scopedToDifferentUserApiKeyCredential = TestCredentialHelper.CreateV4ApiKey(new TimeSpan(5, 5, 5, 5), out var key3) + .WithScopes(new[] { new Scope { Owner = randomUser, OwnerKey = randomUser.Key } }); + AddFieldsToCredential(scopedToDifferentUserApiKeyCredential, "scopedToDifferentUserApiKey", "value5", originalOwner, expiration: new DateTime(2018, 3, 11)); + + if (hasExternalCredential) + { + credentials.Add(externalCredential); + } + + if (hasPasswordCredential) + { + credentials.Add(passwordCredential); + } + + if (hasUnscopedApiKeyCredential) + { + credentials.Add(unscopedApiKeyCredential); + } + + if (hasApiKeyScopedToUserCredential) + { + credentials.Add(scopedToUserApiKeyCredential); + } + + if (hasApiKeyScopedToDifferentUser) + { + credentials.Add(scopedToDifferentUserApiKeyCredential); + } + + originalOwner.Credentials = credentials; + var originalCredentialCount = credentials.Count(); + + var service = new TestableUserService(); + + // Act + await service.TransferApiKeysScopedToUser(originalOwner, newOwner); + + // Assert + service.MockEntitiesContext.Verify( + x => x.SaveChangesAsync(), + hasUnscopedApiKeyCredential || hasApiKeyScopedToUserCredential ? Times.Once() : Times.Never()); + + Assert.Equal(originalCredentialCount, originalOwner.Credentials.Count()); + + Assert.Equal( + (hasUnscopedApiKeyCredential ? 1 : 0) + (hasApiKeyScopedToUserCredential ? 1 : 0), + newOwner.Credentials.Count()); + + AssertCredentialInOriginalOnly(externalCredential, originalOwner, newOwner, hasExternalCredential); + AssertCredentialInOriginalOnly(passwordCredential, originalOwner, newOwner, hasPasswordCredential); + AssertCredentialInOriginalOnly(scopedToDifferentUserApiKeyCredential, originalOwner, newOwner, hasApiKeyScopedToDifferentUser); + + AssertCredentialInNew(unscopedApiKeyCredential, originalOwner, newOwner, hasUnscopedApiKeyCredential); + AssertCredentialInNew(scopedToUserApiKeyCredential, originalOwner, newOwner, hasApiKeyScopedToUserCredential); + } + + private void AddFieldsToCredential(Credential credential, string description, string value, User originalOwner, DateTime? expiration) + { + credential.Description = description; + credential.Value = value; + credential.User = originalOwner; + credential.UserKey = originalOwner.Key; + + if (expiration.HasValue) + { + credential.ExpirationTicks = expiration.Value.Ticks; + credential.Expires = expiration.Value; + } + } + + private void AssertCredentialInOriginalOnly(Credential credential, User originalOwner, User newOwner, bool hasCredential) + { + var credentialEquals = CredentialEqualsFunc(credential); + Assert.Equal(hasCredential, originalOwner.Credentials.Any( + hasCredential ? CredentialEqualsWithOwnerFunc(credential, originalOwner) : CredentialEqualsFunc(credential))); + Assert.False(newOwner.Credentials.Any(CredentialEqualsFunc(credential))); + } + + private void AssertCredentialInNew(Credential credential, User originalOwner, User newOwner, bool hasCredential) + { + Assert.Equal(hasCredential, originalOwner.Credentials.Any( + hasCredential ? CredentialEqualsWithOwnerFunc(credential, originalOwner) : CredentialEqualsFunc(credential))); + Assert.Equal(hasCredential, newOwner.Credentials.Any( + hasCredential ? CredentialEqualsWithOwnerAndScopeFunc(credential, newOwner, originalOwner) : CredentialEqualsFunc(credential))); + } + + private bool CredentialEquals(Credential expected, Credential actual) + { + return + expected.Description == actual.Description && + expected.ExpirationTicks == actual.ExpirationTicks && + expected.Expires == actual.Expires && + expected.Type == actual.Type && + expected.Value == actual.Value; + } + + private bool CredentialEqualsWithOwner(Credential expected, Credential actual, User owner) + { + return CredentialEquals(expected, actual) && + owner == actual.User && + owner.Key == actual.UserKey; + } + + private bool CredentialEqualsWithOwnerAndScope(Credential expected, Credential actual, User owner, User scopeOwner) + { + return CredentialEqualsWithOwner(expected, actual, owner) && + expected.Scopes.All(s => s.Owner == scopeOwner && s.OwnerKey == scopeOwner.Key); + } + + private Func CredentialEqualsFunc(Credential expected) + { + return (c) => CredentialEquals(expected, c); + } + + private Func CredentialEqualsWithOwnerFunc(Credential expected, User owner) + { + return (c) => CredentialEqualsWithOwner(expected, c, owner); + } + + private Func CredentialEqualsWithOwnerAndScopeFunc(Credential expected, User owner, User scopeOwner) + { + return (c) => CredentialEqualsWithOwnerAndScope(expected, c, owner, scopeOwner); + } + } + + public class TheAddOrganizationAccountMethod + { + private const string OrgName = "myOrg"; + private const string OrgEmail = "myOrg@myOrg.com"; + private const string AdminName = "orgAdmin"; + + private static DateTime OrgCreatedUtc = new DateTime(2018, 2, 21); + + private TestableUserService _service = new TestableUserService(); + + [Fact] + public async Task WithUserNotSupportedForOrganizations_ThrowsEntityException() + { + SetupOrganizationsSupportedForUser(supported: false); + var exception = await Assert.ThrowsAsync(() => InvokeAddOrganization()); + Assert.Equal(String.Format(CultureInfo.CurrentCulture, Strings.Organizations_NotInDomainWhitelist, AdminName), exception.Message); + + _service.MockOrganizationRepository.Verify(x => x.InsertOnCommit(It.IsAny()), Times.Never()); + _service.MockSecurityPolicyService.Verify(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false), Times.Never()); + _service.MockEntitiesContext.Verify(x => x.SaveChangesAsync(), Times.Never()); + } + + [Fact] + public async Task WithUsernameConflict_ThrowsEntityException() + { + var conflictUsername = "ialreadyexist"; + + _service.MockEntitiesContext + .Setup(x => x.Users) + .Returns(new[] { new User(conflictUsername) }.MockDbSet().Object); + + SetupOrganizationsSupportedForUser(); + + var exception = await Assert.ThrowsAsync(() => InvokeAddOrganization(orgName: conflictUsername)); + Assert.Equal(String.Format(CultureInfo.CurrentCulture, Strings.UsernameNotAvailable, conflictUsername), exception.Message); + + _service.MockOrganizationRepository.Verify(x => x.InsertOnCommit(It.IsAny()), Times.Never()); + _service.MockSecurityPolicyService.Verify(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false), Times.Never()); + _service.MockEntitiesContext.Verify(x => x.SaveChangesAsync(), Times.Never()); + } + + [Fact] + public async Task WithEmailConflict_ThrowsEntityException() + { + var conflictEmail = "ialreadyexist@existence.com"; + + _service.MockEntitiesContext + .Setup(x => x.Users) + .Returns(new[] { new User("user") { EmailAddress = conflictEmail } }.MockDbSet().Object); + + SetupOrganizationsSupportedForUser(); + + var exception = await Assert.ThrowsAsync(() => InvokeAddOrganization(orgEmail: conflictEmail)); + Assert.Equal(String.Format(CultureInfo.CurrentCulture, Strings.EmailAddressBeingUsed, conflictEmail), exception.Message); + + _service.MockOrganizationRepository.Verify(x => x.InsertOnCommit(It.IsAny()), Times.Never()); + _service.MockSecurityPolicyService.Verify(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false), Times.Never()); + _service.MockEntitiesContext.Verify(x => x.SaveChangesAsync(), Times.Never()); + } + + [Fact] + public async Task WhenAdminHasNoTenant_ThrowsEntityException() + { + _service.MockEntitiesContext + .Setup(x => x.Users) + .Returns(Enumerable.Empty().MockDbSet().Object); + + var adminUsername = "adminWithNoTenant"; + SetupOrganizationsSupportedForUser(adminUsername); + var exception = await Assert.ThrowsAsync(() => InvokeAddOrganization(admin: new User(adminUsername))); + Assert.Equal(String.Format(CultureInfo.CurrentCulture, Strings.Organizations_AdminAccountDoesNotHaveTenant, adminUsername), exception.Message); + + _service.MockOrganizationRepository.Verify(x => x.InsertOnCommit(It.IsAny()), Times.Once()); + _service.MockSecurityPolicyService.Verify(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false), Times.Never()); + _service.MockEntitiesContext.Verify(x => x.SaveChangesAsync(), Times.Never()); + } + + [Fact] + public async Task WhenSubscribingToPolicyFails_ThrowsUserSafeException() + { + _service.MockEntitiesContext + .Setup(x => x.Users) + .Returns(Enumerable.Empty().MockDbSet().Object); + + _service.MockSecurityPolicyService + .Setup(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false)) + .Returns(Task.FromResult(false)); + SetupOrganizationsSupportedForUser(); + + var exception = await Assert.ThrowsAsync(() => InvokeAddOrganization()); + Assert.Equal(Strings.DefaultUserSafeExceptionMessage, exception.Message); + + _service.MockOrganizationRepository.Verify(x => x.InsertOnCommit(It.IsAny()), Times.Once()); + _service.MockSecurityPolicyService.Verify(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false), Times.Once()); + _service.MockEntitiesContext.Verify(x => x.SaveChangesAsync(), Times.Never()); + } + + [Fact] + public async Task WhenSubscribingToPolicySucceeds_ReturnsNewOrg() + { + _service.MockEntitiesContext + .Setup(x => x.Users) + .Returns(Enumerable.Empty().MockDbSet().Object); + + _service.MockSecurityPolicyService + .Setup(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false)) + .Returns(Task.FromResult(true)); + SetupOrganizationsSupportedForUser(); + + var org = await InvokeAddOrganization(); + + Assert.Equal(OrgName, org.Username); + Assert.Equal(OrgEmail, org.UnconfirmedEmailAddress); + Assert.Equal(OrgCreatedUtc, org.CreatedUtc); + Assert.True(org.EmailAllowed); + Assert.True(org.NotifyPackagePushed); + Assert.True(!string.IsNullOrEmpty(org.EmailConfirmationToken)); + + // Both the organization and the admin must have a membership to each other. + Func hasMembership = m => m.Member.Username == AdminName && m.Organization.Username == OrgName && m.IsAdmin; + Assert.True( + org.Members.Any( + m => hasMembership(m) && m.Member.Organizations.Any(hasMembership))); + + _service.MockOrganizationRepository.Verify(x => x.InsertOnCommit(It.IsAny()), Times.Once()); + _service.MockSecurityPolicyService.Verify(sp => sp.SubscribeAsync(It.IsAny(), It.IsAny(), false), Times.Once()); + _service.MockEntitiesContext.Verify(x => x.SaveChangesAsync(), Times.Once()); + } + + private Task InvokeAddOrganization(string orgName = OrgName, string orgEmail = OrgEmail, User admin = null) + { + // Arrange + admin = admin ?? new User(AdminName) + { + Credentials = new Credential[] { + new CredentialBuilder().CreateExternalCredential( + issuer: "AzureActiveDirectory", + value: "abc123", + identity: "Admin", + tenantId: "zyx987") + } + }; + + _service.MockDateTimeProvider + .Setup(x => x.UtcNow) + .Returns(OrgCreatedUtc); + + // Act + return _service.AddOrganizationAsync(orgName, orgEmail, admin); + } + + private void SetupOrganizationsSupportedForUser(string adminUsername = null, bool supported = true) + { + adminUsername = adminUsername ?? AdminName; + + var mockLoginDiscontinuationConfiguration = new Mock(); + mockLoginDiscontinuationConfiguration + .Setup(x => x.AreOrganizationsSupportedForUser(It.Is(u => u.Username == adminUsername))) + .Returns(supported); + + _service.MockConfigObjectService.Setup(x => x.LoginDiscontinuationConfiguration).Returns(mockLoginDiscontinuationConfiguration.Object); + } + } } } diff --git a/tests/NuGetGallery.Facts/Services/ValidationServiceFacts.cs b/tests/NuGetGallery.Facts/Services/ValidationServiceFacts.cs index 89afd36911..461649e5f8 100644 --- a/tests/NuGetGallery.Facts/Services/ValidationServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Services/ValidationServiceFacts.cs @@ -83,6 +83,16 @@ public async Task DoesNotChangeThePackageStatus() /// to do this. Assert.Equal(PackageStatus.Available, _package.PackageStatusKey); } + + [Fact] + public async Task EmitsTelemetry() + { + // Arrange & Act + await _target.RevalidateAsync(_package); + + // Assert + _telemetryService.Verify(x => x.TrackPackageRevalidate(_package), Times.Once); + } } public class TheGetLatestValidationIssuesMethod : FactsBase @@ -303,6 +313,7 @@ public class FactsBase protected readonly Mock _packageService; protected readonly Mock _initiator; protected readonly Mock> _validationSets; + protected readonly Mock _telemetryService; protected readonly Package _package; protected readonly ValidationService _target; @@ -311,13 +322,15 @@ public FactsBase() _packageService = new Mock(); _initiator = new Mock(); _validationSets = new Mock>(); + _telemetryService = new Mock(); _package = new Package(); _target = new ValidationService( _packageService.Object, _initiator.Object, - _validationSets.Object); + _validationSets.Object, + _telemetryService.Object); } } } diff --git a/tests/NuGetGallery.Facts/TestUtils/TestServiceUtility.cs b/tests/NuGetGallery.Facts/TestUtils/TestServiceUtility.cs index 722c528cd0..d98bae66f5 100644 --- a/tests/NuGetGallery.Facts/TestUtils/TestServiceUtility.cs +++ b/tests/NuGetGallery.Facts/TestUtils/TestServiceUtility.cs @@ -137,9 +137,11 @@ public class TestableUserService : UserService public Mock MockSecurityPolicyService { get; protected set; } public Mock> MockUserRepository { get; protected set; } public Mock> MockCredentialRepository { get; protected set; } + public Mock> MockOrganizationRepository { get; protected set; } public Mock MockEntitiesContext { get; protected set; } public Mock MockDatabase { get; protected set; } public Mock MockConfigObjectService { get; protected set; } + public Mock MockDateTimeProvider { get; protected set; } public TestableUserService() { @@ -147,8 +149,10 @@ public TestableUserService() SecurityPolicyService = (MockSecurityPolicyService = new Mock()).Object; UserRepository = (MockUserRepository = new Mock>()).Object; CredentialRepository = (MockCredentialRepository = new Mock>()).Object; + OrganizationRepository = (MockOrganizationRepository = new Mock>()).Object; EntitiesContext = (MockEntitiesContext = new Mock()).Object; ContentObjectService = (MockConfigObjectService = new Mock()).Object; + DateTimeProvider = (MockDateTimeProvider = new Mock()).Object; Auditing = new TestAuditingService(); // Set ConfirmEmailAddress to a default of true @@ -287,11 +291,14 @@ private Mock SetupPackageService() packageRepository.Object); var auditingService = new TestAuditingService(); + var telemetryService = new Mock(); + var packageService = new Mock( packageRegistrationRepository.Object, packageRepository.Object, packageNamingConflictValidator, - auditingService); + auditingService, + telemetryService.Object); packageService.CallBase = true; diff --git a/tests/NuGetGallery.FunctionalTests.Core/EnvironmentSettings.cs b/tests/NuGetGallery.FunctionalTests.Core/EnvironmentSettings.cs index ca313b544b..f5251ba207 100644 --- a/tests/NuGetGallery.FunctionalTests.Core/EnvironmentSettings.cs +++ b/tests/NuGetGallery.FunctionalTests.Core/EnvironmentSettings.cs @@ -450,7 +450,8 @@ public static IEnumerable TrustedHttpsCertificates // returned from HTTPS browser interactions with the gallery. pieces = new List { - "8c11c16610b7a147d10bbcc6a65ce23d321c12c2", // *.nugettest.org + "8c11c16610b7a147d10bbcc6a65ce23d321c12c2", // *.nugettest.org (old) + "6cd4e9738ae52ba11e7b81da8caafbeadf89488f", // *.nugettest.org (new) "9d984f91f40d8b3a1fb29153179415523c4e64d1", // *.int.nugettest.org "3751cb513b93ee67ec9f18a1f2aec1eac87af9bc", // *.nuget.org (old) "03984834f27d5c94f46b3bb190e5a8099787268a" // *.nuget.org (new)