-
Notifications
You must be signed in to change notification settings - Fork 644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support username casing change in Admin UI #9748
base: dev
Are you sure you want to change the base?
Changes from 8 commits
f9ccefc
edd8110
0158a73
69aa0b3
0976baa
84e2929
fb21e0e
44b0ccf
c20bc34
5b184eb
29a6035
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -3,6 +3,7 @@ | |||||
|
||||||
using System; | ||||||
using System.Collections.Generic; | ||||||
using System.Linq; | ||||||
using System.Net; | ||||||
using System.Text.RegularExpressions; | ||||||
using System.Threading.Tasks; | ||||||
|
@@ -20,6 +21,7 @@ public class ChangeUsernameController : AdminControllerBase | |||||
private readonly IEntitiesContext _entitiesContext; | ||||||
private readonly IDateTimeProvider _dateTimeProvider; | ||||||
private readonly IAuditingService _auditingService; | ||||||
private readonly IPackageService _packageService; | ||||||
|
||||||
private readonly Regex UsernameValidationRegex = new Regex(GalleryConstants.UsernameValidationRegex); | ||||||
|
||||||
|
@@ -28,13 +30,15 @@ public ChangeUsernameController( | |||||
IEntityRepository<User> userRepository, | ||||||
IEntitiesContext entitiesContext, | ||||||
IDateTimeProvider dateTimeProvider, | ||||||
IAuditingService auditingService) | ||||||
IAuditingService auditingService, | ||||||
IPackageService packageService) | ||||||
{ | ||||||
_userService = userService ?? throw new ArgumentNullException(nameof(userService)); | ||||||
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); | ||||||
_entitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); | ||||||
_dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider)); | ||||||
_auditingService = auditingService ?? throw new ArgumentNullException(nameof(auditingService)); | ||||||
_packageService = packageService ?? throw new ArgumentNullException(nameof(packageService)); | ||||||
} | ||||||
|
||||||
[HttpGet] | ||||||
|
@@ -83,14 +87,28 @@ public ActionResult VerifyAccount(string accountEmailOrUsername) | |||||
} | ||||||
|
||||||
[HttpGet] | ||||||
public ActionResult ValidateNewUsername(string newUsername) | ||||||
public ActionResult ValidateNewUsername(string newUsername, bool checkOwnedPackages, string oldUsername = "") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
{ | ||||||
if (string.IsNullOrEmpty(newUsername)) | ||||||
{ | ||||||
return Json(HttpStatusCode.BadRequest, "Username cannot be null or empty.", JsonRequestBehavior.AllowGet); | ||||||
} | ||||||
|
||||||
var result = ValidateUsername(newUsername); | ||||||
var oldAccount = _userService.FindByUsername(oldUsername); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: oldAccount -> account It may be better to use the same name for local variables under the same class. For example, line 130 uses "account". We can follow the same name convention and also the account itself is still the same but the name will be changed. Personally I think this is more readable and easier to maintain in the future. |
||||||
if (oldAccount == null) | ||||||
{ | ||||||
return Json(HttpStatusCode.NotFound, "Old username account was not found.", JsonRequestBehavior.AllowGet); | ||||||
} | ||||||
|
||||||
var result = ValidateUsernameChange(oldAccount, newUsername); | ||||||
|
||||||
if (checkOwnedPackages) | ||||||
{ | ||||||
var ownedPackages = _packageService.FindPackagesByOwner(oldAccount, includeUnlisted: true) | ||||||
.Where(p => p.PackageStatusKey != PackageStatus.Deleted) | ||||||
.Select(p => p.PackageRegistration.Id); | ||||||
result.OwnedPackageIds = ownedPackages; | ||||||
} | ||||||
|
||||||
return Json(result, JsonRequestBehavior.AllowGet); | ||||||
} | ||||||
|
@@ -116,39 +134,44 @@ public async Task<ActionResult> ChangeUsername(string oldUsername, string newUse | |||||
return Json(HttpStatusCode.NotFound, "Old username account was not found.", JsonRequestBehavior.AllowGet); | ||||||
} | ||||||
|
||||||
var newUsernameValidation = ValidateUsername(newUsername); | ||||||
var newUsernameValidation = ValidateUsernameChange(account, newUsername); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
|
||||||
if (!newUsernameValidation.IsFormatValid || !newUsernameValidation.IsAvailable) | ||||||
{ | ||||||
return Json(HttpStatusCode.BadRequest, "New username validation failed.", JsonRequestBehavior.AllowGet); | ||||||
} | ||||||
|
||||||
var newAccountForOldUsername = new User() | ||||||
if (account.Username.Equals(newUsername, StringComparison.OrdinalIgnoreCase) == false) | ||||||
mariaghiondea marked this conversation as resolved.
Show resolved
Hide resolved
RiadGahlouz marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
{ | ||||||
Username = account.Username, | ||||||
EmailAllowed = false, | ||||||
IsDeleted = true, | ||||||
CreatedUtc = _dateTimeProvider.UtcNow | ||||||
}; | ||||||
// We're doing a full username change and not just a casing change so we need to lock the old username | ||||||
var newAccountForOldUsername = new User() | ||||||
{ | ||||||
Username = account.Username, | ||||||
EmailAllowed = false, | ||||||
IsDeleted = true, | ||||||
CreatedUtc = _dateTimeProvider.UtcNow | ||||||
}; | ||||||
|
||||||
_userRepository.InsertOnCommit(newAccountForOldUsername); | ||||||
} | ||||||
|
||||||
account.Username = newUsername; | ||||||
|
||||||
await _auditingService.SaveAuditRecordAsync(new UserAuditRecord(account, AuditedUserAction.ChangeUsername)); | ||||||
|
||||||
_userRepository.InsertOnCommit(newAccountForOldUsername); | ||||||
|
||||||
await _entitiesContext.SaveChangesAsync(); | ||||||
|
||||||
return Json(HttpStatusCode.OK, "Account renamed successfully!", JsonRequestBehavior.AllowGet); | ||||||
} | ||||||
|
||||||
private ValidateUsernameResult ValidateUsername(string username) | ||||||
private ValidateUsernameResult ValidateUsernameChange(User requestor, string username) | ||||||
{ | ||||||
var result = new ValidateUsernameResult(); | ||||||
result.IsFormatValid = UsernameValidationRegex.IsMatch(username); | ||||||
result.IsAvailable = _userService.FindByUsername(username, includeDeleted: true) == null; | ||||||
var foundUser = _userService.FindByUsername(username, includeDeleted: true); | ||||||
|
||||||
return result; | ||||||
return new ValidateUsernameResult() | ||||||
{ | ||||||
IsFormatValid = UsernameValidationRegex.IsMatch(username), | ||||||
IsAvailable = foundUser == null || (requestor.Key == foundUser.Key && foundUser.Username != username) // The username check is in the event where we found a user in the DB but we're doing a cAsIng change | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
}; | ||||||
} | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +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. | ||
|
||
using System.Collections.Generic; | ||
|
||
namespace NuGetGallery.Areas.Admin.Models | ||
{ | ||
public class ValidateUsernameResult | ||
{ | ||
public bool IsFormatValid { get; set; } | ||
public bool IsAvailable { get; set; } | ||
public IEnumerable<string> OwnedPackageIds { get; set; } = new List<string>(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -57,7 +57,10 @@ | |
|
||
<i>Validate if the new username is available and has a valid format.</i><br /> | ||
|
||
<input type="text" placeholder="Old Username" autocomplete="off" autofocus style="width: 200px;" rows="1" data-bind="value: oldUsername" /> | ||
<input type="text" placeholder="New username" autocomplete="off" autofocus style="width: 200px;" rows="1" data-bind="value: newUsername" /> | ||
<input type="checkbox" id="checkNonDeletedOwnedPackages" data-bind="checked: checkOwnedPackages" /> | ||
<label for="checkNonDeletedOwnedPackages">Check Non-Deleted Owned Packages</label> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If setting this checkbox just adds a list of owned packages, how about changing it to "List ..."? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe even enable it automatically if new/old only differs by the case? |
||
<button type="submit" data-bind="click: validateNewUsername">Validate</button> | ||
<div style="display:none" data-bind="visible: errorNewUsername"> | ||
@ViewHelpers.AlertDanger(@<text><span data-bind="text: errorNewUsername"></span></text>) | ||
|
@@ -69,6 +72,18 @@ | |
<div style="display:none;" data-bind="visible: validatationUsernameResult.IsAvailable()"><i class="ms-Icon ms-Icon--CompletedSolid" style="color: green"></i> Username is available.</div> | ||
<div style="display:none;" data-bind="visible: !validatationUsernameResult.IsAvailable()"><i class="ms-Icon ms-Icon--StatusErrorFull" style="color: red"></i> Username is already taken.</div> | ||
</div> | ||
<table class="table" id="ownedPackages" style="display: none" data-bind="visible: ownedPackages().length > 0 && checkOwnedPackages" aria-label="owned packages"> | ||
<thead> | ||
<tr> | ||
<th>Owned Package Id</th> | ||
</tr> | ||
</thead> | ||
<tbody data-bind="foreach: ownedPackages"> | ||
<tr> | ||
<td><span data-bind="text: $data"></span></td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
</div> | ||
|
||
<div> | ||
|
@@ -105,6 +120,9 @@ | |
} | ||
this.accountAdministrators = ko.observableArray([]); | ||
this.newUsername = ko.observable(''); | ||
this.oldUsername = ko.observable(''); | ||
this.checkOwnedPackages = ko.observable(false); | ||
this.ownedPackages = ko.observableArray([]); | ||
this.validatationUsernameResult = { | ||
IsFormatValid: ko.observable(false), | ||
IsAvailable: ko.observable(false) | ||
|
@@ -161,14 +179,15 @@ | |
|
||
this.validateNewUsername = function () { | ||
$self.doneValidatingNewUsername(false); | ||
$self.ownedPackages([]); | ||
$self.errorNewUsername(''); | ||
|
||
if (!$self.newUsername()) { | ||
$self.errorNewUsername('Username cannot be null or empty.'); | ||
return; | ||
} | ||
|
||
var queryParams = '?newUsername=' + encodeURIComponent($self.newUsername().trim()); | ||
var queryParams = '?newUsername=' + encodeURIComponent($self.newUsername().trim()) + '&oldUsername=' + encodeURIComponent($self.oldUsername().trim()) + '&checkOwnedPackages=' + encodeURIComponent($self.checkOwnedPackages()); | ||
|
||
$.ajax({ | ||
url: actionUrlValidateNewUsername + queryParams, | ||
|
@@ -177,6 +196,7 @@ | |
success: function (data) { | ||
$self.validatationUsernameResult.IsFormatValid(data.IsFormatValid); | ||
$self.validatationUsernameResult.IsAvailable(data.IsAvailable); | ||
$self.ownedPackages(data.OwnedPackageIds); | ||
} | ||
}) | ||
.fail(function (jqXhr, textStatus, errorThrown) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.Collections.Generic; | ||
using System.Linq; | ||
using System.Net; | ||
using System.Threading.Tasks; | ||
|
@@ -104,11 +105,13 @@ public void WhenValidOrganizationAccountReturnsAccountWithAdministrators() | |
public class ValidateNewUsernameMethod : FactsBase | ||
{ | ||
[Theory] | ||
[InlineData("")] | ||
[InlineData(null)] | ||
public void WhenInvalidNewUsernameReturnsBadRequestStatusCode(string newUsername) | ||
[InlineData("", true)] | ||
[InlineData("", false)] | ||
[InlineData(null, true)] | ||
[InlineData(null, false)] | ||
public void WhenInvalidNewUsernameReturnsBadRequestStatusCode(string newUsername, bool checkOwnedPackages) | ||
{ | ||
var result = ChangeUsernameController.ValidateNewUsername(newUsername) as JsonResult; | ||
var result = ChangeUsernameController.ValidateNewUsername(newUsername, checkOwnedPackages) as JsonResult; | ||
|
||
Assert.Equal(((int)HttpStatusCode.BadRequest), ChangeUsernameController.Response.StatusCode); | ||
Assert.Equal("Username cannot be null or empty.", result.Data); | ||
|
@@ -120,13 +123,35 @@ public void WhenInvalidNewUsernameReturnsBadRequestStatusCode(string newUsername | |
[InlineData("availableUsername", true, true)] | ||
public void WhenValidNewUsernameReturnsValidations(string newUsername, bool isFormatValid, bool isAvailable) | ||
{ | ||
var result = ChangeUsernameController.ValidateNewUsername(newUsername) as JsonResult; | ||
var result = ChangeUsernameController.ValidateNewUsername(newUsername, checkOwnedPackages: false) as JsonResult; | ||
|
||
var validations = result.Data as ValidateUsernameResult; | ||
|
||
Assert.Equal(isFormatValid, validations.IsFormatValid); | ||
Assert.Equal(isAvailable, validations.IsAvailable); | ||
} | ||
|
||
[Theory] | ||
[InlineData("someNewUsername", "testOrganization", false)] | ||
[InlineData("someNewUsername", "testUser", true)] | ||
public void WhenCheckOwnedPackagesReturnThem(string newUsername, string oldUsername, bool expectPackages) | ||
{ | ||
var result = ChangeUsernameController.ValidateNewUsername(newUsername, checkOwnedPackages: true, oldUsername) as JsonResult; | ||
|
||
var validations = result.Data as ValidateUsernameResult; | ||
|
||
Assert.True(validations.IsFormatValid); | ||
Assert.True(validations.IsAvailable); | ||
|
||
if(expectPackages) | ||
{ | ||
Assert.NotEmpty(validations.OwnedPackageIds); | ||
} | ||
else | ||
{ | ||
Assert.Empty(validations.OwnedPackageIds); | ||
} | ||
} | ||
} | ||
|
||
public class ChangeUsernameMethod : FactsBase | ||
|
@@ -211,6 +236,32 @@ public async void WhenValidAccountAndNewUsernameReturnsOkStatusCode() | |
Assert.Equal(((int)HttpStatusCode.OK), ChangeUsernameController.Response.StatusCode); | ||
Assert.Equal("Account renamed successfully!", result.Data); | ||
} | ||
|
||
[Fact] | ||
public async void WhenValidAccountCasingChangeAndNewUsernameReturnsOkStatusCode() | ||
{ | ||
var oldUsername = IndividualAccount.Username; | ||
|
||
var result = await ChangeUsernameController.ChangeUsername(IndividualAccount.Username, IndividualAccount.Username.ToUpper()) as JsonResult; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
GetMock<IUserService>().Verify(u => u.FindByUsername(oldUsername, false)); | ||
GetMock<IUserService>().Verify(u => u.FindByUsername(oldUsername.ToUpper(), true)); | ||
GetMock<IEntityRepository<User>>().Verify(r => r.InsertOnCommit(It.IsAny<User>()), Times.Never()); | ||
GetMock<IEntitiesContext>().Verify(c => c.SaveChangesAsync()); | ||
|
||
Assert.Equal(oldUsername.ToUpper(), IndividualAccount.Username); | ||
Assert.Equal(((int)HttpStatusCode.OK), ChangeUsernameController.Response.StatusCode); | ||
Assert.Equal("Account renamed successfully!", result.Data); | ||
} | ||
|
||
[Fact] | ||
public async void FailWhenAccountCasingNotAvailable() | ||
{ | ||
var result = await ChangeUsernameController.ChangeUsername(IndividualAccount.Username, OrganizationAccount.Username.ToUpper()) as JsonResult; | ||
|
||
Assert.Equal(((int)HttpStatusCode.BadRequest), ChangeUsernameController.Response.StatusCode); | ||
Assert.Equal("New username validation failed.", result.Data); | ||
} | ||
} | ||
|
||
public class FactsBase : TestContainer | ||
|
@@ -227,29 +278,36 @@ public FactsBase() | |
OrganizationAccount = new Fakes().Organization; | ||
OrganizationAdministrator = new Fakes().OrganizationAdmin; | ||
|
||
// FindByUsername is case-insensitive in the database, so we need to simulate that here | ||
GetMock<IUserService>() | ||
.Setup(u => u.FindByUsername(IndividualAccount.Username, It.IsAny<bool>())) | ||
.Setup(u => u.FindByUsername(It.Is<string>(x => string.Equals(IndividualAccount.Username, x, System.StringComparison.OrdinalIgnoreCase)), It.IsAny<bool>())) | ||
.Returns(IndividualAccount); | ||
GetMock<IUserService>() | ||
.Setup(u => u.FindByUsername(IndividualAccount.EmailAddress, false)) | ||
.Setup(u => u.FindByUsername(It.Is<string>(x => string.Equals(IndividualAccount.EmailAddress, x, System.StringComparison.OrdinalIgnoreCase)), false)) | ||
.Returns(IndividualAccount); | ||
|
||
GetMock<IUserService>() | ||
.Setup(u => u.FindByUsername(OrganizationAccount.Username, true)) | ||
.Setup(u => u.FindByUsername(It.Is<string>(x => string.Equals(OrganizationAccount.Username, x, System.StringComparison.OrdinalIgnoreCase)), true)) | ||
.Returns(OrganizationAccount); | ||
GetMock<IUserService>() | ||
.Setup(u => u.FindByUsername(OrganizationAccount.EmailAddress, true)) | ||
.Setup(u => u.FindByUsername(It.Is<string>(x => string.Equals(OrganizationAccount.EmailAddress, x, System.StringComparison.OrdinalIgnoreCase)), true)) | ||
.Returns(OrganizationAccount); | ||
GetMock<IUserService>() | ||
.Setup(u => u.FindByUsername(AvailableUsername, true)) | ||
.Setup(u => u.FindByUsername(It.Is<string>(x => string.Equals(AvailableUsername, x, System.StringComparison.OrdinalIgnoreCase)), true)) | ||
.ReturnsNull(); | ||
|
||
GetMock<IPackageService>().Setup(p => p.FindPackagesByOwner(It.IsAny<User>(), It.IsAny<bool>(), It.IsAny<bool>())) | ||
.Returns(new List<Package>()); | ||
GetMock<IPackageService>().Setup(p => p.FindPackagesByOwner(It.Is<User>(u => u.Username == "testUser"), It.IsAny<bool>(), It.IsAny<bool>())) | ||
.Returns(new List<Package>() { new Package() { PackageRegistration = new PackageRegistration() { Id = "testPackage" } }}); | ||
|
||
ChangeUsernameController = new ChangeUsernameController( | ||
GetMock<IUserService>().Object, | ||
GetMock<IEntityRepository<User>>().Object, | ||
GetMock<IEntitiesContext>().Object, | ||
GetMock<IDateTimeProvider>().Object, | ||
GetMock<IAuditingService>().Object); | ||
GetMock<IAuditingService>().Object, | ||
GetMock<IPackageService>().Object); | ||
|
||
TestUtility.SetupHttpContextMockForUrlGeneration(new Mock<HttpContextBase>(), ChangeUsernameController); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -31,7 +31,7 @@ public Fakes() | |
|
||
ApiKeyV3PlaintextValue = "889e180e-335c-491a-ac26-e83c4bd31d87"; | ||
|
||
User = new User("testUser") | ||
User = new User("testUser") // NOTE: Do not change the casing of this username. It will break tests for the ChangeUsername in the Admin Panel | ||
{ | ||
Key = key++, | ||
EmailAddress = "[email protected]", | ||
|
@@ -58,7 +58,7 @@ public Fakes() | |
|
||
ApiKeyV4PlaintextValue = apiKeyV4PlaintextValue; | ||
|
||
Organization = new Organization("testOrganization") | ||
Organization = new Organization("testOrganization") // NOTE: Do not change the casing of this username. It will break tests for the ChangeUsername in the Admin Panel | ||
{ | ||
Key = key++, | ||
EmailAddress = "[email protected]", | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see where we're passing
true
forcheckOwnedPackages
other than unit test. How we're passing value here?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RiadGahlouz
Have you looked into my question? After this I can approve this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's coming from a form input:
NuGetGallery/src/NuGetGallery/Areas/Admin/Views/ChangeUsername/Index.cshtml
Line 62 in 44b0ccf