From dc1ab11cea249f4ca967f86b115147a63f7c93a5 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 5 Aug 2019 18:58:56 +0800 Subject: 3 things. 1. Exchange Models and Entities namespace. 2. Fix the bug that input with missing field leads to 500. 3. Write unit tests. --- .../Authentication/AuthenticationExtensions.cs | 4 +- Timeline.Tests/Helpers/InvalidModelTestHelpers.cs | 19 +++++++ Timeline.Tests/Helpers/MyWebApplicationFactory.cs | 2 +- Timeline.Tests/Helpers/ResponseExtensions.cs | 2 +- Timeline.Tests/Helpers/UserInfoComparers.cs | 2 +- Timeline.Tests/TokenUnitTest.cs | 62 ++++++++++++++++------ Timeline.Tests/UserUnitTest.cs | 2 +- Timeline/Authenticate/Attribute.cs | 2 +- Timeline/Authenticate/AuthHandler.cs | 3 +- Timeline/Controllers/TokenController.cs | 18 ++----- Timeline/Controllers/UserController.cs | 5 +- Timeline/Controllers/UserTestController.cs | 1 + Timeline/Entities/DatabaseContext.cs | 42 +++++++++++++++ Timeline/Entities/Http/Common.cs | 37 ------------- Timeline/Entities/Http/Token.cs | 26 --------- Timeline/Entities/Http/User.cs | 20 ------- Timeline/Entities/PutResult.cs | 17 ------ Timeline/Entities/UserInfo.cs | 23 -------- Timeline/Entities/UserUtility.cs | 60 --------------------- Timeline/Helpers/InvalidModelResponseFactory.cs | 25 +++++++++ .../20190412102517_InitCreate.Designer.cs | 2 +- .../20190412144150_AddAdminUser.Designer.cs | 2 +- ...0412153003_MakeColumnsInUserNotNull.Designer.cs | 2 +- .../20190719115321_Add-User-Version.Designer.cs | 2 +- .../Migrations/DatabaseContextModelSnapshot.cs | 2 +- Timeline/Models/DatabaseContext.cs | 42 --------------- Timeline/Models/Http/Common.cs | 51 ++++++++++++++++++ Timeline/Models/Http/Token.cs | 32 +++++++++++ Timeline/Models/Http/User.cs | 26 +++++++++ Timeline/Models/PutResult.cs | 17 ++++++ Timeline/Models/UserInfo.cs | 23 ++++++++ Timeline/Models/UserUtility.cs | 60 +++++++++++++++++++++ Timeline/Services/UserService.cs | 2 +- Timeline/Startup.cs | 9 +++- 34 files changed, 369 insertions(+), 275 deletions(-) create mode 100644 Timeline.Tests/Helpers/InvalidModelTestHelpers.cs create mode 100644 Timeline/Entities/DatabaseContext.cs delete mode 100644 Timeline/Entities/Http/Common.cs delete mode 100644 Timeline/Entities/Http/Token.cs delete mode 100644 Timeline/Entities/Http/User.cs delete mode 100644 Timeline/Entities/PutResult.cs delete mode 100644 Timeline/Entities/UserInfo.cs delete mode 100644 Timeline/Entities/UserUtility.cs create mode 100644 Timeline/Helpers/InvalidModelResponseFactory.cs delete mode 100644 Timeline/Models/DatabaseContext.cs create mode 100644 Timeline/Models/Http/Common.cs create mode 100644 Timeline/Models/Http/Token.cs create mode 100644 Timeline/Models/Http/User.cs create mode 100644 Timeline/Models/PutResult.cs create mode 100644 Timeline/Models/UserInfo.cs create mode 100644 Timeline/Models/UserUtility.cs diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index 27362ac3..92617588 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -2,7 +2,7 @@ using Newtonsoft.Json; using System.Net.Http; using System.Threading.Tasks; -using Timeline.Entities.Http; +using Timeline.Models.Http; namespace Timeline.Tests.Helpers.Authentication { @@ -10,7 +10,7 @@ namespace Timeline.Tests.Helpers.Authentication { private const string CreateTokenUrl = "/token/create"; - public static async Task CreateUserTokenAsync(this HttpClient client, string username, string password, double? expireOffset = null) + public static async Task CreateUserTokenAsync(this HttpClient client, string username, string password, int? expireOffset = null) { var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password, ExpireOffset = expireOffset }); var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); diff --git a/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs b/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs new file mode 100644 index 00000000..d35982d4 --- /dev/null +++ b/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs @@ -0,0 +1,19 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Models.Http; +using Xunit; + +namespace Timeline.Tests.Helpers +{ + public static class InvalidModelTestHelpers + { + public static async Task TestPostInvalidModel(HttpClient client, string url, T body) + { + var response = await client.PostAsJsonAsync(url, body); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var responseBody = await response.ReadBodyAsJson(); + Assert.Equal(CommonResponse.ErrorCodes.InvalidModel, responseBody.Code); + } + } +} diff --git a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs index 903cd670..ef207fd2 100644 --- a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs +++ b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs @@ -5,7 +5,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Timeline.Models; +using Timeline.Entities; using Timeline.Services; using Xunit.Abstractions; diff --git a/Timeline.Tests/Helpers/ResponseExtensions.cs b/Timeline.Tests/Helpers/ResponseExtensions.cs index 86ac1c88..9ca583fc 100644 --- a/Timeline.Tests/Helpers/ResponseExtensions.cs +++ b/Timeline.Tests/Helpers/ResponseExtensions.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json; using System.Net.Http; using System.Threading.Tasks; - + namespace Timeline.Tests.Helpers { public static class ResponseExtensions diff --git a/Timeline.Tests/Helpers/UserInfoComparers.cs b/Timeline.Tests/Helpers/UserInfoComparers.cs index fcf37e5c..3068b6dd 100644 --- a/Timeline.Tests/Helpers/UserInfoComparers.cs +++ b/Timeline.Tests/Helpers/UserInfoComparers.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Timeline.Entities; +using Timeline.Models; namespace Timeline.Tests.Helpers { diff --git a/Timeline.Tests/TokenUnitTest.cs b/Timeline.Tests/TokenUnitTest.cs index 7b83cd13..5c7496b5 100644 --- a/Timeline.Tests/TokenUnitTest.cs +++ b/Timeline.Tests/TokenUnitTest.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Net; using System.Net.Http; using Timeline.Controllers; -using Timeline.Entities.Http; +using Timeline.Models.Http; using Timeline.Services; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; @@ -28,43 +28,61 @@ namespace Timeline.Tests } [Fact] - public async void CreateTokenTest_UserNotExist() + public async void CreateToken_MissingUsername() { using (var client = _factory.CreateDefaultClient()) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "usernotexist", Password = "???" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Create_UserNotExist, body.Code); + await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl, + new CreateTokenRequest { Username = null, Password = "user" }); } } [Fact] - public async void CreateTokenTest_BadPassword() + public async void CreateToken_InvalidModel_MissingPassword() { using (var client = _factory.CreateDefaultClient()) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "???" }); + await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl, + new CreateTokenRequest { Username = "user", Password = null }); + } + } + + [Fact] + public async void CreateToken_InvalidModel_BadExpireOffset() + { + using (var client = _factory.CreateDefaultClient()) + { + await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl, + new CreateTokenRequest { Username = "user", Password = "user", ExpireOffset = -1000 }); + } + } + + [Fact] + public async void CreateToken_UserNotExist() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "usernotexist", Password = "???" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Create_BadPassword, body.Code); + Assert.Equal(TokenController.ErrorCodes.Create_UserNotExist, body.Code); } } [Fact] - public async void CreateTokenTest_BadExpireOffset() + public async void CreateToken_BadPassword() { using (var client = _factory.CreateDefaultClient()) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "???", Password = "???", ExpireOffset = -1000 }); + var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "???" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.ReadBodyAsJson(); - Assert.Equal(TokenController.ErrorCodes.Create_BadExpireOffset, body.Code); + Assert.Equal(TokenController.ErrorCodes.Create_BadPassword, body.Code); } } [Fact] - public async void CreateTokenTest_Success() + public async void CreateToken_Success() { using (var client = _factory.CreateDefaultClient()) { @@ -77,7 +95,17 @@ namespace Timeline.Tests } [Fact] - public async void VerifyTokenTest_BadToken() + public async void VerifyToken_InvalidModel_MissingToken() + { + using (var client = _factory.CreateDefaultClient()) + { + await InvalidModelTestHelpers.TestPostInvalidModel(client, VerifyTokenUrl, + new VerifyTokenRequest { Token = null }); + } + } + + [Fact] + public async void VerifyToken_BadToken() { using (var client = _factory.CreateDefaultClient()) { @@ -89,7 +117,7 @@ namespace Timeline.Tests } [Fact] - public async void VerifyTokenTest_BadVersion_AND_UserNotExist() + public async void VerifyToken_BadVersion_AND_UserNotExist() { using (var client = _factory.CreateDefaultClient()) { @@ -131,7 +159,7 @@ namespace Timeline.Tests } [Fact] - public async void VerifyTokenTest_Expired() + public async void VerifyToken_Expired() { using (var client = _factory.CreateDefaultClient()) { @@ -148,7 +176,7 @@ namespace Timeline.Tests } [Fact] - public async void VerifyTokenTest_Success() + public async void VerifyToken_Success() { using (var client = _factory.CreateDefaultClient()) { diff --git a/Timeline.Tests/UserUnitTest.cs b/Timeline.Tests/UserUnitTest.cs index b3377f7b..5728879b 100644 --- a/Timeline.Tests/UserUnitTest.cs +++ b/Timeline.Tests/UserUnitTest.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json; using System.Linq; using System.Net; using System.Threading.Tasks; -using Timeline.Entities; +using Timeline.Models; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; using Xunit; diff --git a/Timeline/Authenticate/Attribute.cs b/Timeline/Authenticate/Attribute.cs index 50b2681d..645eb236 100644 --- a/Timeline/Authenticate/Attribute.cs +++ b/Timeline/Authenticate/Attribute.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Authorization; -using Timeline.Models; +using Timeline.Entities; namespace Timeline.Authenticate { diff --git a/Timeline/Authenticate/AuthHandler.cs b/Timeline/Authenticate/AuthHandler.cs index 41cb11c6..41d05831 100644 --- a/Timeline/Authenticate/AuthHandler.cs +++ b/Timeline/Authenticate/AuthHandler.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Timeline.Models; using Timeline.Services; namespace Timeline.Authenticate @@ -80,7 +81,7 @@ namespace Timeline.Authenticate var identity = new ClaimsIdentity(AuthConstants.Scheme); identity.AddClaim(new Claim(identity.NameClaimType, userInfo.Username, ClaimValueTypes.String)); - identity.AddClaims(Entities.UserUtility.IsAdminToRoleArray(userInfo.Administrator).Select(role => new Claim(identity.RoleClaimType, role, ClaimValueTypes.String))); + identity.AddClaims(UserUtility.IsAdminToRoleArray(userInfo.Administrator).Select(role => new Claim(identity.RoleClaimType, role, ClaimValueTypes.String))); var principal = new ClaimsPrincipal(); principal.AddIdentity(identity); diff --git a/Timeline/Controllers/TokenController.cs b/Timeline/Controllers/TokenController.cs index 549e227b..57407558 100644 --- a/Timeline/Controllers/TokenController.cs +++ b/Timeline/Controllers/TokenController.cs @@ -5,13 +5,14 @@ using Microsoft.IdentityModel.Tokens; using System; using System.Collections.Generic; using System.Threading.Tasks; -using Timeline.Entities.Http; +using Timeline.Models.Http; using Timeline.Services; using static Timeline.Helpers.MyLogHelper; namespace Timeline.Controllers { [Route("token")] + [ApiController] public class TokenController : Controller { private static class LoggingEventIds @@ -60,22 +61,9 @@ namespace Timeline.Controllers Pair("Expire Offset (in days)", request.ExpireOffset))); } - TimeSpan? expireOffset = null; - if (request.ExpireOffset != null) - { - if (request.ExpireOffset.Value <= 0.0) - { - const string message = "Expire time is not bigger than 0."; - var code = ErrorCodes.Create_BadExpireOffset; - LogFailure(message, code); - return BadRequest(new CommonResponse(code, message)); - } - expireOffset = TimeSpan.FromDays(request.ExpireOffset.Value); - } - try { - var expiredTime = expireOffset == null ? null : (DateTime?)(_clock.GetCurrentTime() + expireOffset.Value); + var expiredTime = request.ExpireOffset == null ? null : (DateTime?)(_clock.GetCurrentTime().AddDays(request.ExpireOffset.Value)); var result = await _userService.CreateToken(request.Username, request.Password, expiredTime); _logger.LogInformation(LoggingEventIds.CreateSucceeded, FormatLogMessage("Attemp to login succeeded.", Pair("Username", request.Username), diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 2099690c..042a8107 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -4,13 +4,14 @@ using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; using Timeline.Authenticate; -using Timeline.Entities; -using Timeline.Entities.Http; +using Timeline.Models; +using Timeline.Models.Http; using Timeline.Services; using static Timeline.Helpers.MyLogHelper; namespace Timeline.Controllers { + [ApiController] public class UserController : Controller { private static class ErrorCodes diff --git a/Timeline/Controllers/UserTestController.cs b/Timeline/Controllers/UserTestController.cs index 21686b81..a81f42a8 100644 --- a/Timeline/Controllers/UserTestController.cs +++ b/Timeline/Controllers/UserTestController.cs @@ -5,6 +5,7 @@ using Timeline.Authenticate; namespace Timeline.Controllers { [Route("Test/User")] + [ApiController] public class UserTestController : Controller { [HttpGet("[action]")] diff --git a/Timeline/Entities/DatabaseContext.cs b/Timeline/Entities/DatabaseContext.cs new file mode 100644 index 00000000..9fe046ac --- /dev/null +++ b/Timeline/Entities/DatabaseContext.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + public static class UserRoles + { + public const string Admin = "admin"; + public const string User = "user"; + } + + [Table("user")] + public class User + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("name"), Required] + public string Name { get; set; } + + [Column("password"), Required] + public string EncryptedPassword { get; set; } + + [Column("roles"), Required] + public string RoleString { get; set; } + + [Column("version"), Required] + public long Version { get; set; } + } + + public class DatabaseContext : DbContext + { + public DatabaseContext(DbContextOptions options) + : base(options) + { + + } + + public DbSet Users { get; set; } + } +} diff --git a/Timeline/Entities/Http/Common.cs b/Timeline/Entities/Http/Common.cs deleted file mode 100644 index 3a45a0ae..00000000 --- a/Timeline/Entities/Http/Common.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Timeline.Entities.Http -{ - public class CommonResponse - { - public CommonResponse() - { - - } - - public CommonResponse(int code, string message) - { - Code = code; - Message = message; - } - - public int Code { get; set; } - public string Message { get; set; } - } - - public static class CommonPutResponse - { - public const int CreatedCode = 0; - public const int ModifiedCode = 1; - - public static CommonResponse Created { get; } = new CommonResponse(CreatedCode, "A new item is created."); - public static CommonResponse Modified { get; } = new CommonResponse(ModifiedCode, "An existent item is modified."); - } - - public static class CommonDeleteResponse - { - public const int DeletedCode = 0; - public const int NotExistsCode = 1; - - public static CommonResponse Deleted { get; } = new CommonResponse(DeletedCode, "An existent item is deleted."); - public static CommonResponse NotExists { get; } = new CommonResponse(NotExistsCode, "The item does not exist."); - } -} diff --git a/Timeline/Entities/Http/Token.cs b/Timeline/Entities/Http/Token.cs deleted file mode 100644 index 8a02ed2e..00000000 --- a/Timeline/Entities/Http/Token.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Timeline.Entities.Http -{ - public class CreateTokenRequest - { - public string Username { get; set; } - public string Password { get; set; } - // in day - public double? ExpireOffset { get; set; } - } - - public class CreateTokenResponse - { - public string Token { get; set; } - public UserInfo User { get; set; } - } - - public class VerifyTokenRequest - { - public string Token { get; set; } - } - - public class VerifyTokenResponse - { - public UserInfo User { get; set; } - } -} diff --git a/Timeline/Entities/Http/User.cs b/Timeline/Entities/Http/User.cs deleted file mode 100644 index b5384778..00000000 --- a/Timeline/Entities/Http/User.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Timeline.Entities.Http -{ - public class UserPutRequest - { - public string Password { get; set; } - public bool Administrator { get; set; } - } - - public class UserPatchRequest - { - public string Password { get; set; } - public bool? Administrator { get; set; } - } - - public class ChangePasswordRequest - { - public string OldPassword { get; set; } - public string NewPassword { get; set; } - } -} diff --git a/Timeline/Entities/PutResult.cs b/Timeline/Entities/PutResult.cs deleted file mode 100644 index 4ed48572..00000000 --- a/Timeline/Entities/PutResult.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Timeline.Entities -{ - /// - /// Represents the result of a "put" operation. - /// - public enum PutResult - { - /// - /// Indicates the item did not exist and now is created. - /// - Created, - /// - /// Indicates the item exists already and is modified. - /// - Modified - } -} diff --git a/Timeline/Entities/UserInfo.cs b/Timeline/Entities/UserInfo.cs deleted file mode 100644 index 414a8dfe..00000000 --- a/Timeline/Entities/UserInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Timeline.Entities -{ - public sealed class UserInfo - { - public UserInfo() - { - } - - public UserInfo(string username, bool administrator) - { - Username = username; - Administrator = administrator; - } - - public string Username { get; set; } - public bool Administrator { get; set; } - - public override string ToString() - { - return $"Username: {Username} ; Administrator: {Administrator}"; - } - } -} diff --git a/Timeline/Entities/UserUtility.cs b/Timeline/Entities/UserUtility.cs deleted file mode 100644 index 14cdb2d6..00000000 --- a/Timeline/Entities/UserUtility.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Linq; -using Timeline.Models; -using Timeline.Services; - -namespace Timeline.Entities -{ - public static class UserUtility - { - public const string UserRole = UserRoles.User; - public const string AdminRole = UserRoles.Admin; - - public static string[] UserRoleArray { get; } = new string[] { UserRole }; - public static string[] AdminRoleArray { get; } = new string[] { UserRole, AdminRole }; - - public static string[] IsAdminToRoleArray(bool isAdmin) - { - return isAdmin ? AdminRoleArray : UserRoleArray; - } - - public static bool RoleArrayToIsAdmin(string[] roles) - { - return roles.Contains(AdminRole); - } - - public static string[] RoleStringToRoleArray(string roleString) - { - return roleString.Split(',').ToArray(); - } - - public static string RoleArrayToRoleString(string[] roles) - { - return string.Join(',', roles); - } - - public static string IsAdminToRoleString(bool isAdmin) - { - return RoleArrayToRoleString(IsAdminToRoleArray(isAdmin)); - } - - public static bool RoleStringToIsAdmin(string roleString) - { - return RoleArrayToIsAdmin(RoleStringToRoleArray(roleString)); - } - - public static UserInfo CreateUserInfo(User user) - { - if (user == null) - throw new ArgumentNullException(nameof(user)); - return new UserInfo(user.Name, RoleStringToIsAdmin(user.RoleString)); - } - - internal static UserCache CreateUserCache(User user) - { - if (user == null) - throw new ArgumentNullException(nameof(user)); - return new UserCache { Username = user.Name, Administrator = RoleStringToIsAdmin(user.RoleString), Version = user.Version }; - } - } -} diff --git a/Timeline/Helpers/InvalidModelResponseFactory.cs b/Timeline/Helpers/InvalidModelResponseFactory.cs new file mode 100644 index 00000000..e5c87d9e --- /dev/null +++ b/Timeline/Helpers/InvalidModelResponseFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text; +using Timeline.Models.Http; + +namespace Timeline.Helpers +{ + public static class InvalidModelResponseFactory + { + public static IActionResult Factory(ActionContext context) + { + var modelState = context.ModelState; + + var messageBuilder = new StringBuilder(); + foreach (var model in modelState) + foreach (var error in model.Value.Errors) + { + messageBuilder.Append(model.Key); + messageBuilder.Append(" : "); + messageBuilder.AppendLine(error.ErrorMessage); + } + + return new BadRequestObjectResult(CommonResponse.InvalidModel(messageBuilder.ToString())); + } + } +} diff --git a/Timeline/Migrations/20190412102517_InitCreate.Designer.cs b/Timeline/Migrations/20190412102517_InitCreate.Designer.cs index c68183de..1e4a4115 100644 --- a/Timeline/Migrations/20190412102517_InitCreate.Designer.cs +++ b/Timeline/Migrations/20190412102517_InitCreate.Designer.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Timeline.Models; +using Timeline.Entities; namespace Timeline.Migrations { diff --git a/Timeline/Migrations/20190412144150_AddAdminUser.Designer.cs b/Timeline/Migrations/20190412144150_AddAdminUser.Designer.cs index 319c646a..12a6fb77 100644 --- a/Timeline/Migrations/20190412144150_AddAdminUser.Designer.cs +++ b/Timeline/Migrations/20190412144150_AddAdminUser.Designer.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Timeline.Models; +using Timeline.Entities; namespace Timeline.Migrations { diff --git a/Timeline/Migrations/20190412153003_MakeColumnsInUserNotNull.Designer.cs b/Timeline/Migrations/20190412153003_MakeColumnsInUserNotNull.Designer.cs index c1d1565f..a2644feb 100644 --- a/Timeline/Migrations/20190412153003_MakeColumnsInUserNotNull.Designer.cs +++ b/Timeline/Migrations/20190412153003_MakeColumnsInUserNotNull.Designer.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Timeline.Models; +using Timeline.Entities; namespace Timeline.Migrations { diff --git a/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs b/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs index 42eeeb40..7402b082 100644 --- a/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs +++ b/Timeline/Migrations/20190719115321_Add-User-Version.Designer.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Timeline.Models; +using Timeline.Entities; namespace Timeline.Migrations { diff --git a/Timeline/Migrations/DatabaseContextModelSnapshot.cs b/Timeline/Migrations/DatabaseContextModelSnapshot.cs index 7d244969..be8b3e9f 100644 --- a/Timeline/Migrations/DatabaseContextModelSnapshot.cs +++ b/Timeline/Migrations/DatabaseContextModelSnapshot.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Timeline.Models; +using Timeline.Entities; namespace Timeline.Migrations { diff --git a/Timeline/Models/DatabaseContext.cs b/Timeline/Models/DatabaseContext.cs deleted file mode 100644 index afd5a333..00000000 --- a/Timeline/Models/DatabaseContext.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Timeline.Models -{ - public static class UserRoles - { - public const string Admin = "admin"; - public const string User = "user"; - } - - [Table("user")] - public class User - { - [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Column("name"), Required] - public string Name { get; set; } - - [Column("password"), Required] - public string EncryptedPassword { get; set; } - - [Column("roles"), Required] - public string RoleString { get; set; } - - [Column("version"), Required] - public long Version { get; set; } - } - - public class DatabaseContext : DbContext - { - public DatabaseContext(DbContextOptions options) - : base(options) - { - - } - - public DbSet Users { get; set; } - } -} diff --git a/Timeline/Models/Http/Common.cs b/Timeline/Models/Http/Common.cs new file mode 100644 index 00000000..74959088 --- /dev/null +++ b/Timeline/Models/Http/Common.cs @@ -0,0 +1,51 @@ +namespace Timeline.Models.Http +{ + public class CommonResponse + { + public static class ErrorCodes + { + /// + /// Used when the model is invaid. + /// For example a required field is null. + /// + public const int InvalidModel = -100; + } + + public static CommonResponse InvalidModel(string message) + { + return new CommonResponse(ErrorCodes.InvalidModel, message); + } + + public CommonResponse() + { + + } + + public CommonResponse(int code, string message) + { + Code = code; + Message = message; + } + + public int Code { get; set; } + public string Message { get; set; } + } + + public static class CommonPutResponse + { + public const int CreatedCode = 0; + public const int ModifiedCode = 1; + + public static CommonResponse Created { get; } = new CommonResponse(CreatedCode, "A new item is created."); + public static CommonResponse Modified { get; } = new CommonResponse(ModifiedCode, "An existent item is modified."); + } + + public static class CommonDeleteResponse + { + public const int DeletedCode = 0; + public const int NotExistsCode = 1; + + public static CommonResponse Deleted { get; } = new CommonResponse(DeletedCode, "An existent item is deleted."); + public static CommonResponse NotExists { get; } = new CommonResponse(NotExistsCode, "The item does not exist."); + } +} diff --git a/Timeline/Models/Http/Token.cs b/Timeline/Models/Http/Token.cs new file mode 100644 index 00000000..cef679ef --- /dev/null +++ b/Timeline/Models/Http/Token.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Timeline.Models.Http +{ + public class CreateTokenRequest + { + [Required] + public string Username { get; set; } + [Required] + public string Password { get; set; } + // in days, optional + [Range(1, 365)] + public int? ExpireOffset { get; set; } + } + + public class CreateTokenResponse + { + public string Token { get; set; } + public UserInfo User { get; set; } + } + + public class VerifyTokenRequest + { + [Required] + public string Token { get; set; } + } + + public class VerifyTokenResponse + { + public UserInfo User { get; set; } + } +} diff --git a/Timeline/Models/Http/User.cs b/Timeline/Models/Http/User.cs new file mode 100644 index 00000000..1de7fae2 --- /dev/null +++ b/Timeline/Models/Http/User.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Timeline.Models.Http +{ + public class UserPutRequest + { + [Required] + public string Password { get; set; } + [Required] + public bool Administrator { get; set; } + } + + public class UserPatchRequest + { + public string Password { get; set; } + public bool? Administrator { get; set; } + } + + public class ChangePasswordRequest + { + [Required] + public string OldPassword { get; set; } + [Required] + public string NewPassword { get; set; } + } +} diff --git a/Timeline/Models/PutResult.cs b/Timeline/Models/PutResult.cs new file mode 100644 index 00000000..f11ac138 --- /dev/null +++ b/Timeline/Models/PutResult.cs @@ -0,0 +1,17 @@ +namespace Timeline.Models +{ + /// + /// Represents the result of a "put" operation. + /// + public enum PutResult + { + /// + /// Indicates the item did not exist and now is created. + /// + Created, + /// + /// Indicates the item exists already and is modified. + /// + Modified + } +} diff --git a/Timeline/Models/UserInfo.cs b/Timeline/Models/UserInfo.cs new file mode 100644 index 00000000..b5cb1e7f --- /dev/null +++ b/Timeline/Models/UserInfo.cs @@ -0,0 +1,23 @@ +namespace Timeline.Models +{ + public sealed class UserInfo + { + public UserInfo() + { + } + + public UserInfo(string username, bool administrator) + { + Username = username; + Administrator = administrator; + } + + public string Username { get; set; } + public bool Administrator { get; set; } + + public override string ToString() + { + return $"Username: {Username} ; Administrator: {Administrator}"; + } + } +} diff --git a/Timeline/Models/UserUtility.cs b/Timeline/Models/UserUtility.cs new file mode 100644 index 00000000..711e321a --- /dev/null +++ b/Timeline/Models/UserUtility.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using Timeline.Entities; +using Timeline.Services; + +namespace Timeline.Models +{ + public static class UserUtility + { + public const string UserRole = UserRoles.User; + public const string AdminRole = UserRoles.Admin; + + public static string[] UserRoleArray { get; } = new string[] { UserRole }; + public static string[] AdminRoleArray { get; } = new string[] { UserRole, AdminRole }; + + public static string[] IsAdminToRoleArray(bool isAdmin) + { + return isAdmin ? AdminRoleArray : UserRoleArray; + } + + public static bool RoleArrayToIsAdmin(string[] roles) + { + return roles.Contains(AdminRole); + } + + public static string[] RoleStringToRoleArray(string roleString) + { + return roleString.Split(',').ToArray(); + } + + public static string RoleArrayToRoleString(string[] roles) + { + return string.Join(',', roles); + } + + public static string IsAdminToRoleString(bool isAdmin) + { + return RoleArrayToRoleString(IsAdminToRoleArray(isAdmin)); + } + + public static bool RoleStringToIsAdmin(string roleString) + { + return RoleArrayToIsAdmin(RoleStringToRoleArray(roleString)); + } + + public static UserInfo CreateUserInfo(User user) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + return new UserInfo(user.Name, RoleStringToIsAdmin(user.RoleString)); + } + + internal static UserCache CreateUserCache(User user) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + return new UserCache { Username = user.Name, Administrator = RoleStringToIsAdmin(user.RoleString), Version = user.Version }; + } + } +} diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 7fe7a2b6..28218612 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -6,8 +6,8 @@ using System.Linq; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Models; -using static Timeline.Entities.UserUtility; using static Timeline.Helpers.MyLogHelper; +using static Timeline.Models.UserUtility; namespace Timeline.Services { diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 242e816d..e6a8f96f 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -8,7 +8,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Timeline.Authenticate; using Timeline.Configs; -using Timeline.Models; +using Timeline.Entities; +using Timeline.Helpers; using Timeline.Services; namespace Timeline @@ -29,7 +30,11 @@ namespace Timeline // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + services.AddMvc() + .ConfigureApiBehaviorOptions(options =>{ + options.InvalidModelStateResponseFactory = InvalidModelResponseFactory.Factory; + }) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); services.AddCors(options => { -- cgit v1.2.3 From 063321c90b8509249e65b49f39cf7d4f375305f6 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 8 Aug 2019 17:13:14 +0800 Subject: 2 things. 1. Make Administrator in UserPutRequest nullable. 2. Remove default route. --- Timeline/Controllers/UserController.cs | 10 +--------- Timeline/Models/Http/User.cs | 2 +- Timeline/Startup.cs | 2 +- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 0992946c..28d9523a 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -18,8 +18,6 @@ namespace Timeline.Controllers { public const int Get_NotExists = -1001; - public const int Put_NoPassword = -2001; - public const int Patch_NotExists = -3001; public const int ChangePassword_BadOldPassword = -4001; @@ -55,13 +53,7 @@ namespace Timeline.Controllers [HttpPut("user/{username}"), AdminAuthorize] public async Task Put([FromBody] UserPutRequest request, [FromRoute] string username) { - if (request.Password == null) // This place will be refactored. - { - _logger.LogInformation("Attempt to put a user without a password. Username: {} .", username); - return BadRequest(); - } - - var result = await _userService.PutUser(username, request.Password, request.Administrator); + var result = await _userService.PutUser(username, request.Password, request.Administrator.Value); switch (result) { case PutResult.Created: diff --git a/Timeline/Models/Http/User.cs b/Timeline/Models/Http/User.cs index 3259a448..d45543fb 100644 --- a/Timeline/Models/Http/User.cs +++ b/Timeline/Models/Http/User.cs @@ -7,7 +7,7 @@ namespace Timeline.Models.Http [Required] public string Password { get; set; } [Required] - public bool Administrator { get; set; } + public bool? Administrator { get; set; } } public class UserPatchRequest diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index a28899f4..414bc705 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -89,7 +89,7 @@ namespace Timeline app.UseAuthentication(); - app.UseMvcWithDefaultRoute(); + app.UseMvc(); } } } -- cgit v1.2.3 From 517d8023c348893221b150a8bdf04a91ee513b6b Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 8 Aug 2019 17:19:38 +0800 Subject: Rename user yo users in route. --- Timeline/Controllers/UserController.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 28d9523a..af4cfb53 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -16,11 +16,11 @@ namespace Timeline.Controllers { private static class ErrorCodes { - public const int Get_NotExists = -1001; + public const int Get_NotExist = -1001; - public const int Patch_NotExists = -3001; + public const int Patch_NotExist = -2001; - public const int ChangePassword_BadOldPassword = -4001; + public const int ChangePassword_BadOldPassword = -3001; } private readonly ILogger _logger; @@ -38,19 +38,19 @@ namespace Timeline.Controllers return Ok(await _userService.ListUsers()); } - [HttpGet("user/{username}"), AdminAuthorize] + [HttpGet("users/{username}"), AdminAuthorize] public async Task Get([FromRoute] string username) { var user = await _userService.GetUser(username); if (user == null) { _logger.LogInformation(FormatLogMessage("Attempt to get a non-existent user.", Pair("Username", username))); - return NotFound(new CommonResponse(ErrorCodes.Get_NotExists, "The user does not exist.")); + return NotFound(new CommonResponse(ErrorCodes.Get_NotExist, "The user does not exist.")); } return Ok(user); } - [HttpPut("user/{username}"), AdminAuthorize] + [HttpPut("users/{username}"), AdminAuthorize] public async Task Put([FromBody] UserPutRequest request, [FromRoute] string username) { var result = await _userService.PutUser(username, request.Password, request.Administrator.Value); @@ -67,7 +67,7 @@ namespace Timeline.Controllers } } - [HttpPatch("user/{username}"), AdminAuthorize] + [HttpPatch("users/{username}"), AdminAuthorize] public async Task Patch([FromBody] UserPatchRequest request, [FromRoute] string username) { try @@ -78,11 +78,11 @@ namespace Timeline.Controllers catch (UserNotExistException e) { _logger.LogInformation(e, FormatLogMessage("Attempt to patch a non-existent user.", Pair("Username", username))); - return BadRequest(new CommonResponse(ErrorCodes.Patch_NotExists, "The user does not exist.")); + return BadRequest(new CommonResponse(ErrorCodes.Patch_NotExist , "The user does not exist.")); } } - [HttpDelete("user/{username}"), AdminAuthorize] + [HttpDelete("users/{username}"), AdminAuthorize] public async Task Delete([FromRoute] string username) { try -- cgit v1.2.3 From d3e8485bcd1070108299995d6335318dea6a1c22 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 8 Aug 2019 17:41:59 +0800 Subject: Reorganize unit tests. --- Timeline.Tests/AuthorizationUnitTest.cs | 6 +-- .../Authentication/AuthenticationExtensions.cs | 13 +++++- Timeline.Tests/Helpers/MyWebApplicationFactory.cs | 4 +- Timeline.Tests/Helpers/TestClock.cs | 25 ----------- Timeline.Tests/Helpers/TestUsers.cs | 42 ----------------- Timeline.Tests/Mock/Data/TestUsers.cs | 52 ++++++++++++++++++++++ Timeline.Tests/Mock/Services/TestClock.cs | 25 +++++++++++ Timeline.Tests/Timeline.Tests.csproj | 2 +- Timeline.Tests/TokenUnitTest.cs | 33 +++++++++----- Timeline.Tests/UserUnitTest.cs | 5 ++- 10 files changed, 121 insertions(+), 86 deletions(-) delete mode 100644 Timeline.Tests/Helpers/TestClock.cs delete mode 100644 Timeline.Tests/Helpers/TestUsers.cs create mode 100644 Timeline.Tests/Mock/Data/TestUsers.cs create mode 100644 Timeline.Tests/Mock/Services/TestClock.cs diff --git a/Timeline.Tests/AuthorizationUnitTest.cs b/Timeline.Tests/AuthorizationUnitTest.cs index 8df23c45..d9fb7406 100644 --- a/Timeline.Tests/AuthorizationUnitTest.cs +++ b/Timeline.Tests/AuthorizationUnitTest.cs @@ -34,7 +34,7 @@ namespace Timeline.Tests [Fact] public async Task AuthenticationTest() { - using (var client = await _factory.CreateClientWithUser("user", "user")) + using (var client = await _factory.CreateClientAsUser()) { var response = await client.GetAsync(AuthorizeUrl); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -44,7 +44,7 @@ namespace Timeline.Tests [Fact] public async Task UserAuthorizationTest() { - using (var client = await _factory.CreateClientWithUser("user", "user")) + using (var client = await _factory.CreateClientAsUser()) { var response1 = await client.GetAsync(UserUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); @@ -56,7 +56,7 @@ namespace Timeline.Tests [Fact] public async Task AdminAuthorizationTest() { - using (var client = await _factory.CreateClientWithUser("admin", "admin")) + using (var client = await _factory.CreateClientAsAdmin()) { var response1 = await client.GetAsync(UserUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index de88fd35..e31bd51c 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using System.Net.Http; using System.Threading.Tasks; using Timeline.Models.Http; +using Timeline.Tests.Mock.Data; namespace Timeline.Tests.Helpers.Authentication { @@ -17,12 +18,22 @@ namespace Timeline.Tests.Helpers.Authentication return result; } - public static async Task CreateClientWithUser(this WebApplicationFactory factory, string username, string password) where T : class + public static async Task CreateClientWithCredential(this WebApplicationFactory factory, string username, string password) where T : class { var client = factory.CreateDefaultClient(); var token = (await client.CreateUserTokenAsync(username, password)).Token; client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); return client; } + + public static Task CreateClientAsUser(this WebApplicationFactory factory) where T : class + { + return factory.CreateClientWithCredential(MockUsers.UserUsername, MockUsers.UserPassword); + } + + public static Task CreateClientAsAdmin(this WebApplicationFactory factory) where T : class + { + return factory.CreateClientWithCredential(MockUsers.AdminUsername, MockUsers.AdminPassword); + } } } diff --git a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs index d8da7168..b49756e4 100644 --- a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs +++ b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs @@ -7,6 +7,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Timeline.Entities; using Timeline.Services; +using Timeline.Tests.Mock.Data; +using Timeline.Tests.Mock.Services; using Xunit.Abstractions; namespace Timeline.Tests.Helpers @@ -34,7 +36,7 @@ namespace Timeline.Tests.Helpers using (var context = new DatabaseContext(options)) { context.Database.EnsureCreated(); - context.Users.AddRange(TestMockUsers.MockUsers); + context.Users.AddRange(MockUsers.Users); context.SaveChanges(); } } diff --git a/Timeline.Tests/Helpers/TestClock.cs b/Timeline.Tests/Helpers/TestClock.cs deleted file mode 100644 index ea90305f..00000000 --- a/Timeline.Tests/Helpers/TestClock.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using System; -using Timeline.Services; - -namespace Timeline.Tests.Helpers -{ - public class TestClock : IClock - { - public DateTime? MockCurrentTime { get; set; } = null; - - public DateTime GetCurrentTime() - { - return MockCurrentTime.GetValueOrDefault(DateTime.Now); - } - } - - public static class TestClockWebApplicationFactoryExtensions - { - public static TestClock GetTestClock(this WebApplicationFactory factory) where T : class - { - return factory.Server.Host.Services.GetRequiredService() as TestClock; - } - } -} diff --git a/Timeline.Tests/Helpers/TestUsers.cs b/Timeline.Tests/Helpers/TestUsers.cs deleted file mode 100644 index 71de8237..00000000 --- a/Timeline.Tests/Helpers/TestUsers.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Timeline.Entities; -using Timeline.Models; -using Timeline.Services; - -namespace Timeline.Tests.Helpers -{ - public static class TestMockUsers - { - static TestMockUsers() - { - var mockUsers = new List(); - var passwordService = new PasswordService(); - - mockUsers.Add(new User - { - Name = "user", - EncryptedPassword = passwordService.HashPassword("user"), - RoleString = UserUtility.IsAdminToRoleString(false), - Version = 0, - }); - mockUsers.Add(new User - { - Name = "admin", - EncryptedPassword = passwordService.HashPassword("admin"), - RoleString = UserUtility.IsAdminToRoleString(true), - Version = 0, - }); - - MockUsers = mockUsers; - - var mockUserInfos = mockUsers.Select(u => UserUtility.CreateUserInfo(u)).ToList(); - mockUserInfos.Sort(UserInfoComparers.Comparer); - MockUserInfos = mockUserInfos; - } - - public static List MockUsers { get; } - - public static IReadOnlyList MockUserInfos { get; } - } -} diff --git a/Timeline.Tests/Mock/Data/TestUsers.cs b/Timeline.Tests/Mock/Data/TestUsers.cs new file mode 100644 index 00000000..d784e48c --- /dev/null +++ b/Timeline.Tests/Mock/Data/TestUsers.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using Timeline.Entities; +using Timeline.Models; +using Timeline.Services; +using Timeline.Tests.Helpers; + +namespace Timeline.Tests.Mock.Data +{ + public static class MockUsers + { + static MockUsers() + { + var mockUsers = new List(); + var passwordService = new PasswordService(); + + mockUsers.Add(new User + { + Name = UserUsername, + EncryptedPassword = passwordService.HashPassword(UserPassword), + RoleString = UserUtility.IsAdminToRoleString(false), + Version = 0, + }); + mockUsers.Add(new User + { + Name = AdminUsername, + EncryptedPassword = passwordService.HashPassword(AdminPassword), + RoleString = UserUtility.IsAdminToRoleString(true), + Version = 0, + }); + + Users = mockUsers; + + var mockUserInfos = mockUsers.Select(u => UserUtility.CreateUserInfo(u)).ToList(); + UserUserInfo = mockUserInfos[0]; + AdminUserInfo = mockUserInfos[1]; + mockUserInfos.Sort(UserInfoComparers.Comparer); + UserInfos = mockUserInfos; + } + + public const string UserUsername = "user"; + public const string AdminUsername = "admin"; + public const string UserPassword= "user"; + public const string AdminPassword = "admin"; + + internal static IReadOnlyList Users { get; } + public static IReadOnlyList UserInfos { get; } + + public static UserInfo AdminUserInfo { get; } + public static UserInfo UserUserInfo { get; } + } +} diff --git a/Timeline.Tests/Mock/Services/TestClock.cs b/Timeline.Tests/Mock/Services/TestClock.cs new file mode 100644 index 00000000..0082171e --- /dev/null +++ b/Timeline.Tests/Mock/Services/TestClock.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System; +using Timeline.Services; + +namespace Timeline.Tests.Mock.Services +{ + public class TestClock : IClock + { + public DateTime? MockCurrentTime { get; set; } = null; + + public DateTime GetCurrentTime() + { + return MockCurrentTime.GetValueOrDefault(DateTime.Now); + } + } + + public static class TestClockWebApplicationFactoryExtensions + { + public static TestClock GetTestClock(this WebApplicationFactory factory) where T : class + { + return factory.Server.Host.Services.GetRequiredService() as TestClock; + } + } +} diff --git a/Timeline.Tests/Timeline.Tests.csproj b/Timeline.Tests/Timeline.Tests.csproj index 8cc304f4..854c63ac 100644 --- a/Timeline.Tests/Timeline.Tests.csproj +++ b/Timeline.Tests/Timeline.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.2 diff --git a/Timeline.Tests/TokenUnitTest.cs b/Timeline.Tests/TokenUnitTest.cs index d2a68553..5f3b8e6d 100644 --- a/Timeline.Tests/TokenUnitTest.cs +++ b/Timeline.Tests/TokenUnitTest.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using System; -using System.Linq; using System.Net; using System.Net.Http; using Timeline.Controllers; @@ -10,6 +9,8 @@ using Timeline.Models.Http; using Timeline.Services; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; +using Timeline.Tests.Mock.Data; +using Timeline.Tests.Mock.Services; using Xunit; using Xunit.Abstractions; @@ -53,7 +54,12 @@ namespace Timeline.Tests using (var client = _factory.CreateDefaultClient()) { await InvalidModelTestHelpers.TestPostInvalidModel(client, CreateTokenUrl, - new CreateTokenRequest { Username = "user", Password = "user", ExpireOffset = -1000 }); + new CreateTokenRequest + { + Username = MockUsers.UserUsername, + Password = MockUsers.UserPassword, + ExpireOffset = -1000 + }); } } @@ -62,7 +68,8 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "usernotexist", Password = "???" }); + var response = await client.PostAsJsonAsync(CreateTokenUrl, + new CreateTokenRequest { Username = "usernotexist", Password = "???" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.ReadBodyAsJson(); Assert.Equal(TokenController.ErrorCodes.Create_UserNotExist, body.Code); @@ -74,7 +81,8 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "???" }); + var response = await client.PostAsJsonAsync(CreateTokenUrl, + new CreateTokenRequest { Username = MockUsers.UserUsername, Password = "???" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.ReadBodyAsJson(); Assert.Equal(TokenController.ErrorCodes.Create_BadPassword, body.Code); @@ -86,11 +94,12 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = "user", Password = "user" }); + var response = await client.PostAsJsonAsync(CreateTokenUrl, + new CreateTokenRequest { Username = MockUsers.UserUsername, Password = MockUsers.UserPassword }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.ReadBodyAsJson(); Assert.NotEmpty(body.Token); - Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); + Assert.Equal(MockUsers.UserUserInfo, body.User, UserInfoComparers.EqualityComparer); } } @@ -167,8 +176,9 @@ namespace Timeline.Tests // because verify logic is encapsuled in other library. var mockClock = _factory.GetTestClock(); mockClock.MockCurrentTime = DateTime.Now - TimeSpan.FromDays(2); - var token = (await client.CreateUserTokenAsync("user", "user", 1)).Token; - var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = token }); + var token = (await client.CreateUserTokenAsync(MockUsers.UserUsername, MockUsers.UserPassword, 1)).Token; + var response = await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = token }); var body = await response.ReadBodyAsJson(); Assert.Equal(TokenController.ErrorCodes.Verify_Expired, body.Code); mockClock.MockCurrentTime = null; @@ -180,11 +190,12 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var createTokenResult = await client.CreateUserTokenAsync("user", "user"); - var response = await client.PostAsJsonAsync(VerifyTokenUrl, new VerifyTokenRequest { Token = createTokenResult.Token }); + var createTokenResult = await client.CreateUserTokenAsync(MockUsers.UserUsername, MockUsers.UserPassword); + var response = await client.PostAsJsonAsync(VerifyTokenUrl, + new VerifyTokenRequest { Token = createTokenResult.Token }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - Assert.Equal(TestMockUsers.MockUserInfos.Where(u => u.Username == "user").Single(), body.User, UserInfoComparers.EqualityComparer); + Assert.Equal(MockUsers.UserUserInfo, body.User, UserInfoComparers.EqualityComparer); } } } diff --git a/Timeline.Tests/UserUnitTest.cs b/Timeline.Tests/UserUnitTest.cs index 82284a82..7935dd9a 100644 --- a/Timeline.Tests/UserUnitTest.cs +++ b/Timeline.Tests/UserUnitTest.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Timeline.Models; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; +using Timeline.Tests.Mock.Data; using Xunit; using Xunit.Abstractions; @@ -23,13 +24,13 @@ namespace Timeline.Tests [Fact] public async Task UserTest() { - using (var client = await _factory.CreateClientWithUser("admin", "admin")) + using (var client = await _factory.CreateClientAsAdmin()) { var res1 = await client.GetAsync("users"); Assert.Equal(HttpStatusCode.OK, res1.StatusCode); var users = JsonConvert.DeserializeObject(await res1.Content.ReadAsStringAsync()).ToList(); users.Sort(UserInfoComparers.Comparer); - Assert.Equal(TestMockUsers.MockUserInfos, users, UserInfoComparers.EqualityComparer); + Assert.Equal(MockUsers.UserInfos, users, UserInfoComparers.EqualityComparer); } } } -- cgit v1.2.3 From 5e0ad225efd9627fa99509a1fdf3ed07672c8bda Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Fri, 9 Aug 2019 15:39:58 +0800 Subject: Add UserController unit tests. --- .../Authentication/AuthenticationExtensions.cs | 1 + Timeline.Tests/Helpers/HttpClientExtensions.cs | 15 ++ Timeline.Tests/Helpers/InvalidModelTestHelpers.cs | 8 + Timeline.Tests/Helpers/ResponseExtensions.cs | 47 ++++++ Timeline.Tests/UserUnitTest.cs | 187 ++++++++++++++++++++- Timeline/Controllers/UserController.cs | 4 +- 6 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 Timeline.Tests/Helpers/HttpClientExtensions.cs diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs index e31bd51c..8a44c852 100644 --- a/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationExtensions.cs @@ -14,6 +14,7 @@ namespace Timeline.Tests.Helpers.Authentication public static async Task CreateUserTokenAsync(this HttpClient client, string username, string password, int? expireOffset = null) { var response = await client.PostAsJsonAsync(CreateTokenUrl, new CreateTokenRequest { Username = username, Password = password, ExpireOffset = expireOffset }); + response.AssertOk(); var result = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); return result; } diff --git a/Timeline.Tests/Helpers/HttpClientExtensions.cs b/Timeline.Tests/Helpers/HttpClientExtensions.cs new file mode 100644 index 00000000..cd40d91e --- /dev/null +++ b/Timeline.Tests/Helpers/HttpClientExtensions.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Timeline.Tests.Helpers +{ + public static class HttpClientExtensions + { + public static Task PatchAsJsonAsync(this HttpClient client, string url, T body) + { + return client.PatchAsync(url, new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json")); + } + } +} diff --git a/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs b/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs index 51919021..1c079d0e 100644 --- a/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs +++ b/Timeline.Tests/Helpers/InvalidModelTestHelpers.cs @@ -15,5 +15,13 @@ namespace Timeline.Tests.Helpers var responseBody = await response.ReadBodyAsJson(); Assert.Equal(CommonResponse.ErrorCodes.InvalidModel, responseBody.Code); } + + public static async Task TestPutInvalidModel(HttpClient client, string url, T body) + { + var response = await client.PutAsJsonAsync(url, body); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var responseBody = await response.ReadBodyAsJson(); + Assert.Equal(CommonResponse.ErrorCodes.InvalidModel, responseBody.Code); + } } } diff --git a/Timeline.Tests/Helpers/ResponseExtensions.cs b/Timeline.Tests/Helpers/ResponseExtensions.cs index 155836fb..46c9e81d 100644 --- a/Timeline.Tests/Helpers/ResponseExtensions.cs +++ b/Timeline.Tests/Helpers/ResponseExtensions.cs @@ -1,11 +1,58 @@ using Newtonsoft.Json; +using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Timeline.Models.Http; +using Xunit; namespace Timeline.Tests.Helpers { public static class ResponseExtensions { + public static void AssertOk(this HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + public static void AssertNotFound(this HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + public static void AssertBadRequest(this HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + public static async Task AssertIsPutCreated(this HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.ReadBodyAsJson(); + Assert.Equal(CommonPutResponse.CreatedCode, body.Code); + } + + public static async Task AssertIsPutModified(this HttpResponseMessage response) + { + response.AssertOk(); + var body = await response.ReadBodyAsJson(); + Assert.Equal(CommonPutResponse.ModifiedCode, body.Code); + } + + + public static async Task AssertIsDeleteDeleted(this HttpResponseMessage response) + { + response.AssertOk(); + var body = await response.ReadBodyAsJson(); + Assert.Equal(CommonDeleteResponse.DeletedCode, body.Code); + } + + public static async Task AssertIsDeleteNotExist(this HttpResponseMessage response) + { + response.AssertOk(); + var body = await response.ReadBodyAsJson(); + Assert.Equal(CommonDeleteResponse.NotExistsCode, body.Code); + } + public static async Task ReadBodyAsJson(this HttpResponseMessage response) { return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); diff --git a/Timeline.Tests/UserUnitTest.cs b/Timeline.Tests/UserUnitTest.cs index 7935dd9a..c5c91d34 100644 --- a/Timeline.Tests/UserUnitTest.cs +++ b/Timeline.Tests/UserUnitTest.cs @@ -1,9 +1,10 @@ using Microsoft.AspNetCore.Mvc.Testing; -using Newtonsoft.Json; -using System.Linq; using System.Net; +using System.Net.Http; using System.Threading.Tasks; +using Timeline.Controllers; using Timeline.Models; +using Timeline.Models.Http; using Timeline.Tests.Helpers; using Timeline.Tests.Helpers.Authentication; using Timeline.Tests.Mock.Data; @@ -22,15 +23,185 @@ namespace Timeline.Tests } [Fact] - public async Task UserTest() + public async Task Get_Users_List() { using (var client = await _factory.CreateClientAsAdmin()) { - var res1 = await client.GetAsync("users"); - Assert.Equal(HttpStatusCode.OK, res1.StatusCode); - var users = JsonConvert.DeserializeObject(await res1.Content.ReadAsStringAsync()).ToList(); - users.Sort(UserInfoComparers.Comparer); - Assert.Equal(MockUsers.UserInfos, users, UserInfoComparers.EqualityComparer); + var res = await client.GetAsync("users"); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + + // 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. + + // var users = (await res.ReadBodyAsJson()).ToList(); + // users.Sort(UserInfoComparers.Comparer); + // Assert.Equal(MockUsers.UserInfos, users, UserInfoComparers.EqualityComparer); + await res.ReadBodyAsJson(); + } + } + + [Fact] + public async Task Get_Users_User() + { + using (var client = await _factory.CreateClientAsAdmin()) + { + var res = await client.GetAsync("users/" + MockUsers.UserUsername); + res.AssertOk(); + var user = await res.ReadBodyAsJson(); + Assert.Equal(MockUsers.UserUserInfo, user, UserInfoComparers.EqualityComparer); + } + } + + [Fact] + public async Task Get_Users_404() + { + using (var client = await _factory.CreateClientAsAdmin()) + { + var res = await client.GetAsync("users/usernotexist"); + res.AssertNotFound(); + var body = await res.ReadBodyAsJson(); + Assert.Equal(UserController.ErrorCodes.Get_NotExist, body.Code); + } + } + + [Fact] + public async Task Put_Patch_Delete_User() + { + using (var client = await _factory.CreateClientAsAdmin()) + { + const string username = "putpatchdeleteuser"; + const string password = "password"; + const string url = "users/" + username; + + // Put Invalid Model + await InvalidModelTestHelpers.TestPutInvalidModel(client, url, new UserPutRequest { Password = null, Administrator = false }); + await InvalidModelTestHelpers.TestPutInvalidModel(client, url, new UserPutRequest { Password = password, Administrator = null }); + + async Task CheckAdministrator(bool administrator) + { + var res = await client.GetAsync(url); + res.AssertOk(); + var body = await res.ReadBodyAsJson(); + Assert.Equal(administrator, body.Administrator); + } + + { + // Put Created. + var res = await client.PutAsJsonAsync(url, new UserPutRequest + { + Password = password, + Administrator = false + }); + await res.AssertIsPutCreated(); + await CheckAdministrator(false); + } + + { + // Put Modified. + var res = await client.PutAsJsonAsync(url, new UserPutRequest + { + Password = password, + Administrator = true + }); + await res.AssertIsPutModified(); + await CheckAdministrator(true); + } + + // Patch Not Exist + { + var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { }); + res.AssertNotFound(); + var body = await res.ReadBodyAsJson(); + Assert.Equal(UserController.ErrorCodes.Patch_NotExist, body.Code); + } + + // Patch Success + { + var res = await client.PatchAsJsonAsync(url, new UserPatchRequest { Administrator = false }); + res.AssertOk(); + await CheckAdministrator(false); + } + + // Delete Deleted + { + var res = await client.DeleteAsync(url); + await res.AssertIsDeleteDeleted(); + + var res2 = await client.GetAsync(url); + res2.AssertNotFound(); + } + + // Delete Not Exist + { + var res = await client.DeleteAsync(url); + await res.AssertIsDeleteNotExist(); + } + } + } + + + public class ChangePasswordUnitTest : IClassFixture> + { + private const string url = "userop/changepassword"; + + private readonly WebApplicationFactory _factory; + + public ChangePasswordUnitTest(MyWebApplicationFactory factory, ITestOutputHelper outputHelper) + { + _factory = factory.WithTestLogging(outputHelper); + } + + + [Fact] + public async Task InvalidModel_OldPassword() + { + using (var client = await _factory.CreateClientAsUser()) + { + await InvalidModelTestHelpers.TestPostInvalidModel(client, url, new ChangePasswordRequest { OldPassword = null, NewPassword = "???" }); + } + } + + [Fact] + public async Task InvalidModel_NewPassword() + { + using (var client = await _factory.CreateClientAsUser()) + { + await InvalidModelTestHelpers.TestPostInvalidModel(client, url, new ChangePasswordRequest { OldPassword = "???", NewPassword = null }); + } + } + + [Fact] + public async Task BadOldPassword() + { + using (var client = await _factory.CreateClientAsUser()) + { + var res = await client.PostAsJsonAsync(url, new ChangePasswordRequest { OldPassword = "???", NewPassword = "???" }); + res.AssertBadRequest(); + var body = await res.ReadBodyAsJson(); + Assert.Equal(UserController.ErrorCodes.ChangePassword_BadOldPassword, body.Code); + } + } + + [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 }); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + } + + using (var client = await _factory.CreateClientWithCredential(username, password)) + { + const string newPassword = "new"; + var res = await client.PostAsJsonAsync(url, new ChangePasswordRequest { OldPassword = password, NewPassword = newPassword }); + res.AssertOk(); + await client.CreateUserTokenAsync(username, newPassword); + } } } } diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index af4cfb53..6f2fe77f 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -14,7 +14,7 @@ namespace Timeline.Controllers [ApiController] public class UserController : Controller { - private static class ErrorCodes + public static class ErrorCodes { public const int Get_NotExist = -1001; @@ -78,7 +78,7 @@ namespace Timeline.Controllers catch (UserNotExistException e) { _logger.LogInformation(e, FormatLogMessage("Attempt to patch a non-existent user.", Pair("Username", username))); - return BadRequest(new CommonResponse(ErrorCodes.Patch_NotExist , "The user does not exist.")); + return NotFound(new CommonResponse(ErrorCodes.Patch_NotExist, "The user does not exist.")); } } -- cgit v1.2.3 From ca91baed973a12efeb8e139c521e62fe3f0a3680 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Fri, 9 Aug 2019 15:59:38 +0800 Subject: Add configuration file to remove Database Migration Files from code coverage. --- CI/build-pipeline.yml | 2 +- Timeline.Tests/coverletArgs.runsettings | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 Timeline.Tests/coverletArgs.runsettings diff --git a/CI/build-pipeline.yml b/CI/build-pipeline.yml index 2c15e301..85b373ff 100644 --- a/CI/build-pipeline.yml +++ b/CI/build-pipeline.yml @@ -19,7 +19,7 @@ steps: dotnet restore Timeline.Tests/Timeline.Tests.csproj --configfile nuget.config displayName: Dotnet Restore -- script: dotnet test Timeline.Tests/Timeline.Tests.csproj --configuration $(buildConfiguration) --no-restore --logger trx --collect:"XPlat Code Coverage" +- script: dotnet test Timeline.Tests/Timeline.Tests.csproj --configuration $(buildConfiguration) --no-restore --logger trx --collect:"XPlat Code Coverage" --settings './coverletArgs.runsettings' displayName: Dotnet Test - task: PublishTestResults@2 diff --git a/Timeline.Tests/coverletArgs.runsettings b/Timeline.Tests/coverletArgs.runsettings new file mode 100644 index 00000000..a2284297 --- /dev/null +++ b/Timeline.Tests/coverletArgs.runsettings @@ -0,0 +1,13 @@ + + + + + + + + [Timeline]Timeline.Migrations.* + + + + + -- cgit v1.2.3 From ea8397bafe24b5c9ab814891eb3a293a07ca217e Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Fri, 9 Aug 2019 16:07:29 +0800 Subject: Remove xunit from code coverage. --- Timeline.Tests/coverletArgs.runsettings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Timeline.Tests/coverletArgs.runsettings b/Timeline.Tests/coverletArgs.runsettings index a2284297..24cd1822 100644 --- a/Timeline.Tests/coverletArgs.runsettings +++ b/Timeline.Tests/coverletArgs.runsettings @@ -5,7 +5,7 @@ - [Timeline]Timeline.Migrations.* + [xunit.*]*,[Timeline]Timeline.Migrations.* -- cgit v1.2.3