diff options
-rw-r--r-- | Timeline.Tests/AuthorizationUnitTest.cs | 11 | ||||
-rw-r--r-- | Timeline.Tests/Helpers/MyWebApplicationFactory.cs | 81 | ||||
-rw-r--r-- | Timeline.Tests/TokenUnitTest.cs | 83 | ||||
-rw-r--r-- | Timeline.Tests/UserUnitTest.cs | 268 | ||||
-rw-r--r-- | Timeline.Tests/UsernameValidatorUnitTest.cs | 25 | ||||
-rw-r--r-- | Timeline/Controllers/UserController.cs | 30 | ||||
-rw-r--r-- | Timeline/Models/Http/User.cs | 10 | ||||
-rw-r--r-- | Timeline/Models/Validation/UsernameValidator.cs | 45 | ||||
-rw-r--r-- | Timeline/Models/Validation/Validator.cs | 107 | ||||
-rw-r--r-- | Timeline/Services/UserService.cs | 97 |
10 files changed, 529 insertions, 228 deletions
diff --git a/Timeline.Tests/AuthorizationUnitTest.cs b/Timeline.Tests/AuthorizationUnitTest.cs index 6f52a12d..4751e95f 100644 --- a/Timeline.Tests/AuthorizationUnitTest.cs +++ b/Timeline.Tests/AuthorizationUnitTest.cs @@ -1,5 +1,6 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
+using System;
using System.Net;
using System.Threading.Tasks;
using Timeline.Tests.Helpers;
@@ -9,17 +10,23 @@ using Xunit.Abstractions; namespace Timeline.Tests
{
- public class AuthorizationUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>
+ public class AuthorizationUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>, IDisposable
{
private const string AuthorizeUrl = "Test/User/Authorize";
private const string UserUrl = "Test/User/User";
private const string AdminUrl = "Test/User/Admin";
private readonly WebApplicationFactory<Startup> _factory;
+ private readonly Action _disposeAction;
public AuthorizationUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
{
- _factory = factory.WithTestLogging(outputHelper);
+ _factory = factory.WithTestConfig(outputHelper, out _disposeAction);
+ }
+
+ public void Dispose()
+ {
+ _disposeAction();
}
[Fact]
diff --git a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs index b49756e4..dfadd1ae 100644 --- a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs +++ b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using System;
using Timeline.Entities;
using Timeline.Services;
using Timeline.Tests.Mock.Data;
@@ -15,69 +16,57 @@ namespace Timeline.Tests.Helpers {
public class MyWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
- // We should keep the connection, so the database is persisted but not recreate every time.
- // See https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/sqlite#writing-tests .
- private readonly SqliteConnection _databaseConnection;
-
- public MyWebApplicationFactory() : base()
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
- _databaseConnection = new SqliteConnection("Data Source=:memory:;");
- _databaseConnection.Open();
-
- InitDatabase();
+ builder.ConfigureTestServices(services =>
+ {
+ services.AddSingleton<IClock, TestClock>();
+ });
}
+ }
- private void InitDatabase()
+ public static class WebApplicationFactoryExtensions
+ {
+ public static WebApplicationFactory<TEntry> WithTestConfig<TEntry>(this WebApplicationFactory<TEntry> factory, ITestOutputHelper outputHelper, out Action disposeAction) where TEntry : class
{
- var options = new DbContextOptionsBuilder<DatabaseContext>()
- .UseSqlite(_databaseConnection)
- .Options;
+ // We should keep the connection, so the database is persisted but not recreate every time.
+ // See https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/sqlite#writing-tests .
+ SqliteConnection _databaseConnection = new SqliteConnection("Data Source=:memory:;");
+ _databaseConnection.Open();
- using (var context = new DatabaseContext(options))
{
- context.Database.EnsureCreated();
- context.Users.AddRange(MockUsers.Users);
- context.SaveChanges();
- }
- }
+ var options = new DbContextOptionsBuilder<DatabaseContext>()
+ .UseSqlite(_databaseConnection)
+ .Options;
- protected override void ConfigureWebHost(IWebHostBuilder builder)
- {
- builder.ConfigureServices(services =>
- {
- services.AddEntityFrameworkSqlite();
- services.AddDbContext<DatabaseContext>(options =>
+ using (var context = new DatabaseContext(options))
{
- options.UseSqlite(_databaseConnection);
- });
- })
- .ConfigureTestServices(services =>
- {
- services.AddSingleton<IClock, TestClock>();
- });
- }
+ context.Database.EnsureCreated();
+ context.Users.AddRange(MockUsers.Users);
+ context.SaveChanges();
+ };
+ }
- protected override void Dispose(bool disposing)
- {
- if (disposing)
+ disposeAction = () =>
{
_databaseConnection.Close();
_databaseConnection.Dispose();
- }
-
- base.Dispose(disposing);
- }
- }
+ };
- public static class WebApplicationFactoryExtensions
- {
- public static WebApplicationFactory<TEntry> WithTestLogging<TEntry>(this WebApplicationFactory<TEntry> factory, ITestOutputHelper outputHelper) where TEntry : class
- {
return factory.WithWebHostBuilder(builder =>
{
- builder.ConfigureLogging(logging =>
+ builder
+ .ConfigureLogging(logging =>
{
logging.AddXunit(outputHelper);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddEntityFrameworkSqlite();
+ services.AddDbContext<DatabaseContext>(options =>
+ {
+ options.UseSqlite(_databaseConnection);
+ });
});
});
}
diff --git a/Timeline.Tests/TokenUnitTest.cs b/Timeline.Tests/TokenUnitTest.cs index b5d8a2c8..3babacf7 100644 --- a/Timeline.Tests/TokenUnitTest.cs +++ b/Timeline.Tests/TokenUnitTest.cs @@ -15,43 +15,36 @@ using Xunit.Abstractions; namespace Timeline.Tests
{
- public class TokenUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>
+ public class TokenUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>, IDisposable
{
private const string CreateTokenUrl = "token/create";
private const string VerifyTokenUrl = "token/verify";
private readonly WebApplicationFactory<Startup> _factory;
+ private readonly Action _disposeAction;
public TokenUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
{
- _factory = factory.WithTestLogging(outputHelper);
+ _factory = factory.WithTestConfig(outputHelper, out _disposeAction);
}
- [Fact]
- public async void CreateToken_MissingUsername()
+ public void Dispose()
{
- using (var client = _factory.CreateDefaultClient())
- {
- await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl,
- new CreateTokenRequest { Username = null, Password = "user" });
- }
+ _disposeAction();
}
[Fact]
- public async void CreateToken_InvalidModel_MissingPassword()
+ public async void CreateToken_InvalidModel()
{
using (var client = _factory.CreateDefaultClient())
{
+ // missing username
+ await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl,
+ new CreateTokenRequest { Username = null, Password = "user" });
+ // missing password
await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl,
new CreateTokenRequest { Username = "user", Password = null });
- }
- }
-
- [Fact]
- public async void CreateToken_InvalidModel_BadExpireOffset()
- {
- using (var client = _factory.CreateDefaultClient())
- {
+ // bad expire offset
await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl,
new CreateTokenRequest
{
@@ -101,10 +94,11 @@ namespace Timeline.Tests }
[Fact]
- public async void VerifyToken_InvalidModel_MissingToken()
+ public async void VerifyToken_InvalidModel()
{
using (var client = _factory.CreateDefaultClient())
{
+ // missing token
await InvalidModelTestHelpers.TestPostInvalidModel(client, VerifyTokenUrl,
new VerifyTokenRequest { Token = null });
}
@@ -122,43 +116,42 @@ namespace Timeline.Tests }
[Fact]
- public async void VerifyToken_BadVersion_AND_UserNotExist()
+ public async void VerifyToken_BadVersion()
{
using (var client = _factory.CreateDefaultClient())
{
+ var token = (await client.CreateUserTokenAsync(MockUsers.UserUsername, MockUsers.UserPassword)).Token;
+
using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped.
{
// create a user for test
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
+ await userService.PatchUser(MockUsers.UserUsername, null, null);
+ }
- const string username = "verifytokentest0";
- const string password = "12345678";
-
- await userService.PutUser(username, password, false);
-
- // create a token
- var token = (await client.CreateUserTokenAsync(username, password)).Token;
-
- // increase version
- await userService.PatchUser(username, null, null);
-
- // test against bad version
- var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token });
- response.Should().HaveStatusCodeBadRequest()
- .And.Should().HaveBodyAsCommonResponseWithCode(TokenController.ErrorCodes.Verify_BadVersion);
-
-
- // create another token
- var token2 = (await client.CreateUserTokenAsync(username, password)).Token;
+ var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token });
+ response.Should().HaveStatusCodeBadRequest()
+ .And.Should().HaveBodyAsCommonResponseWithCode(TokenController.ErrorCodes.Verify_BadVersion);
+ }
+ }
- // delete user
- await userService.DeleteUser(username);
+ [Fact]
+ public async void VerifyToken_UserNotExist()
+ {
+ using (var client = _factory.CreateDefaultClient())
+ {
+ var token = (await client.CreateUserTokenAsync(MockUsers.UserUsername, MockUsers.UserPassword)).Token;
- // test against user not exist
- var response2 = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token });
- response2.Should().HaveStatusCodeBadRequest()
- .And.Should().HaveBodyAsCommonResponseWithCode(TokenController.ErrorCodes.Verify_UserNotExist);
+ using (var scope = _factory.Server.Host.Services.CreateScope()) // UserService is scoped.
+ {
+ // create a user for test
+ var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
+ await userService.DeleteUser(MockUsers.UserUsername);
}
+
+ var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token });
+ response.Should().HaveStatusCodeBadRequest()
+ .And.Should().HaveBodyAsCommonResponseWithCode(TokenController.ErrorCodes.Verify_UserNotExist);
}
}
diff --git a/Timeline.Tests/UserUnitTest.cs b/Timeline.Tests/UserUnitTest.cs index 2aa89fe3..77ec37ee 100644 --- a/Timeline.Tests/UserUnitTest.cs +++ b/Timeline.Tests/UserUnitTest.cs @@ -1,5 +1,6 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
+using System;
using System.Net.Http;
using System.Threading.Tasks;
using Timeline.Controllers;
@@ -13,13 +14,19 @@ using Xunit.Abstractions; namespace Timeline.Tests
{
- public class UserUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>
+ public class UserUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>, IDisposable
{
private readonly WebApplicationFactory<Startup> _factory;
+ private readonly Action _disposeAction;
public UserUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
{
- _factory = factory.WithTestLogging(outputHelper);
+ _factory = factory.WithTestConfig(outputHelper, out _disposeAction);
+ }
+
+ public void Dispose()
+ {
+ _disposeAction();
}
[Fact]
@@ -28,9 +35,8 @@ namespace Timeline.Tests using (var client = await _factory.CreateClientAsAdmin())
{
var res = await client.GetAsync("users");
- // Because tests are running asyncronized. So database may be modified and
- // we can't check the exact user lists at this point. So only check the format.
- res.Should().HaveStatusCodeOk().And.Should().HaveBodyAsJson<UserInfo[]>();
+ res.Should().HaveStatusCodeOk().And.Should().HaveBodyAsJson<UserInfo[]>()
+ .Which.Should().BeEquivalentTo(MockUsers.UserInfos);
}
}
@@ -58,118 +64,229 @@ namespace Timeline.Tests }
[Fact]
- public async Task Put_Patch_Delete_User()
+ public async Task Put_InvalidModel()
{
using (var client = await _factory.CreateClientAsAdmin())
{
- const string username = "putpatchdeleteuser";
- const string password = "password";
- const string url = "users/" + username;
-
- // Put Invalid Model
+ const string url = "users/aaaaaaaa";
+ // missing password
await InvalidModelTestHelpers.TestPutInvalidModel(client, url, new UserPutRequest { Password = null, Administrator = false });
- await InvalidModelTestHelpers.TestPutInvalidModel(client, url, new UserPutRequest { Password = password, Administrator = null });
+ // missing administrator
+ await InvalidModelTestHelpers.TestPutInvalidModel(client, url, new UserPutRequest { Password = "???", Administrator = null });
+ }
+ }
- async Task CheckAdministrator(bool administrator)
+ [Fact]
+ public async Task Put_BadUsername()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ var res = await client.PutAsJsonAsync("users/dsf fddf", new UserPutRequest
{
- var res = await client.GetAsync(url);
- res.Should().HaveStatusCodeOk()
- .And.Should().HaveBodyAsJson<UserInfo>()
- .Which.Administrator.Should().Be(administrator);
- }
+ Password = "???",
+ Administrator = false
+ });
+ res.Should().HaveStatusCodeBadRequest()
+ .And.Should().HaveBodyAsCommonResponseWithCode(UserController.ErrorCodes.Put_BadUsername);
+ }
+ }
- {
- // Put Bad Username.
- var res = await client.PutAsJsonAsync("users/dsf fddf", new UserPutRequest
- {
- Password = password,
- Administrator = false
- });
- res.Should().HaveStatusCodeBadRequest()
- .And.Should().HaveBodyAsCommonResponseWithCode(UserController.ErrorCodes.Put_BadUsername);
- }
+ private async Task CheckAdministrator(HttpClient client, string username, bool administrator)
+ {
+ var res = await client.GetAsync("users/" + username);
+ res.Should().HaveStatusCodeOk()
+ .And.Should().HaveBodyAsJson<UserInfo>()
+ .Which.Administrator.Should().Be(administrator);
+ }
+ [Fact]
+ public async Task Put_Modiefied()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ var res = await client.PutAsJsonAsync("users/" + MockUsers.UserUsername, new UserPutRequest
{
- // Put Created.
- var res = await client.PutAsJsonAsync(url, new UserPutRequest
- {
- Password = password,
- Administrator = false
- });
- res.Should().BePutCreated();
- await CheckAdministrator(false);
- }
+ Password = "password",
+ Administrator = false
+ });
+ res.Should().BePutModified();
+ await CheckAdministrator(client, MockUsers.UserUsername, false);
+ }
+ }
- {
- // Put Modified.
- var res = await client.PutAsJsonAsync(url, new UserPutRequest
- {
- Password = password,
- Administrator = true
- });
- res.Should().BePutModified();
- await CheckAdministrator(true);
- }
+ [Fact]
+ public async Task Put_Created()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ const string username = "puttest";
+ const string url = "users/" + username;
- // Patch Not Exist
+ var res = await client.PutAsJsonAsync(url, new UserPutRequest
{
- var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { });
- res.Should().HaveStatusCodeNotFound()
- .And.Should().HaveBodyAsCommonResponseWithCode(UserController.ErrorCodes.Patch_NotExist);
- }
+ Password = "password",
+ Administrator = false
+ });
+ res.Should().BePutCreated();
+ await CheckAdministrator(client, username, false);
+ }
+ }
+
+ [Fact]
+ public async Task Patch_NotExist()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { });
+ res.Should().HaveStatusCodeNotFound()
+ .And.Should().HaveBodyAsCommonResponseWithCode(UserController.ErrorCodes.Patch_NotExist);
+ }
+ }
- // Patch Success
+ [Fact]
+ public async Task Patch_Success()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
{
- var res = await client.PatchAsJsonAsync(url, new UserPatchRequest { Administrator = false });
+ var res = await client.PatchAsJsonAsync("users/" + MockUsers.UserUsername,
+ new UserPatchRequest { Administrator = false });
res.Should().HaveStatusCodeOk();
- await CheckAdministrator(false);
+ await CheckAdministrator(client, MockUsers.UserUsername, false);
}
+ }
+ }
- // Delete Deleted
+ [Fact]
+ public async Task Delete_Deleted()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
{
+ var url = "users/" + MockUsers.UserUsername;
var res = await client.DeleteAsync(url);
res.Should().BeDeleteDeleted();
var res2 = await client.GetAsync(url);
res2.Should().HaveStatusCodeNotFound();
}
+ }
+ }
- // Delete Not Exist
+ [Fact]
+ public async Task Delete_NotExist()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
{
- var res = await client.DeleteAsync(url);
+ var res = await client.DeleteAsync("users/usernotexist");
res.Should().BeDeleteNotExist();
}
}
}
- public class ChangePasswordUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>
+ public class ChangeUsernameUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>, IDisposable
{
- private const string url = "userop/changepassword";
+ private const string url = "userop/changeusername";
private readonly WebApplicationFactory<Startup> _factory;
+ private readonly Action _disposeAction;
- public ChangePasswordUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
+ public ChangeUsernameUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
{
- _factory = factory.WithTestLogging(outputHelper);
+ _factory = factory.WithTestConfig(outputHelper, out _disposeAction);
}
+ public void Dispose()
+ {
+ _disposeAction();
+ }
[Fact]
- public async Task InvalidModel_OldPassword()
+ public async Task InvalidModel()
{
- using (var client = await _factory.CreateClientAsUser())
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ // missing old username
+ await InvalidModelTestHelpers.TestPostInvalidModel(client, url,
+ new ChangeUsernameRequest { OldUsername= null, NewUsername= "hhh" });
+ // missing new username
+ await InvalidModelTestHelpers.TestPostInvalidModel(client, url,
+ new ChangeUsernameRequest { OldUsername= "hhh", NewUsername= null });
+ // bad username
+ await InvalidModelTestHelpers.TestPostInvalidModel(client, url,
+ new ChangeUsernameRequest { OldUsername = "hhh", NewUsername = "???" });
+ }
+ }
+
+ [Fact]
+ public async Task UserNotExist()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ var res = await client.PostAsJsonAsync(url,
+ new ChangeUsernameRequest{ OldUsername= "usernotexist", NewUsername= "newUsername" });
+ res.Should().HaveStatusCodeBadRequest()
+ .And.Should().HaveBodyAsCommonResponseWithCode(UserController.ErrorCodes.ChangeUsername_NotExist);
+ }
+ }
+
+ [Fact]
+ public async Task UserAlreadyExist()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
+ {
+ var res = await client.PostAsJsonAsync(url,
+ new ChangeUsernameRequest { OldUsername = MockUsers.UserUsername, NewUsername = MockUsers.AdminUsername });
+ res.Should().HaveStatusCodeBadRequest()
+ .And.Should().HaveBodyAsCommonResponseWithCode(UserController.ErrorCodes.ChangeUsername_AlreadyExist);
+ }
+ }
+
+ [Fact]
+ public async Task Success()
+ {
+ using (var client = await _factory.CreateClientAsAdmin())
{
- await InvalidModelTestHelpers.TestPostInvalidModel(client, url, new ChangePasswordRequest { OldPassword = null, NewPassword = "???" });
+ const string newUsername = "hahaha";
+ var res = await client.PostAsJsonAsync(url,
+ new ChangeUsernameRequest { OldUsername = MockUsers.UserUsername, NewUsername = newUsername });
+ res.Should().HaveStatusCodeOk();
+ await client.CreateUserTokenAsync(newUsername, MockUsers.UserPassword);
}
}
+ }
+
+
+ public class ChangePasswordUnitTest : IClassFixture<MyWebApplicationFactory<Startup>>, IDisposable
+ {
+ private const string url = "userop/changepassword";
+
+ private readonly WebApplicationFactory<Startup> _factory;
+ private readonly Action _disposeAction;
+
+ public ChangePasswordUnitTest(MyWebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper)
+ {
+ _factory = factory.WithTestConfig(outputHelper, out _disposeAction);
+ }
+
+ public void Dispose()
+ {
+ _disposeAction();
+ }
[Fact]
- public async Task InvalidModel_NewPassword()
+ public async Task InvalidModel()
{
using (var client = await _factory.CreateClientAsUser())
{
- await InvalidModelTestHelpers.TestPostInvalidModel(client, url, new ChangePasswordRequest { OldPassword = "???", NewPassword = null });
+ // missing old password
+ await InvalidModelTestHelpers.TestPostInvalidModel(client, url,
+ new ChangePasswordRequest { OldPassword = null, NewPassword = "???" });
+ // missing new password
+ await InvalidModelTestHelpers.TestPostInvalidModel(client, url,
+ new ChangePasswordRequest { OldPassword = "???", NewPassword = null });
}
}
@@ -187,22 +304,13 @@ namespace Timeline.Tests [Fact]
public async Task Success()
{
- const string username = "changepasswordtest";
- const string password = "password";
-
- // create a new user to avoid interference
- using (var client = await _factory.CreateClientAsAdmin())
- {
- var res = await client.PutAsJsonAsync("users/" + username, new UserPutRequest { Password = password, Administrator = false });
- res.Should().BePutCreated();
- }
-
- using (var client = await _factory.CreateClientWithCredential(username, password))
+ using (var client = await _factory.CreateClientAsUser())
{
const string newPassword = "new";
- var res = await client.PostAsJsonAsync(url, new ChangePasswordRequest { OldPassword = password, NewPassword = newPassword });
+ var res = await client.PostAsJsonAsync(url,
+ new ChangePasswordRequest { OldPassword = MockUsers.UserPassword, NewPassword = newPassword });
res.Should().HaveStatusCodeOk();
- await client.CreateUserTokenAsync(username, newPassword);
+ await client.CreateUserTokenAsync(MockUsers.UserUsername, newPassword);
}
}
}
diff --git a/Timeline.Tests/UsernameValidatorUnitTest.cs b/Timeline.Tests/UsernameValidatorUnitTest.cs index 97d56195..20558d0e 100644 --- a/Timeline.Tests/UsernameValidatorUnitTest.cs +++ b/Timeline.Tests/UsernameValidatorUnitTest.cs @@ -1,6 +1,6 @@ using FluentAssertions;
using System;
-using Timeline.Services;
+using Timeline.Models.Validation;
using Xunit;
namespace Timeline.Tests
@@ -14,13 +14,6 @@ namespace Timeline.Tests _validator = validator;
}
- [Fact]
- public void NullShouldThrow()
- {
- _validator.Invoking(v => v.Validate(null, out string message)).Should().Throw<ArgumentNullException>();
- }
-
-
private string FailAndMessage(string username)
{
var result = _validator.Validate(username, out var message);
@@ -31,7 +24,21 @@ namespace Timeline.Tests private void Succeed(string username)
{
_validator.Validate(username, out var message).Should().BeTrue();
- message.Should().BeNull();
+ message.Should().Be(ValidationConstants.SuccessMessage);
+ }
+
+ [Fact]
+ public void Null()
+ {
+ FailAndMessage(null).Should().ContainEquivalentOf("null");
+ }
+
+ [Fact]
+ public void NotString()
+ {
+ var result = _validator.Validate(123, out var message);
+ result.Should().BeFalse();
+ message.Should().ContainEquivalentOf("type");
}
[Fact]
diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index d38f96e1..bd13f0a3 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -22,7 +22,10 @@ namespace Timeline.Controllers public const int Patch_NotExist = -3001;
- public const int ChangePassword_BadOldPassword = -4001;
+ public const int ChangeUsername_NotExist = -4001;
+ public const int ChangeUsername_AlreadyExist = -4002;
+
+ public const int ChangePassword_BadOldPassword = -5001;
}
private readonly ILogger<UserController> _logger;
@@ -108,6 +111,31 @@ namespace Timeline.Controllers }
}
+ [HttpPost("userop/changeusername"), AdminAuthorize]
+ public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest request)
+ {
+ try
+ {
+ await _userService.ChangeUsername(request.OldUsername, request.NewUsername);
+ _logger.LogInformation(FormatLogMessage("A user changed username.",
+ Pair("Old Username", request.OldUsername), Pair("New Username", request.NewUsername)));
+ return Ok();
+ }
+ catch (UserNotExistException e)
+ {
+ _logger.LogInformation(e, FormatLogMessage("Attempt to change a non-existent user's username failed.",
+ Pair("Old Username", request.OldUsername), Pair("New Username", request.NewUsername)));
+ return BadRequest(new CommonResponse(ErrorCodes.ChangeUsername_NotExist, $"The user {request.OldUsername} does not exist."));
+ }
+ catch (UserAlreadyExistException e)
+ {
+ _logger.LogInformation(e, FormatLogMessage("Attempt to change a user's username to a existent one failed.",
+ Pair("Old Username", request.OldUsername), Pair("New Username", request.NewUsername)));
+ return BadRequest(new CommonResponse(ErrorCodes.ChangeUsername_AlreadyExist, $"The user {request.NewUsername} already exists."));
+ }
+ // there is no need to catch bad format exception because it is already checked in model validation.
+ }
+
[HttpPost("userop/changepassword"), Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
diff --git a/Timeline/Models/Http/User.cs b/Timeline/Models/Http/User.cs index d45543fb..4308a19c 100644 --- a/Timeline/Models/Http/User.cs +++ b/Timeline/Models/Http/User.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations;
+using Timeline.Models.Validation;
namespace Timeline.Models.Http
{
@@ -16,6 +17,15 @@ namespace Timeline.Models.Http public bool? Administrator { get; set; }
}
+ public class ChangeUsernameRequest
+ {
+ [Required]
+ public string OldUsername { get; set; }
+
+ [Required, ValidateWith(typeof(UsernameValidator))]
+ public string NewUsername { get; set; }
+ }
+
public class ChangePasswordRequest
{
[Required]
diff --git a/Timeline/Models/Validation/UsernameValidator.cs b/Timeline/Models/Validation/UsernameValidator.cs new file mode 100644 index 00000000..e4891400 --- /dev/null +++ b/Timeline/Models/Validation/UsernameValidator.cs @@ -0,0 +1,45 @@ +using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace Timeline.Models.Validation
+{
+ public class UsernameValidator : Validator<string>
+ {
+ public const int MaxLength = 26;
+ public const string RegexPattern = @"^[a-zA-Z0-9_][a-zA-Z0-9-_]*$";
+
+ private readonly Regex _regex = new Regex(RegexPattern);
+
+ protected override bool DoValidate(string value, out string message)
+ {
+ if (value.Length == 0)
+ {
+ message = "An empty string is not permitted.";
+ return false;
+ }
+
+ if (value.Length > 26)
+ {
+ message = $"Too long, more than 26 characters is not premitted, found {value.Length}.";
+ return false;
+ }
+
+ foreach ((char c, int i) in value.Select((c, i) => (c, i)))
+ if (char.IsWhiteSpace(c))
+ {
+ message = $"A whitespace is found at {i} . Whitespace is not permited.";
+ return false;
+ }
+
+ var match = _regex.Match(value);
+ if (!match.Success)
+ {
+ message = "Regex match failed.";
+ return false;
+ }
+
+ message = ValidationConstants.SuccessMessage;
+ return true;
+ }
+ }
+}
diff --git a/Timeline/Models/Validation/Validator.cs b/Timeline/Models/Validation/Validator.cs new file mode 100644 index 00000000..a1acbed9 --- /dev/null +++ b/Timeline/Models/Validation/Validator.cs @@ -0,0 +1,107 @@ +using System;
+using System.ComponentModel.DataAnnotations;
+
+namespace Timeline.Models.Validation
+{
+ /// <summary>
+ /// A validator to validate value.
+ /// See <see cref="Validate(object, out string)"/>.
+ /// </summary>
+ public interface IValidator
+ {
+ /// <summary>
+ /// Validate given value.
+ /// </summary>
+ /// <param name="value">The value to validate.</param>
+ /// <param name="message">The validation message.</param>
+ /// <returns>True if validation passed. Otherwise false.</returns>
+ bool Validate(object value, out string message);
+ }
+
+ public static class ValidationConstants
+ {
+ public const string SuccessMessage = "Validation succeeded.";
+ }
+
+ /// <summary>
+ /// Convenient base class for validator.
+ /// </summary>
+ /// <typeparam name="T">The type of accepted value.</typeparam>
+ /// <remarks>
+ /// Subclass should override <see cref="DoValidate(T, out string)"/> to do the real validation.
+ /// This class will check the nullity and type of value. If value is null or not of type <typeparamref name="T"/>
+ /// it will return false and not call <see cref="DoValidate(T, out string)"/>.
+ ///
+ /// If you want some other behaviours, write the validator from scratch.
+ /// </remarks>
+ public abstract class Validator<T> : IValidator
+ {
+ public bool Validate(object value, out string message)
+ {
+ if (value == null)
+ {
+ message = "Value is null.";
+ return false;
+ }
+
+ if (value is T v)
+ {
+
+ return DoValidate(v, out message);
+ }
+ else
+ {
+ message = $"Value is not of type {typeof(T).Name}";
+ return false;
+ }
+ }
+
+ protected abstract bool DoValidate(T value, out string message);
+ }
+
+ [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter,
+ AllowMultiple = false)]
+ public class ValidateWithAttribute : ValidationAttribute
+ {
+ private readonly IValidator _validator;
+
+ /// <summary>
+ /// Create with a given validator.
+ /// </summary>
+ /// <param name="validator">The validator used to validate.</param>
+ public ValidateWithAttribute(IValidator validator)
+ {
+ _validator = validator ?? throw new ArgumentNullException(nameof(validator));
+ }
+
+ /// <summary>
+ /// Create the validator with default constructor.
+ /// </summary>
+ /// <param name="validatorType">The type of the validator.</param>
+ public ValidateWithAttribute(Type validatorType)
+ {
+ if (validatorType == null)
+ throw new ArgumentNullException(nameof(validatorType));
+
+ if (!typeof(IValidator).IsAssignableFrom(validatorType))
+ throw new ArgumentException("Given type is not assignable to IValidator.", nameof(validatorType));
+
+ try
+ {
+ _validator = Activator.CreateInstance(validatorType) as IValidator;
+ }
+ catch (Exception e)
+ {
+ throw new ArgumentException("Failed to create a validator instance from default constructor. See inner exception.", e);
+ }
+ }
+
+ protected override ValidationResult IsValid(object value, ValidationContext validationContext)
+ {
+ if (_validator.Validate(value, out var message))
+ return ValidationResult.Success;
+ else
+ return new ValidationResult(string.Format("Field {0} is bad. {1}", validationContext.DisplayName, message));
+ }
+ }
+}
diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 0993d3dc..96c3e256 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -3,10 +3,10 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging;
using System;
using System.Linq;
-using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Timeline.Entities;
using Timeline.Models;
+using Timeline.Models.Validation;
using static Timeline.Helpers.MyLogHelper;
using static Timeline.Models.UserUtility;
@@ -121,54 +121,24 @@ namespace Timeline.Services public string Username { get; private set; }
}
- public class UsernameValidator
- {
- public const int MaxLength = 26;
- public const string RegexPattern = @"^[a-zA-Z0-9_][a-zA-Z0-9-_]*$";
- private readonly Regex _regex = new Regex(RegexPattern);
+ /// <summary>
+ /// Thrown when the user already exists.
+ /// </summary>
+ [Serializable]
+ public class UserAlreadyExistException : Exception
+ {
+ public UserAlreadyExistException(string username) : base($"User {username} already exists.") { Username = username; }
+ public UserAlreadyExistException(string username, string message) : base(message) { Username = username; }
+ public UserAlreadyExistException(string message, Exception inner) : base(message, inner) { }
+ protected UserAlreadyExistException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
/// <summary>
- /// Validate a username.
+ /// The username that already exists.
/// </summary>
- /// <param name="username">The username. Can't be null.</param>
- /// <param name="message">Set as error message if there is error. Or null if no error.</param>
- /// <returns>True if validation passed. Otherwise false.</returns>
- /// <exception cref="ArgumentNullException">Thrown when <paramref name="username"/> is null.</exception>
- public bool Validate(string username, out string message)
- {
- if (username == null)
- throw new ArgumentNullException(nameof(username));
-
- if (username.Length == 0)
- {
- message = "An empty string is not permitted.";
- return false;
- }
-
- if (username.Length > 26)
- {
- message = $"Too long, more than 26 characters is not premitted, found {username.Length}.";
- return false;
- }
-
- foreach ((char c, int i) in username.Select((c, i) => (c, i)))
- if (char.IsWhiteSpace(c))
- {
- message = $"A whitespace is found at {i} . Whitespace is not permited.";
- return false;
- }
-
- var match = _regex.Match(username);
- if (!match.Success)
- {
- message = "Regex match failed.";
- return false;
- }
-
- message = null;
- return true;
- }
+ public string Username { get; set; }
}
public interface IUserService
@@ -254,6 +224,17 @@ namespace Timeline.Services /// <exception cref="UserNotExistException">Thrown if the user with given username does not exist.</exception>
/// <exception cref="BadPasswordException">Thrown if the old password is wrong.</exception>
Task ChangePassword(string username, string oldPassword, string newPassword);
+
+ /// <summary>
+ /// Change a user's username.
+ /// </summary>
+ /// <param name="oldUsername">The user's old username.</param>
+ /// <param name="newUsername">The new username.</param>
+ /// <exception cref="ArgumentException">Thrown if <paramref name="oldUsername"/> or <paramref name="newUsername"/> is null or empty.</exception>
+ /// <exception cref="UserNotExistException">Thrown if the user with old username does not exist.</exception>
+ /// <exception cref="UsernameBadFormatException">Thrown if the new username is not accepted because of bad format.</exception>
+ /// <exception cref="UserAlreadyExistException">Thrown if user with the new username already exists.</exception>
+ Task ChangeUsername(string oldUsername, string newUsername);
}
internal class UserCache
@@ -482,5 +463,31 @@ namespace Timeline.Services //clear cache
RemoveCache(user.Id);
}
+
+ public async Task ChangeUsername(string oldUsername, string newUsername)
+ {
+ if (string.IsNullOrEmpty(oldUsername))
+ throw new ArgumentException("Old username is null or empty", nameof(oldUsername));
+ if (string.IsNullOrEmpty(newUsername))
+ throw new ArgumentException("New username is null or empty", nameof(newUsername));
+
+ if (!_usernameValidator.Validate(newUsername, out var message))
+ throw new UsernameBadFormatException(newUsername, $"New username is of bad format. {message}");
+
+ var user = await _databaseContext.Users.Where(u => u.Name == oldUsername).SingleOrDefaultAsync();
+ if (user == null)
+ throw new UserNotExistException(oldUsername);
+
+ var conflictUser = await _databaseContext.Users.Where(u => u.Name == newUsername).SingleOrDefaultAsync();
+ if (conflictUser != null)
+ throw new UserAlreadyExistException(newUsername);
+
+ user.Name = newUsername;
+ user.Version += 1;
+ await _databaseContext.SaveChangesAsync();
+ _logger.LogInformation(FormatLogMessage("A user entry changed name field.",
+ Pair("Id", user.Id), Pair("Old Username", oldUsername), Pair("New Username", newUsername)));
+ RemoveCache(user.Id);
+ }
}
}
|