From 39aa54bb10da8b76a4021feb984b8aad0df6269b Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 18 Aug 2019 18:07:50 +0800 Subject: Add avatar service. --- Timeline.Tests/Helpers/MyTestLoggerFactory.cs | 14 ++ Timeline.Tests/Helpers/MyWebApplicationFactory.cs | 5 + Timeline.Tests/Mock/Data/TestDatabase.cs | 46 ++++++ Timeline.Tests/UserAvatarServiceTest.cs | 159 +++++++++++++++++++ Timeline/Entities/DatabaseContext.cs | 3 + Timeline/Entities/UserAvatar.cs | 18 +++ Timeline/Services/UserAvatarService.cs | 181 ++++++++++++++++++++++ Timeline/Timeline.csproj | 6 - Timeline/appsettings.Development.json | 2 +- Timeline/default-avatar.png | Bin 0 -> 26442 bytes art-src/user.svg | 4 + 11 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 Timeline.Tests/Helpers/MyTestLoggerFactory.cs create mode 100644 Timeline.Tests/Mock/Data/TestDatabase.cs create mode 100644 Timeline.Tests/UserAvatarServiceTest.cs create mode 100644 Timeline/Entities/UserAvatar.cs create mode 100644 Timeline/Services/UserAvatarService.cs create mode 100644 Timeline/default-avatar.png create mode 100644 art-src/user.svg diff --git a/Timeline.Tests/Helpers/MyTestLoggerFactory.cs b/Timeline.Tests/Helpers/MyTestLoggerFactory.cs new file mode 100644 index 00000000..40c6a77e --- /dev/null +++ b/Timeline.Tests/Helpers/MyTestLoggerFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit.Abstractions; + +namespace Timeline.Tests.Helpers +{ + public static class MyTestLoggerFactory + { + public static LoggerFactory Create(ITestOutputHelper outputHelper) + { + return new LoggerFactory(new[] { new XunitLoggerProvider(outputHelper) }); + } + } +} diff --git a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs index dfadd1ae..e96d11fe 100644 --- a/Timeline.Tests/Helpers/MyWebApplicationFactory.cs +++ b/Timeline.Tests/Helpers/MyWebApplicationFactory.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; @@ -37,6 +38,10 @@ namespace Timeline.Tests.Helpers { var options = new DbContextOptionsBuilder() .UseSqlite(_databaseConnection) + .ConfigureWarnings(builder => + { + builder.Throw(RelationalEventId.QueryClientEvaluationWarning); + }) .Options; using (var context = new DatabaseContext(options)) diff --git a/Timeline.Tests/Mock/Data/TestDatabase.cs b/Timeline.Tests/Mock/Data/TestDatabase.cs new file mode 100644 index 00000000..09c77dce --- /dev/null +++ b/Timeline.Tests/Mock/Data/TestDatabase.cs @@ -0,0 +1,46 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System; +using Timeline.Entities; + +namespace Timeline.Tests.Mock.Data +{ + public class TestDatabase : IDisposable + { + private readonly SqliteConnection _databaseConnection; + private readonly DatabaseContext _databaseContext; + + public TestDatabase() + { + _databaseConnection = new SqliteConnection("Data Source=:memory:;"); + _databaseConnection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_databaseConnection) + .ConfigureWarnings(builder => + { + builder.Throw(RelationalEventId.QueryClientEvaluationWarning); + }) + .Options; + + _databaseContext = new DatabaseContext(options); + + // init with mock data + _databaseContext.Database.EnsureCreated(); + _databaseContext.Users.AddRange(MockUsers.Users); + _databaseContext.SaveChanges(); + } + + public void Dispose() + { + _databaseContext.Dispose(); + + _databaseConnection.Close(); + _databaseConnection.Dispose(); + } + + public SqliteConnection DatabaseConnection => _databaseConnection; + public DatabaseContext DatabaseContext => _databaseContext; + } +} diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs new file mode 100644 index 00000000..a8e0562b --- /dev/null +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Mock.Data; +using Xunit; +using Xunit.Abstractions; + +namespace Timeline.Tests +{ + public class MockDefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + public static Avatar Avatar { get; } = new Avatar { Type = "image/test", Data = Encoding.ASCII.GetBytes("test") }; + + public Task GetDefaultAvatar() + { + return Task.FromResult(Avatar); + } + } + + public class UserAvatarServiceTest : IDisposable, IClassFixture + { + private static Avatar MockAvatar { get; } = new Avatar + { + Type = "image/testaaa", + Data = Encoding.ASCII.GetBytes("amock") + }; + + private static Avatar MockAvatar2 { get; } = new Avatar + { + Type = "image/testbbb", + Data = Encoding.ASCII.GetBytes("bmock") + }; + + private readonly MockDefaultUserAvatarProvider _mockDefaultUserAvatarProvider; + + private readonly LoggerFactory _loggerFactory; + private readonly TestDatabase _database; + + private readonly UserAvatarService _service; + + public UserAvatarServiceTest(ITestOutputHelper outputHelper, MockDefaultUserAvatarProvider mockDefaultUserAvatarProvider) + { + _mockDefaultUserAvatarProvider = mockDefaultUserAvatarProvider; + + _loggerFactory = MyTestLoggerFactory.Create(outputHelper); + _database = new TestDatabase(); + + _service = new UserAvatarService(_loggerFactory.CreateLogger(), _database.DatabaseContext, _mockDefaultUserAvatarProvider); + } + + public void Dispose() + { + _loggerFactory.Dispose(); + _database.Dispose(); + } + + [Fact] + public void GetAvatar_ShouldThrow_ArgumentException() + { + // no need to await because arguments are checked syncronizedly. + _service.Invoking(s => s.GetAvatar(null)).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + _service.Invoking(s => s.GetAvatar("")).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("empty", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void GetAvatar_ShouldThrow_UserNotExistException() + { + const string username = "usernotexist"; + _service.Awaiting(s => s.GetAvatar(username)).Should().Throw() + .Where(e => e.Username == username); + } + + [Fact] + public async Task GetAvatar_ShouldReturn_Default() + { + const string username = MockUsers.UserUsername; + (await _service.GetAvatar(username)).Should().BeEquivalentTo(await _mockDefaultUserAvatarProvider.GetDefaultAvatar()); + } + + [Fact] + public async Task GetAvatar_ShouldReturn_Data() + { + const string username = MockUsers.UserUsername; + + { + // create mock data + var context = _database.DatabaseContext; + var user = await context.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync(); + user.Avatar = new UserAvatar + { + Type = MockAvatar.Type, + Data = MockAvatar.Data + }; + await context.SaveChangesAsync(); + } + + (await _service.GetAvatar(username)).Should().BeEquivalentTo(MockAvatar); + } + + [Fact] + public void SetAvatar_ShouldThrow_ArgumentException() + { + // no need to await because arguments are checked syncronizedly. + _service.Invoking(s => s.SetAvatar(null, MockAvatar)).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + _service.Invoking(s => s.SetAvatar("", MockAvatar)).Should().Throw() + .Where(e => e.ParamName == "username" && e.Message.Contains("empty", StringComparison.OrdinalIgnoreCase)); + + _service.Invoking(s => s.SetAvatar("aaa", new Avatar { Type = null, Data = new[] { (byte)0x00 } })).Should().Throw() + .Where(e => e.ParamName == "avatar" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + _service.Invoking(s => s.SetAvatar("aaa", new Avatar { Type = "", Data = new[] { (byte)0x00 } })).Should().Throw() + .Where(e => e.ParamName == "avatar" && e.Message.Contains("empty", StringComparison.OrdinalIgnoreCase)); + + _service.Invoking(s => s.SetAvatar("aaa", new Avatar { Type = "aaa", Data = null })).Should().Throw() + .Where(e => e.ParamName == "avatar" && e.Message.Contains("null", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SetAvatar_ShouldThrow_UserNotExistException() + { + const string username = "usernotexist"; + _service.Awaiting(s => s.SetAvatar(username, MockAvatar)).Should().Throw() + .Where(e => e.Username == username); + } + + [Fact] + public async Task SetAvatar_Should_Work() + { + const string username = MockUsers.UserUsername; + + var user = await _database.DatabaseContext.Users.Where(u => u.Name == username).Include(u => u.Avatar).SingleAsync(); + + // create + await _service.SetAvatar(username, MockAvatar); + user.Avatar.Should().NotBeNull(); + user.Avatar.Type.Should().Be(MockAvatar.Type); + user.Avatar.Data.Should().Equal(MockAvatar.Data); + + // modify + await _service.SetAvatar(username, MockAvatar2); + user.Avatar.Should().NotBeNull(); + user.Avatar.Type.Should().Be(MockAvatar2.Type); + user.Avatar.Data.Should().Equal(MockAvatar2.Data); + + // delete + await _service.SetAvatar(username, null); + user.Avatar.Should().BeNull(); + } + } +} diff --git a/Timeline/Entities/DatabaseContext.cs b/Timeline/Entities/DatabaseContext.cs index 3629e821..f32e5992 100644 --- a/Timeline/Entities/DatabaseContext.cs +++ b/Timeline/Entities/DatabaseContext.cs @@ -27,6 +27,8 @@ namespace Timeline.Entities [Column("version"), Required] public long Version { get; set; } + + public UserAvatar Avatar { get; set; } } public class DatabaseContext : DbContext @@ -44,5 +46,6 @@ namespace Timeline.Entities } public DbSet Users { get; set; } + public DbSet UserAvatars { get; set; } } } diff --git a/Timeline/Entities/UserAvatar.cs b/Timeline/Entities/UserAvatar.cs new file mode 100644 index 00000000..d7c24403 --- /dev/null +++ b/Timeline/Entities/UserAvatar.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Timeline.Entities +{ + [Table("user_avatars")] + public class UserAvatar + { + [Column("id"), Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("data"), Required] + public byte[] Data { get; set; } + + [Column("type"), Required] + public string Type { get; set; } + } +} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs new file mode 100644 index 00000000..21153575 --- /dev/null +++ b/Timeline/Services/UserAvatarService.cs @@ -0,0 +1,181 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services +{ + public class Avatar + { + public string Type { get; set; } + public byte[] Data { get; set; } + } + + /// + /// Thrown when avatar is of bad format. + /// + [Serializable] + public class AvatarDataException : Exception + { + public AvatarDataException(Avatar avatar, string message) : base(message) { Avatar = avatar; } + public AvatarDataException(Avatar avatar, string message, Exception inner) : base(message, inner) { Avatar = avatar; } + protected AvatarDataException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public Avatar Avatar { get; set; } + } + + /// + /// Provider for default user avatar. + /// + /// + /// Mainly for unit tests. + /// + public interface IDefaultUserAvatarProvider + { + /// + /// Get the default avatar. + /// + Task GetDefaultAvatar(); + } + + public interface IUserAvatarService + { + /// + /// Get avatar of a user. If the user has no avatar, a default one is returned. + /// + /// The username of the user to get avatar of. + /// The avatar. + /// Thrown if is null or empty. + /// Thrown if the user does not exist. + Task GetAvatar(string username); + + /// + /// Set avatar for a user. + /// + /// The username of the user to set avatar for. + /// The avatar. Can be null to delete the saved avatar. + /// Throw if is null or empty. + /// Or thrown if is not null but is null or empty or is null. + /// Thrown if the user does not exist. + /// Thrown if avatar is of bad format. + Task SetAvatar(string username, Avatar avatar); + } + + public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + private readonly IHostingEnvironment _environment; + + public DefaultUserAvatarProvider(IHostingEnvironment environment) + { + _environment = environment; + } + + public async Task GetDefaultAvatar() + { + return new Avatar + { + Type = "image/png", + Data = await File.ReadAllBytesAsync(Path.Combine(_environment.ContentRootPath, "default-avatar.png")) + }; + } + } + + public class UserAvatarService : IUserAvatarService + { + + private readonly ILogger _logger; + + private readonly DatabaseContext _database; + + private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + + public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider) + { + _logger = logger; + _database = database; + _defaultUserAvatarProvider = defaultUserAvatarProvider; + } + + public async Task GetAvatar(string username) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + throw new UserNotExistException(username); + + await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); + var avatar = user.Avatar; + + if (avatar == null) + { + return await _defaultUserAvatarProvider.GetDefaultAvatar(); + } + else + { + return new Avatar + { + Type = avatar.Type, + Data = avatar.Data + }; + } + } + + public async Task SetAvatar(string username, Avatar avatar) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + if (avatar != null) + { + if (string.IsNullOrEmpty(avatar.Type)) + throw new ArgumentException("Type of avatar is null or empty.", nameof(avatar)); + if (avatar.Data == null) + throw new ArgumentException("Data of avatar is null.", nameof(avatar)); + } + + var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + if (user == null) + throw new UserNotExistException(username); + + await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); + var avatarEntity = user.Avatar; + + if (avatar == null) + { + if (avatarEntity == null) + return; + else + { + _database.UserAvatars.Remove(avatarEntity); + await _database.SaveChangesAsync(); + } + } + else + { + // TODO: Use image library to check the format to prohibit bad data. + if (avatarEntity == null) + { + user.Avatar = new UserAvatar + { + Type = avatar.Type, + Data = avatar.Data + }; + } + else + { + avatarEntity.Type = avatar.Type; + avatarEntity.Data = avatar.Data; + } + await _database.SaveChangesAsync(); + } + } + } +} diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index 1f70c634..29ff3354 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -5,12 +5,6 @@ 1f6fb74d-4277-4bc0-aeea-b1fc5ffb0b43 crupest - - - - - - diff --git a/Timeline/appsettings.Development.json b/Timeline/appsettings.Development.json index db4b074a..424b3885 100644 --- a/Timeline/appsettings.Development.json +++ b/Timeline/appsettings.Development.json @@ -7,6 +7,6 @@ } }, "JwtConfig": { - "SigningKey": "crupest hahahahahahahhahahahahaha" + "SigningKey": "this is very very very very very long secret" } } diff --git a/Timeline/default-avatar.png b/Timeline/default-avatar.png new file mode 100644 index 00000000..4086e1d2 Binary files /dev/null and b/Timeline/default-avatar.png differ diff --git a/art-src/user.svg b/art-src/user.svg new file mode 100644 index 00000000..acbd3c8a --- /dev/null +++ b/art-src/user.svg @@ -0,0 +1,4 @@ + + + + -- cgit v1.2.3 From 647006822f01a53dade5ea040210059a98a43196 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 18 Aug 2019 19:15:44 +0800 Subject: Add avatar controller. --- Timeline/Controllers/UserAvatarController.cs | 70 ++++++++++++++++++++++++++++ Timeline/Services/UserAvatarService.cs | 10 ++++ Timeline/Startup.cs | 2 + 3 files changed, 82 insertions(+) create mode 100644 Timeline/Controllers/UserAvatarController.cs diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs new file mode 100644 index 00000000..f61fd54a --- /dev/null +++ b/Timeline/Controllers/UserAvatarController.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Models.Http; +using Timeline.Services; + +namespace Timeline.Controllers +{ + [ApiController] + public class UserAvatarController : Controller + { + public static class ErrorCodes + { + public const int Get_UserNotExist = -1001; + + public const int Put_UserNotExist = -2001; + } + + private readonly ILogger _logger; + + private readonly IUserAvatarService _service; + + public UserAvatarController(ILogger logger, IUserAvatarService service) + { + _logger = logger; + _service = service; + } + + [HttpGet("users/{username}/avatar")] + public async Task Get(string username) + { + try + { + var avatar = await _service.GetAvatar(username); + return File(avatar.Data, avatar.Type); + } + catch (UserNotExistException) + { + _logger.LogInformation($"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); + return NotFound(new CommonResponse(ErrorCodes.Get_UserNotExist, "User does not exist.")); + } + } + + [HttpPut("users/{username}/avatar")] + [Consumes("image/png", "image/jpeg", "image/gif", "image/webp")] + public async Task Put(string username) + { + try + { + var data = new byte[Convert.ToInt32(Request.ContentLength)]; + await Request.Body.ReadAsync(data, 0, data.Length); + + await _service.SetAvatar(username, new Avatar + { + Data = data, + Type = Request.ContentType + }); + + _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} . Mime Type: {Request.ContentType} ."); + return Ok(); + } + catch (UserNotExistException) + { + _logger.LogInformation($"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); + return BadRequest(new CommonResponse(ErrorCodes.Put_UserNotExist, "User does not exist.")); + } + } + } +} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 21153575..4f11978c 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; using System.IO; @@ -178,4 +179,13 @@ namespace Timeline.Services } } } + + public static class UserAvatarServiceCollectionExtensions + { + public static void AddUserAvatarService(this IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + } + } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 414bc705..afc06d9b 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -58,6 +58,8 @@ namespace Timeline services.AddTransient(); services.AddTransient(); + services.AddUserAvatarService(); + var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get(); services.AddDbContext(options => -- cgit v1.2.3 From bc832053bdad644a0a86ded0173f3f47c0159018 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 18 Aug 2019 23:08:05 +0800 Subject: Develop user avatar controller. --- Timeline/Authenticate/PrincipalExtensions.cs | 13 ++++++++ Timeline/Controllers/UserAvatarController.cs | 45 ++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 Timeline/Authenticate/PrincipalExtensions.cs diff --git a/Timeline/Authenticate/PrincipalExtensions.cs b/Timeline/Authenticate/PrincipalExtensions.cs new file mode 100644 index 00000000..fa39ea89 --- /dev/null +++ b/Timeline/Authenticate/PrincipalExtensions.cs @@ -0,0 +1,13 @@ +using System.Security.Principal; +using Timeline.Entities; + +namespace Timeline.Authenticate +{ + public static class PrincipalExtensions + { + public static bool IsAdmin(this IPrincipal principal) + { + return principal.IsInRole(UserRoles.Admin); + } + } +} diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index f61fd54a..6dc767df 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -1,7 +1,10 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Timeline.Authenticate; using Timeline.Models.Http; using Timeline.Services; @@ -15,6 +18,10 @@ namespace Timeline.Controllers public const int Get_UserNotExist = -1001; public const int Put_UserNotExist = -2001; + public const int Put_Forbid = -2002; + + public const int Delete_UserNotExist = -3001; + public const int Delete_Forbid = -3002; } private readonly ILogger _logger; @@ -28,6 +35,7 @@ namespace Timeline.Controllers } [HttpGet("users/{username}/avatar")] + [Authorize] public async Task Get(string username) { try @@ -43,9 +51,17 @@ namespace Timeline.Controllers } [HttpPut("users/{username}/avatar")] + [Authorize] [Consumes("image/png", "image/jpeg", "image/gif", "image/webp")] public async Task Put(string username) { + if (!User.IsAdmin() && User.Identity.Name != username) + { + _logger.LogInformation($"Attempt to put a avatar of other user as a non-admin failed. Operator Username: {User.Identity.Name} ; Username To Put Avatar: {username} ."); + return StatusCode(StatusCodes.Status403Forbidden, + new CommonResponse(ErrorCodes.Put_Forbid, "Normal user can't change other's avatar.")); + } + try { var data = new byte[Convert.ToInt32(Request.ContentLength)]; @@ -57,7 +73,7 @@ namespace Timeline.Controllers Type = Request.ContentType }); - _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} . Mime Type: {Request.ContentType} ."); + _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} ; Mime Type: {Request.ContentType} ."); return Ok(); } catch (UserNotExistException) @@ -66,5 +82,30 @@ namespace Timeline.Controllers return BadRequest(new CommonResponse(ErrorCodes.Put_UserNotExist, "User does not exist.")); } } + + [HttpDelete("users/{username}/avatar")] + [Authorize] + public async Task Delete(string username) + { + if (!User.IsAdmin() && User.Identity.Name != username) + { + _logger.LogInformation($"Attempt to delete a avatar of other user as a non-admin failed. Operator Username: {User.Identity.Name} ; Username To Put Avatar: {username} ."); + return StatusCode(StatusCodes.Status403Forbidden, + new CommonResponse(ErrorCodes.Delete_Forbid, "Normal user can't delete other's avatar.")); + } + + try + { + await _service.SetAvatar(username, null); + + _logger.LogInformation($"Succeed to delete a avatar of a user. Username: {username} ."); + return Ok(); + } + catch (UserNotExistException) + { + _logger.LogInformation($"Attempt to delete a avatar of a non-existent user failed. Username: {username} ."); + return BadRequest(new CommonResponse(ErrorCodes.Delete_UserNotExist, "User does not exist.")); + } + } } } -- cgit v1.2.3 From e5f5be69f854565d4f58d996cbf4347fa0eae0ff Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 00:01:09 +0800 Subject: Add validator. --- Timeline.Tests/UserAvatarServiceTest.cs | 14 ++++++++--- Timeline/Services/UserAvatarService.cs | 44 +++++++++++++++++++++++++++++++-- Timeline/Timeline.csproj | 1 + nuget.config | 7 +++--- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs index a8e0562b..d767958a 100644 --- a/Timeline.Tests/UserAvatarServiceTest.cs +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -24,7 +24,15 @@ namespace Timeline.Tests } } - public class UserAvatarServiceTest : IDisposable, IClassFixture + public class MockUserAvatarValidator : IUserAvatarValidator + { + public Task<(bool, string)> Validate(Avatar avatar) + { + return Task.FromResult((true, "Validate succeed.")); + } + } + + public class UserAvatarServiceTest : IDisposable, IClassFixture, IClassFixture { private static Avatar MockAvatar { get; } = new Avatar { @@ -45,14 +53,14 @@ namespace Timeline.Tests private readonly UserAvatarService _service; - public UserAvatarServiceTest(ITestOutputHelper outputHelper, MockDefaultUserAvatarProvider mockDefaultUserAvatarProvider) + public UserAvatarServiceTest(ITestOutputHelper outputHelper, MockDefaultUserAvatarProvider mockDefaultUserAvatarProvider, MockUserAvatarValidator mockUserAvatarValidator) { _mockDefaultUserAvatarProvider = mockDefaultUserAvatarProvider; _loggerFactory = MyTestLoggerFactory.Create(outputHelper); _database = new TestDatabase(); - _service = new UserAvatarService(_loggerFactory.CreateLogger(), _database.DatabaseContext, _mockDefaultUserAvatarProvider); + _service = new UserAvatarService(_loggerFactory.CreateLogger(), _database.DatabaseContext, _mockDefaultUserAvatarProvider, mockUserAvatarValidator); } public void Dispose() diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 4f11978c..2a73cde5 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; using System; using System.IO; using System.Linq; @@ -45,6 +47,11 @@ namespace Timeline.Services Task GetDefaultAvatar(); } + public interface IUserAvatarValidator + { + Task<(bool valid, string message)> Validate(Avatar avatar); + } + public interface IUserAvatarService { /// @@ -87,6 +94,31 @@ namespace Timeline.Services } } + public class UserAvatarValidator : IUserAvatarValidator + { + public Task<(bool valid, string message)> Validate(Avatar avatar) + { + return Task.Run(() => + { + try + { + using (var image = Image.Load(avatar.Data, out IImageFormat format)) + { + if (!format.MimeTypes.Contains(avatar.Type)) + return (false, "Image's actual mime type is not the specified one."); + if (image.Width != image.Height) + return (false, "Image is not a square, aka width is not equal to height."); + } + return (true, "A good avatar."); + } + catch (UnknownImageFormatException e) + { + return (false, $"Failed to decode image. Exception: {e} ."); + } + }); + } + } + public class UserAvatarService : IUserAvatarService { @@ -95,12 +127,14 @@ namespace Timeline.Services private readonly DatabaseContext _database; private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + private readonly IUserAvatarValidator _avatarValidator; - public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider) + public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider, IUserAvatarValidator avatarValidator) { _logger = logger; _database = database; _defaultUserAvatarProvider = defaultUserAvatarProvider; + _avatarValidator = avatarValidator; } public async Task GetAvatar(string username) @@ -157,11 +191,15 @@ namespace Timeline.Services { _database.UserAvatars.Remove(avatarEntity); await _database.SaveChangesAsync(); + _logger.LogInformation("Removed an entry in user_avatars."); } } else { - // TODO: Use image library to check the format to prohibit bad data. + (bool valid, string message) = await _avatarValidator.Validate(avatar); + if (!valid) + throw new AvatarDataException(avatar, $"Failed to validate image. {message}"); + if (avatarEntity == null) { user.Avatar = new UserAvatar @@ -176,6 +214,7 @@ namespace Timeline.Services avatarEntity.Data = avatar.Data; } await _database.SaveChangesAsync(); + _logger.LogInformation("Added or modified an entry in user_avatars."); } } } @@ -186,6 +225,7 @@ namespace Timeline.Services { services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/Timeline/Timeline.csproj b/Timeline/Timeline.csproj index 29ff3354..3855e0d1 100644 --- a/Timeline/Timeline.csproj +++ b/Timeline/Timeline.csproj @@ -12,5 +12,6 @@ + diff --git a/nuget.config b/nuget.config index 22519a86..e1fc7cfe 100644 --- a/nuget.config +++ b/nuget.config @@ -1,8 +1,9 @@ - + - - + + + -- cgit v1.2.3 From e946c2e546efae29112628c0e6d42dc145605f09 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 00:21:38 +0800 Subject: Improve avatar validation. --- Timeline/Controllers/UserAvatarController.cs | 36 ++++++++++++++++++++---- Timeline/Services/UserAvatarService.cs | 41 ++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 6dc767df..710ca764 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -19,9 +19,28 @@ namespace Timeline.Controllers public const int Put_UserNotExist = -2001; public const int Put_Forbid = -2002; + public const int Put_BadFormat_CantDecode = -2011; + public const int Put_BadFormat_UnmatchedFormat = -2012; + public const int Put_BadFormat_BadSize = -2013; public const int Delete_UserNotExist = -3001; public const int Delete_Forbid = -3002; + + + public static int From(AvatarDataException.ErrorReason error) + { + switch (error) + { + case AvatarDataException.ErrorReason.CantDecode: + return Put_BadFormat_CantDecode; + case AvatarDataException.ErrorReason.UnmatchedFormat: + return Put_BadFormat_UnmatchedFormat; + case AvatarDataException.ErrorReason.BadSize: + return Put_BadFormat_BadSize; + default: + throw new Exception("Unknown AvatarDataException.ErrorReason value."); + } + } } private readonly ILogger _logger; @@ -43,9 +62,9 @@ namespace Timeline.Controllers var avatar = await _service.GetAvatar(username); return File(avatar.Data, avatar.Type); } - catch (UserNotExistException) + catch (UserNotExistException e) { - _logger.LogInformation($"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); + _logger.LogInformation(e, $"Attempt to get a avatar of a non-existent user failed. Username: {username} ."); return NotFound(new CommonResponse(ErrorCodes.Get_UserNotExist, "User does not exist.")); } } @@ -76,11 +95,16 @@ namespace Timeline.Controllers _logger.LogInformation($"Succeed to put a avatar of a user. Username: {username} ; Mime Type: {Request.ContentType} ."); return Ok(); } - catch (UserNotExistException) + catch (UserNotExistException e) { - _logger.LogInformation($"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); + _logger.LogInformation(e, $"Attempt to put a avatar of a non-existent user failed. Username: {username} ."); return BadRequest(new CommonResponse(ErrorCodes.Put_UserNotExist, "User does not exist.")); } + catch (AvatarDataException e) + { + _logger.LogInformation(e, $"Attempt to put a avatar of a bad format failed. Username: {username} ."); + return BadRequest(new CommonResponse(ErrorCodes.From(e.Error), "Bad format.")); + } } [HttpDelete("users/{username}/avatar")] @@ -101,9 +125,9 @@ namespace Timeline.Controllers _logger.LogInformation($"Succeed to delete a avatar of a user. Username: {username} ."); return Ok(); } - catch (UserNotExistException) + catch (UserNotExistException e) { - _logger.LogInformation($"Attempt to delete a avatar of a non-existent user failed. Username: {username} ."); + _logger.LogInformation(e, $"Attempt to delete a avatar of a non-existent user failed. Username: {username} ."); return BadRequest(new CommonResponse(ErrorCodes.Delete_UserNotExist, "User does not exist.")); } } diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 2a73cde5..dd0e5e7c 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -24,12 +24,29 @@ namespace Timeline.Services [Serializable] public class AvatarDataException : Exception { - public AvatarDataException(Avatar avatar, string message) : base(message) { Avatar = avatar; } - public AvatarDataException(Avatar avatar, string message, Exception inner) : base(message, inner) { Avatar = avatar; } + public enum ErrorReason + { + /// + /// Decoding image failed. + /// + CantDecode, + /// + /// Decoding succeeded but the real type is not the specified type. + /// + UnmatchedFormat, + /// + /// Image is not a square. + /// + BadSize + } + + public AvatarDataException(Avatar avatar, ErrorReason error, string message) : base(message) { Avatar = avatar; Error = error; } + public AvatarDataException(Avatar avatar, ErrorReason error, string message, Exception inner) : base(message, inner) { Avatar = avatar; Error = error; } protected AvatarDataException( System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + public ErrorReason Error { get; set; } public Avatar Avatar { get; set; } } @@ -49,7 +66,12 @@ namespace Timeline.Services public interface IUserAvatarValidator { - Task<(bool valid, string message)> Validate(Avatar avatar); + /// + /// Validate a avatar's format and size info. + /// + /// The avatar to validate. + /// Thrown when validation failed. + Task Validate(Avatar avatar); } public interface IUserAvatarService @@ -96,7 +118,7 @@ namespace Timeline.Services public class UserAvatarValidator : IUserAvatarValidator { - public Task<(bool valid, string message)> Validate(Avatar avatar) + public Task Validate(Avatar avatar) { return Task.Run(() => { @@ -105,15 +127,14 @@ namespace Timeline.Services using (var image = Image.Load(avatar.Data, out IImageFormat format)) { if (!format.MimeTypes.Contains(avatar.Type)) - return (false, "Image's actual mime type is not the specified one."); + throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.UnmatchedFormat, "Image's actual mime type is not the specified one."); if (image.Width != image.Height) - return (false, "Image is not a square, aka width is not equal to height."); + throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.BadSize, "Image is not a square, aka, width is not equal to height."); } - return (true, "A good avatar."); } catch (UnknownImageFormatException e) { - return (false, $"Failed to decode image. Exception: {e} ."); + throw new AvatarDataException(avatar, AvatarDataException.ErrorReason.CantDecode, "Failed to decode image. See inner exception.", e); } }); } @@ -196,9 +217,7 @@ namespace Timeline.Services } else { - (bool valid, string message) = await _avatarValidator.Validate(avatar); - if (!valid) - throw new AvatarDataException(avatar, $"Failed to validate image. {message}"); + await _avatarValidator.Validate(avatar); if (avatarEntity == null) { -- cgit v1.2.3 From 7b04ec2e8370813c6bddfd0b3db811a729ca58a2 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 00:25:42 +0800 Subject: Fix unit tests. --- Timeline.Tests/UserAvatarServiceTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs index d767958a..fbec8228 100644 --- a/Timeline.Tests/UserAvatarServiceTest.cs +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -26,9 +26,9 @@ namespace Timeline.Tests public class MockUserAvatarValidator : IUserAvatarValidator { - public Task<(bool, string)> Validate(Avatar avatar) + public Task Validate(Avatar avatar) { - return Task.FromResult((true, "Validate succeed.")); + return Task.CompletedTask; } } -- cgit v1.2.3 From 2c50b2bebc15a2ae48fb124fcf090d32c043d62b Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 00:41:47 +0800 Subject: Add unit tests for avatar validator. --- Timeline.Tests/IntegratedTests/UserAvatarTests.cs | 11 +++ Timeline.Tests/UserAvatarServiceTest.cs | 89 +++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Timeline.Tests/IntegratedTests/UserAvatarTests.cs diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTests.cs b/Timeline.Tests/IntegratedTests/UserAvatarTests.cs new file mode 100644 index 00000000..14781474 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/UserAvatarTests.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Timeline.Tests.IntegratedTests +{ + public class UserAvatarTests + { + } +} diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs index fbec8228..04f52c71 100644 --- a/Timeline.Tests/UserAvatarServiceTest.cs +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -1,7 +1,11 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Formats.Png; using System; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -32,6 +36,91 @@ namespace Timeline.Tests } } + public class UserAvatarValidatorTest : IClassFixture + { + private readonly UserAvatarValidator _validator; + + public UserAvatarValidatorTest(UserAvatarValidator validator) + { + _validator = validator; + } + + [Fact] + public void CantDecode() + { + Avatar avatar = new Avatar + { + Data = Encoding.ASCII.GetBytes("This is not a image."), + Type = "image/png" + }; + _validator.Awaiting(v => v.Validate(avatar)) + .Should().Throw() + .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.CantDecode); + } + + [Fact] + public void UnmatchedFormat() + { + Avatar avatar; + using (var image = new Image(100, 100)) + { + using (var stream = new MemoryStream()) + { + image.SaveAsPng(stream); + avatar = new Avatar + { + Data = stream.ToArray(), + Type = "image/jpeg" + }; + } + } + _validator.Awaiting(v => v.Validate(avatar)) + .Should().Throw() + .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.UnmatchedFormat); + } + + [Fact] + public void BadSize() + { + Avatar avatar; + using (var image = new Image(100, 200)) + { + using (var stream = new MemoryStream()) + { + image.SaveAsPng(stream); + avatar = new Avatar + { + Data = stream.ToArray(), + Type = PngFormat.Instance.DefaultMimeType + }; + } + } + _validator.Awaiting(v => v.Validate(avatar)) + .Should().Throw() + .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.BadSize); + } + + [Fact] + public void Success() + { + Avatar avatar; + using (var image = new Image(100, 100)) + { + using (var stream = new MemoryStream()) + { + image.SaveAsPng(stream); + avatar = new Avatar + { + Data = stream.ToArray(), + Type = PngFormat.Instance.DefaultMimeType + }; + } + } + _validator.Awaiting(v => v.Validate(avatar)) + .Should().NotThrow(); + } + } + public class UserAvatarServiceTest : IDisposable, IClassFixture, IClassFixture { private static Avatar MockAvatar { get; } = new Avatar -- cgit v1.2.3 From 09af59ea133d01bbbeba76a448563189fa39e440 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 01:44:29 +0800 Subject: Add integrated tests. --- Timeline.Tests/Helpers/HttpClientExtensions.cs | 9 ++ Timeline.Tests/Helpers/ImageHelper.cs | 21 +++ Timeline.Tests/IntegratedTests/UserAvatarTests.cs | 153 +++++++++++++++++++++- Timeline.Tests/UserAvatarServiceTest.cs | 48 ++----- 4 files changed, 190 insertions(+), 41 deletions(-) create mode 100644 Timeline.Tests/Helpers/ImageHelper.cs diff --git a/Timeline.Tests/Helpers/HttpClientExtensions.cs b/Timeline.Tests/Helpers/HttpClientExtensions.cs index cd40d91e..b9204fcc 100644 --- a/Timeline.Tests/Helpers/HttpClientExtensions.cs +++ b/Timeline.Tests/Helpers/HttpClientExtensions.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; @@ -11,5 +12,13 @@ namespace Timeline.Tests.Helpers { return client.PatchAsync(url, new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json")); } + + public static Task PutByteArrayAsync(this HttpClient client, string url, byte[] body, string mimeType) + { + var content = new ByteArrayContent(body); + content.Headers.ContentLength = body.Length; + content.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + return client.PutAsync(url, content); + } } } diff --git a/Timeline.Tests/Helpers/ImageHelper.cs b/Timeline.Tests/Helpers/ImageHelper.cs new file mode 100644 index 00000000..c5a9cf17 --- /dev/null +++ b/Timeline.Tests/Helpers/ImageHelper.cs @@ -0,0 +1,21 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using System.IO; + +namespace Timeline.Tests.Helpers +{ + public static class ImageHelper + { + public static byte[] CreatePngWithSize(int width, int height) + { + using (var image = new Image(width, height)) + { + using (var stream = new MemoryStream()) + { + image.SaveAsPng(stream); + return stream.ToArray(); + } + } + } + } +} diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTests.cs b/Timeline.Tests/IntegratedTests/UserAvatarTests.cs index 14781474..e282da1b 100644 --- a/Timeline.Tests/IntegratedTests/UserAvatarTests.cs +++ b/Timeline.Tests/IntegratedTests/UserAvatarTests.cs @@ -1,11 +1,154 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SixLabors.ImageSharp.Formats.Png; +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Xunit; +using Xunit.Abstractions; namespace Timeline.Tests.IntegratedTests { - public class UserAvatarTests + public class UserAvatarUnitTest : IClassFixture>, IDisposable { + private readonly WebApplicationFactory _factory; + private readonly Action _disposeAction; + + public UserAvatarUnitTest(MyWebApplicationFactory factory, ITestOutputHelper outputHelper) + { + _factory = factory.WithTestConfig(outputHelper, out _disposeAction); + } + + public void Dispose() + { + _disposeAction(); + } + + [Fact] + public async Task Test() + { + Avatar mockAvatar = new Avatar + { + Data = ImageHelper.CreatePngWithSize(100, 100), + Type = PngFormat.Instance.DefaultMimeType + }; + + using (var client = await _factory.CreateClientAsUser()) + { + { + var res = await client.GetAsync("users/usernotexist/avatar"); + res.Should().HaveStatusCodeNotFound() + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Get_UserNotExist); + } + + var env = _factory.Server.Host.Services.GetRequiredService(); + var defaultAvatarData = await File.ReadAllBytesAsync(Path.Combine(env.ContentRootPath, "default-avatar.png")); + + async Task GetReturnDefault(string username = "user") + { + var res = await client.GetAsync($"users/{username}/avatar"); + res.Should().HaveStatusCodeOk(); + res.Content.Headers.ContentType.MediaType.Should().Be("image/png"); + var body = await res.Content.ReadAsByteArrayAsync(); + body.Should().Equal(defaultAvatarData); + } + + await GetReturnDefault(); + await GetReturnDefault("admin"); + + { + var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/notaccept"); + res.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/notaccept"); + res.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", new[] { (byte)0x00 }, "image/png"); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Put_BadFormat_CantDecode); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, "image/jpeg"); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Put_BadFormat_UnmatchedFormat); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", ImageHelper.CreatePngWithSize(100, 200), "image/png"); + res.Should().HaveStatusCode(HttpStatusCode.BadRequest) + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Put_BadFormat_BadSize); + } + + { + var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type); + res.Should().HaveStatusCode(HttpStatusCode.OK); + + var res2 = await client.GetAsync("users/user/avatar"); + res2.Should().HaveStatusCodeOk(); + res2.Content.Headers.ContentType.MediaType.Should().Be(mockAvatar.Type); + var body = await res2.Content.ReadAsByteArrayAsync(); + body.Should().Equal(mockAvatar.Data); + } + + { + var res = await client.PutByteArrayAsync("users/admin/avatar", new[] { (byte)0x00 }, "image/png"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden) + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Put_Forbid); + } + + { + var res = await client.DeleteAsync("users/admin/avatar"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden) + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Delete_Forbid); + } + + for (int i = 0; i < 2; i++) // double delete should work. + { + var res = await client.DeleteAsync("users/user/avatar"); + res.Should().HaveStatusCodeOk(); + await GetReturnDefault(); + } + } + + // Authorization check. + using (var client = await _factory.CreateClientAsAdmin()) + { + { + var res = await client.PutByteArrayAsync("users/user/avatar", mockAvatar.Data, mockAvatar.Type); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + + { + var res = await client.DeleteAsync("users/user/avatar"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + + { + var res = await client.PutByteArrayAsync("users/usernotexist/avatar", new[] { (byte)0x00 }, "image/png"); + res.Should().HaveStatusCodeBadRequest() + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Put_UserNotExist); + } + + { + var res = await client.DeleteAsync("users/usernotexist/avatar"); + res.Should().HaveStatusCodeBadRequest() + .And.Should().HaveBodyAsCommonResponseWithCode(UserAvatarController.ErrorCodes.Delete_UserNotExist); + } + } + } } -} +} \ No newline at end of file diff --git a/Timeline.Tests/UserAvatarServiceTest.cs b/Timeline.Tests/UserAvatarServiceTest.cs index 04f52c71..69d6b61c 100644 --- a/Timeline.Tests/UserAvatarServiceTest.cs +++ b/Timeline.Tests/UserAvatarServiceTest.cs @@ -61,19 +61,11 @@ namespace Timeline.Tests [Fact] public void UnmatchedFormat() { - Avatar avatar; - using (var image = new Image(100, 100)) + Avatar avatar = new Avatar { - using (var stream = new MemoryStream()) - { - image.SaveAsPng(stream); - avatar = new Avatar - { - Data = stream.ToArray(), - Type = "image/jpeg" - }; - } - } + Data = ImageHelper.CreatePngWithSize(100, 100), + Type = "image/jpeg" + }; _validator.Awaiting(v => v.Validate(avatar)) .Should().Throw() .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.UnmatchedFormat); @@ -82,19 +74,11 @@ namespace Timeline.Tests [Fact] public void BadSize() { - Avatar avatar; - using (var image = new Image(100, 200)) + Avatar avatar = new Avatar { - using (var stream = new MemoryStream()) - { - image.SaveAsPng(stream); - avatar = new Avatar - { - Data = stream.ToArray(), - Type = PngFormat.Instance.DefaultMimeType - }; - } - } + Data = ImageHelper.CreatePngWithSize(100, 200), + Type = PngFormat.Instance.DefaultMimeType + }; _validator.Awaiting(v => v.Validate(avatar)) .Should().Throw() .Where(e => e.Avatar == avatar && e.Error == AvatarDataException.ErrorReason.BadSize); @@ -103,19 +87,11 @@ namespace Timeline.Tests [Fact] public void Success() { - Avatar avatar; - using (var image = new Image(100, 100)) + Avatar avatar = new Avatar { - using (var stream = new MemoryStream()) - { - image.SaveAsPng(stream); - avatar = new Avatar - { - Data = stream.ToArray(), - Type = PngFormat.Instance.DefaultMimeType - }; - } - } + Data = ImageHelper.CreatePngWithSize(100, 100), + Type = PngFormat.Instance.DefaultMimeType + }; _validator.Awaiting(v => v.Validate(avatar)) .Should().NotThrow(); } -- cgit v1.2.3 From c7980afb3098acf4ea13cd4972d37e925248bae4 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 19 Aug 2019 01:46:48 +0800 Subject: Migrate database. --- .../20190818174505_AddUserAvatar.Designer.cs | 83 ++++++++++++++++++++++ .../Migrations/20190818174505_AddUserAvatar.cs | 62 ++++++++++++++++ .../Migrations/DatabaseContextModelSnapshot.cs | 31 ++++++++ 3 files changed, 176 insertions(+) create mode 100644 Timeline/Migrations/20190818174505_AddUserAvatar.Designer.cs create mode 100644 Timeline/Migrations/20190818174505_AddUserAvatar.cs diff --git a/Timeline/Migrations/20190818174505_AddUserAvatar.Designer.cs b/Timeline/Migrations/20190818174505_AddUserAvatar.Designer.cs new file mode 100644 index 00000000..b0a105d0 --- /dev/null +++ b/Timeline/Migrations/20190818174505_AddUserAvatar.Designer.cs @@ -0,0 +1,83 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Timeline.Entities; + +namespace Timeline.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20190818174505_AddUserAvatar")] + partial class AddUserAvatar + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Timeline.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("AvatarId"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnName("password"); + + b.Property("Name") + .IsRequired() + .HasColumnName("name") + .HasMaxLength(26); + + b.Property("RoleString") + .IsRequired() + .HasColumnName("roles"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnName("version") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex("AvatarId"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("Timeline.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Data") + .IsRequired() + .HasColumnName("data"); + + b.Property("Type") + .IsRequired() + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("user_avatars"); + }); + + modelBuilder.Entity("Timeline.Entities.User", b => + { + b.HasOne("Timeline.Entities.UserAvatar", "Avatar") + .WithMany() + .HasForeignKey("AvatarId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Timeline/Migrations/20190818174505_AddUserAvatar.cs b/Timeline/Migrations/20190818174505_AddUserAvatar.cs new file mode 100644 index 00000000..7e0843c4 --- /dev/null +++ b/Timeline/Migrations/20190818174505_AddUserAvatar.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Timeline.Migrations +{ + public partial class AddUserAvatar : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvatarId", + table: "users", + nullable: true); + + migrationBuilder.CreateTable( + name: "user_avatars", + columns: table => new + { + id = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + data = table.Column(nullable: false), + type = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_avatars", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_users_AvatarId", + table: "users", + column: "AvatarId"); + + migrationBuilder.AddForeignKey( + name: "FK_users_user_avatars_AvatarId", + table: "users", + column: "AvatarId", + principalTable: "user_avatars", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_users_user_avatars_AvatarId", + table: "users"); + + migrationBuilder.DropTable( + name: "user_avatars"); + + migrationBuilder.DropIndex( + name: "IX_users_AvatarId", + table: "users"); + + migrationBuilder.DropColumn( + name: "AvatarId", + table: "users"); + } + } +} diff --git a/Timeline/Migrations/DatabaseContextModelSnapshot.cs b/Timeline/Migrations/DatabaseContextModelSnapshot.cs index e1de451c..152bdea4 100644 --- a/Timeline/Migrations/DatabaseContextModelSnapshot.cs +++ b/Timeline/Migrations/DatabaseContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -22,6 +23,8 @@ namespace Timeline.Migrations .ValueGeneratedOnAdd() .HasColumnName("id"); + b.Property("AvatarId"); + b.Property("EncryptedPassword") .IsRequired() .HasColumnName("password"); @@ -42,8 +45,36 @@ namespace Timeline.Migrations b.HasKey("Id"); + b.HasIndex("AvatarId"); + b.ToTable("users"); }); + + modelBuilder.Entity("Timeline.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Data") + .IsRequired() + .HasColumnName("data"); + + b.Property("Type") + .IsRequired() + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("user_avatars"); + }); + + modelBuilder.Entity("Timeline.Entities.User", b => + { + b.HasOne("Timeline.Entities.UserAvatar", "Avatar") + .WithMany() + .HasForeignKey("AvatarId"); + }); #pragma warning restore 612, 618 } } -- cgit v1.2.3