diff --git a/src/NuGet.Services.Entities/Package.cs b/src/NuGet.Services.Entities/Package.cs
index a98b0462e9..09002be34f 100644
--- a/src/NuGet.Services.Entities/Package.cs
+++ b/src/NuGet.Services.Entities/Package.cs
@@ -240,6 +240,18 @@ public bool HasEmbeddedReadme
public User User { get; set; }
public int? UserKey { get; set; }
+ ///
+ /// The user that promoted this package from to
+ /// , or null if the user was deleted.
+ ///
+ ///
+ /// Packages that were not published through the staging flow have null. This is distinct from
+ /// , which records who pushed the package: for a staged package the pusher and the approver
+ /// can be different identities, and both are retained for the lifetime of the package.
+ ///
+ public User ApproverUser { get; set; }
+ public int? ApproverUserKey { get; set; }
+
///
/// List of historical metadata info of this package (before edits were applied)
///
diff --git a/src/NuGet.Services.Entities/PackageStatus.cs b/src/NuGet.Services.Entities/PackageStatus.cs
index 758ceb6610..9ea9f7be42 100644
--- a/src/NuGet.Services.Entities/PackageStatus.cs
+++ b/src/NuGet.Services.Entities/PackageStatus.cs
@@ -29,5 +29,13 @@ public enum PackageStatus
/// state, they are not available but the package ID and version are still reserved.
///
FailedValidation = 3,
+
+ ///
+ /// The package has completed validation successfully but has not yet been published. Staged packages are not
+ /// visible to consumers: they are not searchable or restorable and they do not appear in any V3 artifact. A
+ /// staged package moves to when it is promoted through the Gallery. When packages are
+ /// in this state, they are not available but the package ID and version are still reserved.
+ ///
+ Staged = 4,
}
}
diff --git a/src/NuGet.Services.Entities/StagedPackage.cs b/src/NuGet.Services.Entities/StagedPackage.cs
new file mode 100644
index 0000000000..b3cb44da88
--- /dev/null
+++ b/src/NuGet.Services.Entities/StagedPackage.cs
@@ -0,0 +1,107 @@
+// 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.ComponentModel.DataAnnotations;
+
+namespace NuGet.Services.Entities
+{
+ ///
+ /// Ephemeral staging metadata for a package in . The row is created when the
+ /// package is pushed to staging and hard-deleted when the package is promoted, deleted, or expires.
+ ///
+ ///
+ ///
+ /// Every staged package has a required : ownership is stored here rather than derived from the
+ /// package registration's owners, so that a package with no is still attributable to an
+ /// account. This is an accounting concept, not an authorization one — the right to promote is still evaluated
+ /// against the package registration's owners.
+ ///
+ ///
+ /// This data is deliberately kept off the permanent row because it is meaningless once the
+ /// package leaves staging. The durable record of a promotion is .
+ ///
+ ///
+ public class StagedPackage : IEntity
+ {
+ public const int MaxBlobPathLength = 512;
+
+ ///
+ /// Gets or sets the primary key for the entity.
+ ///
+ public int Key { get; set; }
+
+ ///
+ /// Gets or sets the key of the staged package. Unique: a package has at most one staging row.
+ ///
+ public int PackageKey { get; set; }
+
+ ///
+ /// Gets or sets the staged package.
+ ///
+ public virtual Package Package { get; set; }
+
+ ///
+ /// Gets or sets the key of the user or organization that owns this staged package. This is the account the
+ /// package is charged against for , and it is not the same question as
+ /// who owns the package registration: promotion is still authorized against the registration's owners.
+ ///
+ public int OwnerKey { get; set; }
+
+ ///
+ /// Gets or sets the user or organization that owns this staged package.
+ ///
+ public virtual User Owner { get; set; }
+
+ ///
+ /// Gets or sets the key of the group this package belongs to, or null if the package is ungrouped.
+ /// When set, the group's owner must match .
+ ///
+ public int? StagingGroupKey { get; set; }
+
+ ///
+ /// Gets or sets the group this package belongs to, or null if the package is ungrouped.
+ ///
+ public virtual StagingGroup StagingGroup { get; set; }
+
+ ///
+ /// Gets or sets when the package entered staging. The timestamp is in UTC.
+ ///
+ public DateTime CreatedDate { get; set; }
+
+ ///
+ /// Gets or sets how far validation has got for this package. A staged package stays in
+ /// throughout, so this is the only signal that it is promotable.
+ ///
+ public StagedValidationStatus ValidationStatus { get; set; }
+
+ ///
+ /// Gets or sets the tracking ID of the validation set for this row's current content, assigned at push time
+ /// and rewritten whenever the content is replaced. Validation outcomes from any other set describe content
+ /// that has since been superseded and must be ignored, since a replace reuses the same package key.
+ ///
+ public Guid ValidationTrackingId { get; set; }
+
+ ///
+ /// Gets or sets the absolute deadline after which this package is eligible for cleanup. For grouped packages
+ /// this mirrors the group's expiration; for ungrouped packages it is set at push time and reset on replace.
+ /// The timestamp is in UTC.
+ ///
+ public DateTime ExpirationDate { get; set; }
+
+ ///
+ /// Gets or sets the path to the nupkg in the private staging container. The path embeds a GUID so that
+ /// deleting and re-uploading the same id and version cannot collide with the outgoing blob.
+ ///
+ [Required]
+ [StringLength(MaxBlobPathLength)]
+ public string BlobPath { get; set; }
+
+ ///
+ /// Gets or sets the path to the companion snupkg in the private staging container, or null if no
+ /// symbol package has been staged. Set once symbol validation succeeds.
+ ///
+ [StringLength(MaxBlobPathLength)]
+ public string SnupkgBlobPath { get; set; }
+ }
+}
diff --git a/src/NuGet.Services.Entities/StagedValidationStatus.cs b/src/NuGet.Services.Entities/StagedValidationStatus.cs
new file mode 100644
index 0000000000..822a1f0fb5
--- /dev/null
+++ b/src/NuGet.Services.Entities/StagedValidationStatus.cs
@@ -0,0 +1,28 @@
+// 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 NuGet.Services.Entities
+{
+ ///
+ /// The validation state of a package in . A staged package stays in
+ /// for its whole time in staging; this tracks how far validation got.
+ ///
+ public enum StagedValidationStatus
+ {
+ ///
+ /// Validation is in progress. This is the state a package is created in at push time.
+ ///
+ Validating = 0,
+
+ ///
+ /// Validation completed successfully. Only packages in this state may be promoted.
+ ///
+ Succeeded = 1,
+
+ ///
+ /// Validation failed. The package remains staged so the owner can inspect the failure, replace it, or
+ /// delete it, but it can never be promoted.
+ ///
+ Failed = 2,
+ }
+}
diff --git a/src/NuGet.Services.Entities/StagingBlobCleanup.cs b/src/NuGet.Services.Entities/StagingBlobCleanup.cs
new file mode 100644
index 0000000000..c9c87ad8b2
--- /dev/null
+++ b/src/NuGet.Services.Entities/StagingBlobCleanup.cs
@@ -0,0 +1,47 @@
+// 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.ComponentModel.DataAnnotations;
+
+namespace NuGet.Services.Entities
+{
+ ///
+ /// A table-as-queue of staging blobs awaiting deletion. Entries are enqueued in the same transaction that removes
+ /// a row, and drained later by the Gallery.Maintenance cleanup task.
+ ///
+ ///
+ /// Blob deletion is deferred rather than performed inline so that promotion and delete requests do not block on
+ /// storage calls. This follows the existing PackageRevalidations table-as-queue pattern.
+ ///
+ /// The queue is drained in primary key order, which the clustered key already provides, so
+ /// is deliberately not indexed. It exists for diagnosing a backed-up queue, not for
+ /// ordering it.
+ ///
+ ///
+ public class StagingBlobCleanup : IEntity
+ {
+ ///
+ /// Matches : every path enqueued here comes from a
+ /// , so the two must not drift.
+ ///
+ public const int MaxBlobPathLength = StagedPackage.MaxBlobPathLength;
+
+ ///
+ /// Gets or sets the primary key for the entity.
+ ///
+ public int Key { get; set; }
+
+ ///
+ /// Gets or sets the path of the blob in the staging container that needs to be deleted.
+ ///
+ [Required]
+ [StringLength(MaxBlobPathLength)]
+ public string BlobPath { get; set; }
+
+ ///
+ /// Gets or sets when this entry was enqueued. The timestamp is in UTC.
+ ///
+ public DateTime CreatedDate { get; set; }
+ }
+}
diff --git a/src/NuGet.Services.Entities/StagingGroup.cs b/src/NuGet.Services.Entities/StagingGroup.cs
new file mode 100644
index 0000000000..8c0bb66cd1
--- /dev/null
+++ b/src/NuGet.Services.Entities/StagingGroup.cs
@@ -0,0 +1,78 @@
+// 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.ComponentModel.DataAnnotations;
+
+namespace NuGet.Services.Entities
+{
+ ///
+ /// A named collection of staged packages belonging to a single owner. Groups let an author stage a set of
+ /// interdependent packages and promote them together, so consumers never observe a partially published set.
+ ///
+ ///
+ ///
+ /// Groups are optional. A staged package with no group has a null .
+ /// A staged package belongs to at most one group, enforced by the unique index on
+ /// .
+ ///
+ ///
+ /// Groups are hard-deleted. Deleting a group removes its rows and enqueues their
+ /// blobs to in the same transaction, so the group row has no remaining
+ /// dependents and there is nothing left for the cleanup job to defer.
+ ///
+ ///
+ public class StagingGroup : IEntity
+ {
+ public const int MaxIdLength = 64;
+ public const int MaxNameLength = 256;
+
+ ///
+ /// Gets or sets the primary key for the entity.
+ ///
+ public int Key { get; set; }
+
+ ///
+ /// Gets or sets the user-specified slug for this group: lowercase alphanumeric characters and dashes.
+ /// Unique per owner, not globally.
+ ///
+ [Required]
+ [StringLength(MaxIdLength)]
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets the key of the user or organization that owns this group.
+ ///
+ public int OwnerKey { get; set; }
+
+ ///
+ /// Gets or sets the user or organization that owns this group. Any user with push permissions under this
+ /// owner may add packages to the group, promote it, or delete it.
+ ///
+ public virtual User Owner { get; set; }
+
+ ///
+ /// Gets or sets the display name for this group. Set to when the author does not supply one.
+ ///
+ [Required]
+ [StringLength(MaxNameLength)]
+ public string Name { get; set; }
+
+ ///
+ /// Gets or sets when the group was created. The timestamp is in UTC.
+ ///
+ public DateTime CreatedDate { get; set; }
+
+ ///
+ /// Gets or sets the absolute deadline after which this group is eligible for cleanup. Set on creation and
+ /// reset on every push to the group. The timestamp is in UTC.
+ ///
+ public DateTime ExpirationDate { get; set; }
+
+ ///
+ /// Gets or sets the packages staged in this group.
+ ///
+ public virtual ICollection StagedPackages { get; set; }
+ }
+}
diff --git a/src/NuGet.Services.Entities/User.cs b/src/NuGet.Services.Entities/User.cs
index 9520de0c3b..7504846b5d 100644
--- a/src/NuGet.Services.Entities/User.cs
+++ b/src/NuGet.Services.Entities/User.cs
@@ -124,6 +124,13 @@ public bool Confirmed
public bool IsLocked => UserStatusKey == UserStatus.Locked;
+ ///
+ /// The maximum number of packages this account may have staged at one time, across all staging groups
+ /// and the ungrouped bucket. When null, the service-wide default applies. Set via the admin panel to
+ /// grant an elevated limit to accounts that publish in large batches.
+ ///
+ public int? StagingPackageLimit { get; set; }
+
public string LastSavedEmailAddress
{
get
diff --git a/src/NuGetGallery.Core/Entities/EntitiesContext.cs b/src/NuGetGallery.Core/Entities/EntitiesContext.cs
index de1b164904..8aedf3b5b2 100644
--- a/src/NuGetGallery.Core/Entities/EntitiesContext.cs
+++ b/src/NuGetGallery.Core/Entities/EntitiesContext.cs
@@ -84,6 +84,9 @@ public IDisposable WithQueryHint(string queryHint)
public DbSet Vulnerabilities { get; set; }
public DbSet VulnerableRanges { get; set; }
public DbSet PackageRenames { get; set; }
+ public DbSet StagingGroups { get; set; }
+ public DbSet StagedPackages { get; set; }
+ public DbSet StagingBlobCleanups { get; set; }
public DbSet FederatedCredentialPolicies { get; set; }
public DbSet FederatedCredentials { get; set; }
@@ -360,6 +363,12 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder)
modelBuilder.Entity()
.HasOptional(p => p.Certificate);
+ modelBuilder.Entity()
+ .HasOptional(p => p.ApproverUser)
+ .WithMany()
+ .HasForeignKey(p => p.ApproverUserKey)
+ .WillCascadeOnDelete(false);
+
modelBuilder.Entity()
.HasKey(pm => pm.Key);
@@ -608,6 +617,96 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder)
modelBuilder.Entity()
.HasIndex(x => x.FederatedCredentialPolicyKey);
+
+ modelBuilder.Entity()
+ .HasKey(g => g.Key);
+
+ modelBuilder.Entity()
+ .Property(g => g.CreatedDate)
+ .HasColumnType("datetime2");
+
+ modelBuilder.Entity()
+ .Property(g => g.ExpirationDate)
+ .HasColumnType("datetime2");
+
+ // Group slugs are unique per owner, not globally. OwnerKey leads the index so it also serves
+ // "list the groups for this owner".
+ // ExpirationDate is deliberately not indexed here, unlike on StagedPackage. It is rewritten on every push
+ // to the group, so an index would add write amplification to the hot path to speed up a sweep over a table
+ // bounded at StagingConstants.MaxGroupsPerOwner rows per owner.
+ modelBuilder.Entity()
+ .HasIndex(g => new { g.OwnerKey, g.Id })
+ .IsUnique();
+
+ modelBuilder.Entity()
+ .HasRequired(g => g.Owner)
+ .WithMany()
+ .HasForeignKey(g => g.OwnerKey)
+ .WillCascadeOnDelete(false); // users are only soft deleted today
+
+ modelBuilder.Entity()
+ .HasKey(s => s.Key);
+
+ modelBuilder.Entity()
+ .Property(s => s.CreatedDate)
+ .HasColumnType("datetime2");
+
+ modelBuilder.Entity()
+ .Property(s => s.ExpirationDate)
+ .HasColumnType("datetime2");
+
+ // One staging row per package. This is what enforces "a package belongs to at most one group".
+ modelBuilder.Entity()
+ .HasIndex(s => s.PackageKey)
+ .IsUnique();
+
+ // Drives the cleanup job's expiration sweep.
+ modelBuilder.Entity()
+ .HasIndex(s => s.ExpirationDate);
+
+ // The owner is stored on the staged package rather than being reached through the group, so that an
+ // ungrouped package is still attributable to an account. This is what makes "list my staged packages" and
+ // the quota count a single seek.
+ // Note this is an accounting concept, not an authorization one: the right to promote is still checked
+ // against the package registration's owners.
+ modelBuilder.Entity()
+ .HasIndex(s => s.OwnerKey);
+
+ modelBuilder.Entity()
+ .HasRequired(s => s.Owner)
+ .WithMany()
+ .HasForeignKey(s => s.OwnerKey)
+ .WillCascadeOnDelete(false); // users are only soft deleted today
+
+ // Deliberately no cascade. A cascade would let the database drop the staging row without enqueueing its
+ // blobs into StagingBlobCleanup, orphaning them in the staging container. Package deletes must instead go
+ // through the cleanup path, which is also why PackageDeleteService removes child rows explicitly rather
+ // than relying on cascades.
+ modelBuilder.Entity()
+ .HasRequired(s => s.Package)
+ .WithMany()
+ .HasForeignKey(s => s.PackageKey)
+ .WillCascadeOnDelete(false);
+
+ // Deliberately no cascade: deleting a group must go through the cleanup path so that each member's
+ // staging blobs are enqueued into StagingBlobCleanup first. A cascade would drop the rows and orphan
+ // the blobs.
+ // The invariant that a group member's OwnerKey matches its group's OwnerKey is enforced in the staging
+ // service rather than by the database. Expressing it declaratively would need a composite foreign key
+ // referencing a unique constraint on StagingGroups, which EF6 cannot map because it only supports
+ // foreign keys to primary keys.
+ modelBuilder.Entity()
+ .HasOptional(s => s.StagingGroup)
+ .WithMany(g => g.StagedPackages)
+ .HasForeignKey(s => s.StagingGroupKey)
+ .WillCascadeOnDelete(false);
+
+ modelBuilder.Entity()
+ .HasKey(c => c.Key);
+
+ modelBuilder.Entity()
+ .Property(c => c.CreatedDate)
+ .HasColumnType("datetime2");
}
#pragma warning restore 618
diff --git a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs
index 08d3cda78a..1147af9719 100644
--- a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs
+++ b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs
@@ -24,6 +24,9 @@ public interface IEntitiesContext : IReadOnlyEntitiesContext
DbSet Vulnerabilities { get; set; }
DbSet VulnerableRanges { get; set; }
DbSet PackageRenames { get; set; }
+ DbSet StagingGroups { get; set; }
+ DbSet StagedPackages { get; set; }
+ DbSet StagingBlobCleanups { get; set; }
bool HasChanges { get; }
diff --git a/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs b/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs
index 95b1aa0cbe..bb9b3a4052 100644
--- a/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs
+++ b/src/NuGetGallery.Services/AccountManagement/DeleteAccountService.cs
@@ -120,7 +120,9 @@ private async Task DeleteAccountImplAsync(User userToBeDeleted, User userToExecu
ResetPackagesAndAccountsDeletedBy(userToBeDeleted);
RemovePackagePushedBy(userToBeDeleted);
+ RemovePackageApprovedBy(userToBeDeleted);
RemovePackageDeprecatedBy(userToBeDeleted);
+ RemoveStagingGroups(userToBeDeleted);
var organizationToBeDeleted = userToBeDeleted as Organization;
if (organizationToBeDeleted != null)
@@ -235,8 +237,70 @@ private void RemovePackagePushedBy(User user)
}
}
- private List GetPackageRegistrationsOwnedByUser(User user)
+ private void RemovePackageApprovedBy(User user)
{
+ foreach (var package in _entitiesContext
+ .Packages
+ .Where(p => p.ApproverUserKey == user.Key)
+ .ToList())
+ {
+ package.ApproverUser = null;
+ }
+ }
+
+ ///
+ /// Deletes the staging data owned by the account. Both staged packages and staging groups have a required
+ /// owner, so unlike package ownership these rows cannot be orphaned and must be removed outright. Neither
+ /// relationship cascades, so the staged packages are removed here and their blobs enqueued for deferred
+ /// deletion.
+ ///
+ ///
+ /// The underlying rows are left behind in . They are
+ /// invisible on every public surface, but they still occupy their id and version. Reclaiming them requires the
+ /// hard delete path in PackageDeleteService, which is tracked separately.
+ ///
+ private void RemoveStagingGroups(User user)
+ {
+ var stagedPackages = _entitiesContext
+ .StagedPackages
+ .Where(s => s.OwnerKey == user.Key)
+ .ToList();
+
+ foreach (var stagedPackage in stagedPackages)
+ {
+ EnqueueStagingBlobCleanup(stagedPackage.BlobPath);
+ EnqueueStagingBlobCleanup(stagedPackage.SnupkgBlobPath);
+
+ _entitiesContext.StagedPackages.Remove(stagedPackage);
+ }
+
+ // Groups are removed after their members because the foreign key does not cascade.
+ var groups = _entitiesContext
+ .StagingGroups
+ .Where(g => g.OwnerKey == user.Key)
+ .ToList();
+
+ foreach (var group in groups)
+ {
+ _entitiesContext.StagingGroups.Remove(group);
+ }
+ }
+
+ private void EnqueueStagingBlobCleanup(string blobPath)
+ {
+ if (string.IsNullOrEmpty(blobPath))
+ {
+ return;
+ }
+
+ _entitiesContext.StagingBlobCleanups.Add(new StagingBlobCleanup
+ {
+ BlobPath = blobPath,
+ CreatedDate = DateTime.UtcNow,
+ });
+ }
+
+ private List GetPackageRegistrationsOwnedByUser(User user) {
return _packageService
.FindPackageRegistrationsByOwner(user)
.ToList();
diff --git a/src/NuGetGallery/Controllers/PackagesController.cs b/src/NuGetGallery/Controllers/PackagesController.cs
index e8840fbd96..cee2fc285d 100644
--- a/src/NuGetGallery/Controllers/PackagesController.cs
+++ b/src/NuGetGallery/Controllers/PackagesController.cs
@@ -1996,6 +1996,7 @@ public virtual ActionResult DeleteSymbols(string id, string version)
// Fetch all versions of the package with symbols.
var versionsWithSymbols = packages
.Where(p => p.PackageStatusKey != PackageStatus.Deleted)
+ .Where(p => p.PackageStatusKey != PackageStatus.Staged)
.Where(p => (p.LatestSymbolPackage()?.StatusKey ?? PackageStatus.Deleted) == PackageStatus.Available)
.OrderByDescending(p => new NuGetVersion(p.Version));
diff --git a/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs b/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs
index 8306e08a4f..9ad795d274 100644
--- a/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs
+++ b/src/NuGetGallery/Helpers/ViewModelExtensions/PackageViewModelFactory.cs
@@ -80,6 +80,10 @@ private static PackageStatusSummary GetPackageStatusSummary(PackageStatus packag
{
return PackageStatusSummary.None;
}
+ case PackageStatus.Staged:
+ {
+ return PackageStatusSummary.Staged;
+ }
default:
throw new ArgumentOutOfRangeException(nameof(packageStatus));
}
diff --git a/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.Designer.cs b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.Designer.cs
new file mode 100644
index 0000000000..ed4203228b
--- /dev/null
+++ b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.Designer.cs
@@ -0,0 +1,29 @@
+//
+namespace NuGetGallery.Migrations
+{
+ using System.CodeDom.Compiler;
+ using System.Data.Entity.Migrations;
+ using System.Data.Entity.Migrations.Infrastructure;
+ using System.Resources;
+
+ [GeneratedCode("EntityFramework.Migrations", "6.5.1")]
+ public sealed partial class AddPackageStaging : IMigrationMetadata
+ {
+ private readonly ResourceManager Resources = new ResourceManager(typeof(AddPackageStaging));
+
+ string IMigrationMetadata.Id
+ {
+ get { return "202607290027439_AddPackageStaging"; }
+ }
+
+ string IMigrationMetadata.Source
+ {
+ get { return null; }
+ }
+
+ string IMigrationMetadata.Target
+ {
+ get { return Resources.GetString("Target"); }
+ }
+ }
+}
diff --git a/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.cs b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.cs
new file mode 100644
index 0000000000..50762ca308
--- /dev/null
+++ b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.cs
@@ -0,0 +1,105 @@
+namespace NuGetGallery.Migrations
+{
+ using System;
+ using System.Data.Entity.Migrations;
+
+ public partial class AddPackageStaging : DbMigration
+ {
+ public override void Up()
+ {
+ CreateTable(
+ "dbo.StagedPackages",
+ c => new
+ {
+ Key = c.Int(nullable: false, identity: true),
+ PackageKey = c.Int(nullable: false),
+ OwnerKey = c.Int(nullable: false),
+ StagingGroupKey = c.Int(),
+ CreatedDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"),
+ ValidationStatus = c.Int(nullable: false),
+ ValidationTrackingId = c.Guid(nullable: false),
+ ExpirationDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"),
+ BlobPath = c.String(nullable: false, maxLength: 512),
+ SnupkgBlobPath = c.String(maxLength: 512),
+ })
+ .PrimaryKey(t => t.Key)
+ .ForeignKey("dbo.Users", t => t.OwnerKey)
+ .ForeignKey("dbo.Packages", t => t.PackageKey)
+ .ForeignKey("dbo.StagingGroups", t => t.StagingGroupKey)
+ .Index(t => t.PackageKey, unique: true)
+ .Index(t => t.OwnerKey)
+ .Index(t => t.StagingGroupKey)
+ .Index(t => t.ExpirationDate);
+
+ CreateTable(
+ "dbo.StagingGroups",
+ c => new
+ {
+ Key = c.Int(nullable: false, identity: true),
+ Id = c.String(nullable: false, maxLength: 64),
+ OwnerKey = c.Int(nullable: false),
+ Name = c.String(nullable: false, maxLength: 256),
+ CreatedDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"),
+ ExpirationDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"),
+ })
+ .PrimaryKey(t => t.Key)
+ .ForeignKey("dbo.Users", t => t.OwnerKey)
+ .Index(t => new { t.OwnerKey, t.Id }, unique: true);
+
+ CreateTable(
+ "dbo.StagingBlobCleanups",
+ c => new
+ {
+ Key = c.Int(nullable: false, identity: true),
+ BlobPath = c.String(nullable: false, maxLength: 512),
+ CreatedDate = c.DateTime(nullable: false, precision: 7, storeType: "datetime2"),
+ })
+ .PrimaryKey(t => t.Key);
+
+ AddColumn("dbo.Users", "StagingPackageLimit", c => c.Int());
+ AddColumn("dbo.Packages", "ApproverUserKey", c => c.Int());
+
+ // dbo.Packages is one of the largest tables in the gallery database, so the index backing the
+ // ApproverUserKey foreign key is created online when the target supports it. The indexes on the
+ // staging tables above are left to the scaffolded CreateTable calls because those tables are new
+ // and therefore empty.
+ // "WITH (ONLINE = ON)" is not supported on all editions of SQL Server. We want to create the index in the background
+ // when we are deploying to our live environment on Azure (which supports online index creation).
+ // Editions: https://docs.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql?view=sql-server-ver15#arguments
+ // We used sp_executesql because it is blocked on SQL that does not support "WITH (ONLINE = ON)".
+ Sql(@"IF SERVERPROPERTY('edition') = 'SQL Azure'
+ BEGIN
+ EXECUTE sp_executesql N'CREATE NONCLUSTERED INDEX [IX_ApproverUserKey]
+ ON [dbo].[Packages] ([ApproverUserKey])
+ WITH (ONLINE = ON)'
+ END
+ ELSE
+ BEGIN
+ CREATE NONCLUSTERED INDEX [IX_ApproverUserKey]
+ ON [dbo].[Packages] ([ApproverUserKey])
+ END");
+
+ AddForeignKey("dbo.Packages", "ApproverUserKey", "dbo.Users", "Key");
+ }
+
+ public override void Down()
+ {
+ DropForeignKey("dbo.StagedPackages", "StagingGroupKey", "dbo.StagingGroups");
+ DropForeignKey("dbo.StagingGroups", "OwnerKey", "dbo.Users");
+ DropForeignKey("dbo.StagedPackages", "PackageKey", "dbo.Packages");
+ DropForeignKey("dbo.StagedPackages", "OwnerKey", "dbo.Users");
+ DropForeignKey("dbo.Packages", "ApproverUserKey", "dbo.Users");
+ DropIndex("dbo.StagingGroups", new[] { "OwnerKey", "Id" });
+ DropIndex("dbo.StagedPackages", new[] { "ExpirationDate" });
+ DropIndex("dbo.StagedPackages", new[] { "StagingGroupKey" });
+ DropIndex("dbo.StagedPackages", new[] { "OwnerKey" });
+ DropIndex("dbo.StagedPackages", new[] { "PackageKey" });
+ DropIndex(table: "dbo.Packages", name: "IX_ApproverUserKey");
+ DropColumn("dbo.Packages", "ApproverUserKey");
+ DropColumn("dbo.Users", "StagingPackageLimit");
+ DropTable("dbo.StagingBlobCleanups");
+ DropTable("dbo.StagingGroups");
+ DropTable("dbo.StagedPackages");
+ }
+ }
+}
diff --git a/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.resx b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.resx
new file mode 100644
index 0000000000..bda076f631
--- /dev/null
+++ b/src/NuGetGallery/Migrations/202607290027439_AddPackageStaging.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 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
+
+
+ dbo
+
+
\ No newline at end of file
diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj
index 3b27e15866..0e278a9728 100644
--- a/src/NuGetGallery/NuGetGallery.csproj
+++ b/src/NuGetGallery/NuGetGallery.csproj
@@ -395,6 +395,10 @@
202606092223210_UpdatePackageKeyIndexOnPackageDeprecations.cs
+
+
+ 202607290027439_AddPackageStaging.cs
+
@@ -1783,6 +1787,9 @@
202606092223210_UpdatePackageKeyIndexOnPackageDeprecations.cs
+
+ 202607290027439_AddPackageStaging.cs
+
diff --git a/src/NuGetGallery/ViewModels/PackageStatusSummary.cs b/src/NuGetGallery/ViewModels/PackageStatusSummary.cs
index 9c8978c5f3..7eba0d5f72 100644
--- a/src/NuGetGallery/ViewModels/PackageStatusSummary.cs
+++ b/src/NuGetGallery/ViewModels/PackageStatusSummary.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
@@ -19,5 +19,7 @@ public enum PackageStatusSummary
Listed,
[Description("Unlisted")]
Unlisted,
+ [Description("Staged")]
+ Staged,
}
-}
\ No newline at end of file
+}
diff --git a/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs b/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs
index 4557dfbeab..a8c4f76b89 100644
--- a/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs
+++ b/src/VerifyMicrosoftPackage/Fakes/FakeEntitiesContext.cs
@@ -27,6 +27,9 @@ class FakeEntitiesContext : IEntitiesContext
public DbSet VulnerableRanges { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public DbSet Packages { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public DbSet PackageRenames { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+ public DbSet StagingGroups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+ public DbSet StagedPackages { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+ public DbSet StagingBlobCleanups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public string QueryHint => throw new NotImplementedException();
diff --git a/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs b/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs
index 2e567c088e..85e48bc9fa 100644
--- a/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs
+++ b/tests/NuGet.Services.AzureSearch.Tests/DatabaseAuxiliaryDataFetcherFacts.cs
@@ -133,6 +133,9 @@ private class DisposableEntitiesContext : IEntitiesContext, IDisposable
public DbSet Vulnerabilities { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public DbSet VulnerableRanges { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public DbSet PackageRenames { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+ public DbSet StagingGroups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+ public DbSet StagedPackages { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+ public DbSet StagingBlobCleanups { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public string QueryHint => throw new NotImplementedException();
diff --git a/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs b/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs
index ed8b1bb68c..7b2366303a 100644
--- a/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs
+++ b/tests/NuGet.Services.Entities.Tests/PackageStatusFacts.cs
@@ -20,6 +20,7 @@ public class PackageStatusFacts
{ PackageStatus.Deleted, 1 },
{ PackageStatus.Validating, 2 },
{ PackageStatus.FailedValidation, 3 },
+ { PackageStatus.Staged, 4 },
};
[Fact]
diff --git a/tests/NuGet.Services.Entities.Tests/StagedValidationStatusFacts.cs b/tests/NuGet.Services.Entities.Tests/StagedValidationStatusFacts.cs
new file mode 100644
index 0000000000..bb19933209
--- /dev/null
+++ b/tests/NuGet.Services.Entities.Tests/StagedValidationStatusFacts.cs
@@ -0,0 +1,24 @@
+// 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 Xunit;
+
+namespace NuGet.Services.Entities.Tests
+{
+ public class StagedValidationStatusFacts
+ {
+ ///
+ /// These values are persisted to the database as integers, and
+ /// is what the promotion eligibility check compares against, so reordering them would make validation-failed
+ /// packages promotable. Nothing else catches this: the values live in the conceptual model only, so the
+ /// migration drift test sees no change.
+ ///
+ [Fact]
+ public void ValuesAreNotChanged()
+ {
+ Assert.Equal(0, (int)StagedValidationStatus.Validating);
+ Assert.Equal(1, (int)StagedValidationStatus.Succeeded);
+ Assert.Equal(2, (int)StagedValidationStatus.Failed);
+ }
+ }
+}
diff --git a/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs b/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs
index 08a691b5c5..25d223373d 100644
--- a/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs
+++ b/tests/NuGetGallery.Facts/Services/DeleteAccountServiceFacts.cs
@@ -1,8 +1,9 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using Moq;
@@ -132,6 +133,7 @@ await deleteAccountService.DeleteAccountAsync(
Assert.True(registration.Packages.Single().Listed);
Assert.NotNull(testUser.EmailAddress);
Assert.NotNull(testableService.PackagePushedByUser.User);
+ Assert.NotNull(testableService.PackageApprovedByUser.ApproverUser);
Assert.NotNull(testableService.DeprecationDeprecatedByUser.DeprecatedByUser);
Assert.Empty(testableService.DeletedAccounts);
Assert.NotEmpty(testableService.PackageOwnerRequests);
@@ -143,6 +145,7 @@ await deleteAccountService.DeleteAccountAsync(
Assert.NotEmpty(testUser.Organizations);
Assert.NotNull(testableService.PackageDeletedByUser.DeletedBy);
Assert.NotNull(testableService.AccountDeletedByUser.DeletedBy);
+ testableService.AssertStagingGroupsPreserved();
}
else
{
@@ -153,6 +156,7 @@ await deleteAccountService.DeleteAccountAsync(
!registration.Packages.Single().Listed);
Assert.Null(testUser.EmailAddress);
Assert.Null(testableService.PackagePushedByUser.User);
+ Assert.Null(testableService.PackageApprovedByUser.ApproverUser);
Assert.Null(testableService.DeprecationDeprecatedByUser.DeprecatedByUser);
Assert.Single(testableService.DeletedAccounts);
Assert.Empty(testableService.PackageOwnerRequests);
@@ -163,6 +167,7 @@ await deleteAccountService.DeleteAccountAsync(
Assert.Empty(testUser.OrganizationRequests);
Assert.Null(testableService.PackageDeletedByUser.DeletedBy);
Assert.Null(testableService.AccountDeletedByUser.DeletedBy);
+ testableService.AssertStagingGroupsRemoved();
Assert.Empty(testUser.Organizations);
foreach (var testUserOrganization in testUserOrganizations)
@@ -271,6 +276,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph
Assert.Contains(registration.Owners, o => o.MatchesUser(organization));
Assert.NotEmpty(organization.SecurityPolicies);
Assert.NotNull(testableService.PackagePushedByUser.User);
+ Assert.NotNull(testableService.PackageApprovedByUser.ApproverUser);
Assert.NotNull(testableService.DeprecationDeprecatedByUser.DeprecatedByUser);
Assert.Empty(testableService.DeletedAccounts);
Assert.NotEmpty(testableService.PackageOwnerRequests);
@@ -279,6 +285,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph
Assert.Empty(testableService.AuditService.Records);
Assert.NotNull(testableService.PackageDeletedByUser.DeletedBy);
Assert.NotNull(testableService.AccountDeletedByUser.DeletedBy);
+ testableService.AssertStagingGroupsPreserved();
}
else
{
@@ -290,6 +297,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph
Assert.DoesNotContain(registration.Owners, o => o.MatchesUser(organization));
Assert.Empty(organization.SecurityPolicies);
Assert.Null(testableService.PackagePushedByUser.User);
+ Assert.Null(testableService.PackageApprovedByUser.ApproverUser);
Assert.Null(testableService.DeprecationDeprecatedByUser.DeprecatedByUser);
Assert.Single(testableService.DeletedAccounts);
Assert.Empty(testableService.PackageOwnerRequests);
@@ -297,6 +305,7 @@ public async Task DeleteOrganization(bool isPackageOrphaned, AccountDeletionOrph
Assert.True(testableService.HasDeletedOwnerScope);
Assert.Null(testableService.PackageDeletedByUser.DeletedBy);
Assert.Null(testableService.AccountDeletedByUser.DeletedBy);
+ testableService.AssertStagingGroupsRemoved();
var deleteRecord = testableService.AuditService.Records[0] as DeleteAccountAuditRecord;
Assert.True(deleteRecord != null);
@@ -357,12 +366,14 @@ public async Task DeleteUnconfirmedOrganization(bool isPackageOrphaned)
Assert.Empty(organization.SecurityPolicies);
Assert.Empty(organization.ReservedNamespaces);
Assert.Null(testableService.PackagePushedByUser.User);
+ Assert.Null(testableService.PackageApprovedByUser.ApproverUser);
Assert.Null(testableService.DeprecationDeprecatedByUser.DeprecatedByUser);
Assert.Empty(testableService.DeletedAccounts);
Assert.Single(testableService.SupportRequests);
Assert.Empty(testableService.PackageOwnerRequests);
Assert.True(testableService.HasDeletedOwnerScope);
Assert.Single(testableService.AuditService.Records);
+ testableService.AssertStagingGroupsRemoved();
var deleteRecord = testableService.AuditService.Records[0] as DeleteAccountAuditRecord;
Assert.True(deleteRecord != null);
@@ -542,6 +553,17 @@ public class DeleteAccountTestService
public List DeletedUsers = new List();
public List SupportRequests = new List();
public Package PackagePushedByUser;
+ public Package PackageApprovedByUser;
+ public StagingGroup StagingGroupOwnedByUser;
+ public StagingGroup StagingGroupOwnedByDifferentUser;
+ public StagedPackage StagedPackageInUserGroup;
+ public StagedPackage UngroupedStagedPackage;
+ public StagedPackage StagedPackageOwnedByDifferentUser;
+
+ public DbSet StagingGroupDbSet;
+ public DbSet StagedPackageDbSet;
+ public DbSet StagingBlobCleanupDbSet;
+
public PackageDeprecation DeprecationDeprecatedByUser;
public List PackageOwnerRequests = new List();
public FakeAuditingService AuditService = new FakeAuditingService();
@@ -614,11 +636,62 @@ public DeleteAccountTestService(User user, PackageRegistration userPackagesRegis
UserKey = _user.Key
};
+ PackageApprovedByUser = new Package
+ {
+ ApproverUser = _user,
+ ApproverUserKey = _user.Key
+ };
+
DeprecationDeprecatedByUser = new PackageDeprecation
{
DeprecatedByUser = _user,
DeprecatedByUserKey = _user.Key
};
+
+ StagingGroupOwnedByUser = new StagingGroup
+ {
+ Key = 3001,
+ Id = "preview-1",
+ Owner = _user,
+ OwnerKey = _user.Key
+ };
+
+ StagingGroupOwnedByDifferentUser = new StagingGroup
+ {
+ Key = 3002,
+ Id = "someone-else",
+ Owner = new User { Key = 1111 },
+ OwnerKey = 1111
+ };
+
+ StagedPackageInUserGroup = new StagedPackage
+ {
+ Key = 4001,
+ Owner = _user,
+ OwnerKey = _user.Key,
+ StagingGroup = StagingGroupOwnedByUser,
+ StagingGroupKey = StagingGroupOwnedByUser.Key,
+ BlobPath = "staging/abc/package.nupkg",
+ SnupkgBlobPath = "staging/abc/package.snupkg"
+ };
+
+ UngroupedStagedPackage = new StagedPackage
+ {
+ Key = 4002,
+ Owner = _user,
+ OwnerKey = _user.Key,
+ StagingGroupKey = null,
+ BlobPath = "staging/def/ungrouped.nupkg"
+ };
+
+ StagedPackageOwnedByDifferentUser = new StagedPackage
+ {
+ Key = 4003,
+ Owner = new User { Key = 1111 },
+ OwnerKey = 1111,
+ StagingGroupKey = null,
+ BlobPath = "staging/ghi/other.nupkg"
+ };
}
public DeleteAccountTestService()
@@ -662,6 +735,39 @@ public User User
get { return _user; }
}
+ ///
+ /// Asserts the account's staged packages and groups were removed, that another account's staged package
+ /// and group were left alone, and that every removed package's staging blobs were enqueued for deferred
+ /// deletion. Ungrouped packages are removed too, because they carry their own owner.
+ ///
+ public void AssertStagingGroupsRemoved()
+ {
+ Assert.Equal(
+ new[] { StagingGroupOwnedByDifferentUser },
+ StagingGroupDbSet.ToList());
+ Assert.Equal(
+ new[] { StagedPackageOwnedByDifferentUser },
+ StagedPackageDbSet.ToList());
+ Assert.Equal(
+ new[]
+ {
+ StagedPackageInUserGroup.BlobPath,
+ StagedPackageInUserGroup.SnupkgBlobPath,
+ UngroupedStagedPackage.BlobPath,
+ },
+ StagingBlobCleanupDbSet.Select(c => c.BlobPath).ToList());
+ }
+
+ ///
+ /// Asserts nothing about staging was touched, for the cases where the delete is expected to fail.
+ ///
+ public void AssertStagingGroupsPreserved()
+ {
+ Assert.Equal(2, StagingGroupDbSet.Count());
+ Assert.Equal(3, StagedPackageDbSet.Count());
+ Assert.Empty(StagingBlobCleanupDbSet);
+ }
+
private Mock SetupEntitiesContext()
{
var mockContext = new Mock();
@@ -684,6 +790,51 @@ private Mock SetupEntitiesContext()
packageDbSet.Add(PackagePushedByUser);
}
+ if (PackageApprovedByUser != null)
+ {
+ packageDbSet.Add(PackageApprovedByUser);
+ }
+
+ StagingGroupDbSet = FakeEntitiesContext.CreateDbSet();
+ mockContext
+ .Setup(x => x.StagingGroups)
+ .Returns(StagingGroupDbSet);
+
+ StagedPackageDbSet = FakeEntitiesContext.CreateDbSet();
+ mockContext
+ .Setup(x => x.StagedPackages)
+ .Returns(StagedPackageDbSet);
+
+ StagingBlobCleanupDbSet = FakeEntitiesContext.CreateDbSet();
+ mockContext
+ .Setup(x => x.StagingBlobCleanups)
+ .Returns(StagingBlobCleanupDbSet);
+
+ if (StagingGroupOwnedByUser != null)
+ {
+ StagingGroupDbSet.Add(StagingGroupOwnedByUser);
+ }
+
+ if (StagingGroupOwnedByDifferentUser != null)
+ {
+ StagingGroupDbSet.Add(StagingGroupOwnedByDifferentUser);
+ }
+
+ if (StagedPackageInUserGroup != null)
+ {
+ StagedPackageDbSet.Add(StagedPackageInUserGroup);
+ }
+
+ if (UngroupedStagedPackage != null)
+ {
+ StagedPackageDbSet.Add(UngroupedStagedPackage);
+ }
+
+ if (StagedPackageOwnedByDifferentUser != null)
+ {
+ StagedPackageDbSet.Add(StagedPackageOwnedByDifferentUser);
+ }
+
return mockContext;
}
@@ -916,4 +1067,4 @@ private Mock SetupTelemetryService()
}
}
}
-}
\ No newline at end of file
+}
diff --git a/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs b/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs
index b66fa9c3f3..1264c5cb14 100644
--- a/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs
+++ b/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs
@@ -48,6 +48,24 @@ public DbSet Deprecations
set => throw new NotSupportedException();
}
+ public DbSet StagingGroups
+ {
+ get => Set();
+ set => throw new NotSupportedException();
+ }
+
+ public DbSet StagedPackages
+ {
+ get => Set();
+ set => throw new NotSupportedException();
+ }
+
+ public DbSet StagingBlobCleanups
+ {
+ get => Set();
+ set => throw new NotSupportedException();
+ }
+
public DbSet Credentials
{
get => Set();
diff --git a/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs b/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs
index df2e73d642..59b8caaea8 100644
--- a/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs
+++ b/tests/NuGetGallery.Facts/ViewModels/PackageViewModelFacts.cs
@@ -69,6 +69,8 @@ public void UsesNormalizedPackageVersionIfNormalizedVersionMissing()
[InlineData(PackageStatus.FailedValidation, false, PackageStatusSummary.FailedValidation)]
[InlineData(PackageStatus.Validating, true, PackageStatusSummary.Validating)]
[InlineData(PackageStatus.Validating, false, PackageStatusSummary.Validating)]
+ [InlineData(PackageStatus.Staged, true, PackageStatusSummary.Staged)]
+ [InlineData(PackageStatus.Staged, false, PackageStatusSummary.Staged)]
public void PackageStatusSummaryIsCorrect(PackageStatus packageStatus, bool isListed, PackageStatusSummary expected)
{
// Arrange
@@ -95,7 +97,9 @@ public void PackageStatusSummaryThrowsForUnexpectedPackageStatus()
{
Version = "1.0.0",
PackageRegistration = new PackageRegistration { Id = "SomeId" },
- PackageStatusKey = (PackageStatus)4,
+ // Deliberately not a defined PackageStatus. Use a value far outside the defined range so that
+ // adding a new status does not silently turn this test into a no-op.
+ PackageStatusKey = (PackageStatus)9999,
};
// Act & Assert