From 294807481dd42dc3c660e29630b36462e7bcfaaf Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Sun, 27 Oct 2019 22:52:21 +0800 Subject: Add error code tests. --- Timeline/Filters/ContentHeaderAttributes.cs | 52 --------------- Timeline/Filters/Header.cs | 99 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 52 deletions(-) delete mode 100644 Timeline/Filters/ContentHeaderAttributes.cs create mode 100644 Timeline/Filters/Header.cs (limited to 'Timeline/Filters') diff --git a/Timeline/Filters/ContentHeaderAttributes.cs b/Timeline/Filters/ContentHeaderAttributes.cs deleted file mode 100644 index 99bd1540..00000000 --- a/Timeline/Filters/ContentHeaderAttributes.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Localization; -using Timeline.Models.Http; - -namespace Timeline.Filters -{ - public class RequireContentTypeAttribute : ActionFilterAttribute - { - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] - public override void OnActionExecuting(ActionExecutingContext context) - { - if (context.HttpContext.Request.ContentType == null) - { - context.Result = new BadRequestObjectResult(HeaderErrorResponse.MissingContentType()); - } - } - } - - public class RequireContentLengthAttribute : ActionFilterAttribute - { - public RequireContentLengthAttribute() - : this(true) - { - - } - - public RequireContentLengthAttribute(bool requireNonZero) - { - RequireNonZero = requireNonZero; - } - - public bool RequireNonZero { get; set; } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] - public override void OnActionExecuting(ActionExecutingContext context) - { - if (context.HttpContext.Request.ContentLength == null) - { - context.Result = new BadRequestObjectResult(HeaderErrorResponse.MissingContentLength()); - return; - } - - if (RequireNonZero && context.HttpContext.Request.ContentLength.Value == 0) - { - context.Result = new BadRequestObjectResult(HeaderErrorResponse.ZeroContentLength()); - return; - } - } - } -} diff --git a/Timeline/Filters/Header.cs b/Timeline/Filters/Header.cs new file mode 100644 index 00000000..f5fb16aa --- /dev/null +++ b/Timeline/Filters/Header.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Timeline.Models.Http; +using static Timeline.Resources.Filters; + +namespace Timeline +{ + public static partial class ErrorCodes + { + public static partial class Http + { + public static partial class Filter // bxx = 1xx + { + public static partial class Header // bbb = 100 + { + public static class ContentType // cc = 01 + { + public const int Missing = 11000101; // dd = 01 + } + + public static class ContentLength // cc = 02 + { + public const int Missing = 11000201; // dd = 01 + public const int Zero = 11000202; // dd = 02 + } + } + } + + } + } +} + +namespace Timeline.Filters +{ + public class RequireContentTypeAttribute : ActionFilterAttribute + { + internal static CommonResponse CreateResponse() + { + return new CommonResponse( + ErrorCodes.Http.Filter.Header.ContentType.Missing, + MessageHeaderContentTypeMissing); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] + public override void OnActionExecuting(ActionExecutingContext context) + { + if (context.HttpContext.Request.ContentType == null) + { + context.Result = new BadRequestObjectResult(CreateResponse()); + } + } + } + + public class RequireContentLengthAttribute : ActionFilterAttribute + { + internal static CommonResponse CreateMissingResponse() + { + return new CommonResponse( + ErrorCodes.Http.Filter.Header.ContentLength.Missing, + MessageHeaderContentLengthMissing); + } + + internal static CommonResponse CreateZeroResponse() + { + return new CommonResponse( + ErrorCodes.Http.Filter.Header.ContentLength.Zero, + MessageHeaderContentLengthZero); + } + + public RequireContentLengthAttribute() + : this(true) + { + + } + + public RequireContentLengthAttribute(bool requireNonZero) + { + RequireNonZero = requireNonZero; + } + + public bool RequireNonZero { get; set; } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] + public override void OnActionExecuting(ActionExecutingContext context) + { + if (context.HttpContext.Request.ContentLength == null) + { + context.Result = new BadRequestObjectResult(CreateMissingResponse()); + return; + } + + if (RequireNonZero && context.HttpContext.Request.ContentLength.Value == 0) + { + context.Result = new BadRequestObjectResult(CreateZeroResponse()); + return; + } + } + } +} -- cgit v1.2.3 From fbaa8cab95a91b887bbd2d108d27c5abb38e4e29 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Mon, 28 Oct 2019 23:35:00 +0800 Subject: Add UserDetailController unit tests. --- .../Controllers/UserDetailControllerTest.cs | 93 ++++++++++++++ .../Helpers/AssertionResponseExtensions.cs | 141 --------------------- Timeline.Tests/Helpers/ParameterInfoAssertions.cs | 63 +++++++++ Timeline.Tests/Helpers/ReflectionHelper.cs | 13 ++ Timeline.Tests/Helpers/ResponseAssertions.cs | 141 +++++++++++++++++++++ Timeline/Controllers/UserDetailController.cs | 44 +++++++ Timeline/Filters/User.cs | 42 ++++++ Timeline/Resources/Filters.Designer.cs | 9 ++ Timeline/Resources/Filters.resx | 3 + Timeline/Resources/Filters.zh.resx | 3 + 10 files changed, 411 insertions(+), 141 deletions(-) create mode 100644 Timeline.Tests/Controllers/UserDetailControllerTest.cs delete mode 100644 Timeline.Tests/Helpers/AssertionResponseExtensions.cs create mode 100644 Timeline.Tests/Helpers/ParameterInfoAssertions.cs create mode 100644 Timeline.Tests/Helpers/ReflectionHelper.cs create mode 100644 Timeline.Tests/Helpers/ResponseAssertions.cs create mode 100644 Timeline/Controllers/UserDetailController.cs create mode 100644 Timeline/Filters/User.cs (limited to 'Timeline/Filters') diff --git a/Timeline.Tests/Controllers/UserDetailControllerTest.cs b/Timeline.Tests/Controllers/UserDetailControllerTest.cs new file mode 100644 index 00000000..99341c40 --- /dev/null +++ b/Timeline.Tests/Controllers/UserDetailControllerTest.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Filters; +using Timeline.Models.Validation; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Xunit; + +namespace Timeline.Tests.Controllers +{ + public class UserDetailControllerTest : IDisposable + { + private readonly Mock _mockUserDetailService; + private readonly UserDetailController _controller; + + public UserDetailControllerTest() + { + _mockUserDetailService = new Mock(); + _controller = new UserDetailController(_mockUserDetailService.Object); + } + + public void Dispose() + { + _controller.Dispose(); + } + + [Fact] + public void AttributeTest() + { + typeof(UserDetailController).Should().BeDecoratedWith(); + + var getNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.GetNickname)); + getNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + getNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + + var putNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.PutNickname)); + putNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + putNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + var stringLengthAttributeOnPutBody = putNickname.GetParameter("body").Should().BeDecoratedWith() + .And.BeDecoratedWith() + .Which; + stringLengthAttributeOnPutBody.MinimumLength.Should().Be(1); + stringLengthAttributeOnPutBody.MaximumLength.Should().Be(10); + + var deleteNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.DeleteNickname)); + deleteNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith(); + deleteNickname.GetParameter("username").Should().BeDecoratedWith() + .And.BeDecoratedWith(); + } + + [Fact] + public async Task GetNickname_ShouldWork() + { + const string username = "uuu"; + const string nickname = "nnn"; + _mockUserDetailService.Setup(s => s.GetNickname(username)).ReturnsAsync(nickname); + var actionResult = await _controller.GetNickname(username); + actionResult.Result.Should().BeAssignableTo(nickname); + _mockUserDetailService.VerifyAll(); + } + + [Fact] + public async Task PutNickname_ShouldWork() + { + const string username = "uuu"; + const string nickname = "nnn"; + _mockUserDetailService.Setup(s => s.SetNickname(username, nickname)).Returns(Task.CompletedTask); + var actionResult = await _controller.PutNickname(username, nickname); + actionResult.Should().BeAssignableTo(); + _mockUserDetailService.VerifyAll(); + } + + [Fact] + public async Task DeleteNickname_ShouldWork() + { + const string username = "uuu"; + _mockUserDetailService.Setup(s => s.SetNickname(username, null)).Returns(Task.CompletedTask); + var actionResult = await _controller.DeleteNickname(username); + actionResult.Should().BeAssignableTo(); + _mockUserDetailService.VerifyAll(); + } + } +} diff --git a/Timeline.Tests/Helpers/AssertionResponseExtensions.cs b/Timeline.Tests/Helpers/AssertionResponseExtensions.cs deleted file mode 100644 index 08f10b2b..00000000 --- a/Timeline.Tests/Helpers/AssertionResponseExtensions.cs +++ /dev/null @@ -1,141 +0,0 @@ -using FluentAssertions; -using FluentAssertions.Execution; -using FluentAssertions.Formatting; -using FluentAssertions.Primitives; -using Newtonsoft.Json; -using System; -using System.Net; -using System.Net.Http; -using System.Text; -using Timeline.Models.Http; - -namespace Timeline.Tests.Helpers -{ - public class HttpResponseMessageValueFormatter : IValueFormatter - { - public bool CanHandle(object value) - { - return value is HttpResponseMessage; - } - - public string Format(object value, FormattingContext context, FormatChild formatChild) - { - string newline = context.UseLineBreaks ? Environment.NewLine : ""; - string padding = new string('\t', context.Depth); - - var res = (HttpResponseMessage)value; - - var builder = new StringBuilder(); - builder.Append($"{newline}{padding} Status Code: {res.StatusCode} ; Body: "); - - try - { - var body = res.Content.ReadAsStringAsync().Result; - if (body.Length > 40) - { - body = body[0..40] + " ..."; - } - builder.Append(body); - } - catch (AggregateException) - { - builder.Append("NOT A STRING."); - } - - return builder.ToString(); - } - } - - public class HttpResponseMessageAssertions - : ReferenceTypeAssertions - { - static HttpResponseMessageAssertions() - { - Formatter.AddFormatter(new HttpResponseMessageValueFormatter()); - } - - public HttpResponseMessageAssertions(HttpResponseMessage instance) - { - Subject = instance; - } - - protected override string Identifier => "HttpResponseMessage"; - - public AndConstraint HaveStatusCode(int expected, string because = "", params object[] becauseArgs) - { - return HaveStatusCode((HttpStatusCode)expected, because, becauseArgs); - } - - public AndConstraint HaveStatusCode(HttpStatusCode expected, string because = "", params object[] becauseArgs) - { - Execute.Assertion.BecauseOf(because, becauseArgs) - .ForCondition(Subject.StatusCode == expected) - .FailWith("Expected status code of {context:HttpResponseMessage} to be {0}{reason}, but found {1}.", expected, Subject.StatusCode); - return new AndConstraint(Subject); - } - - public AndWhichConstraint HaveJsonBody(string because = "", params object[] becauseArgs) - { - var a = Execute.Assertion.BecauseOf(because, becauseArgs); - string body; - try - { - body = Subject.Content.ReadAsStringAsync().Result; - } - catch (AggregateException e) - { - a.FailWith("Expected response body of {context:HttpResponseMessage} to be json string{reason}, but failed to read it or it was not a string. Exception is {0}.", e.InnerExceptions); - return new AndWhichConstraint(Subject, null); - } - - var result = JsonConvert.DeserializeObject(body); - return new AndWhichConstraint(Subject, result); - } - } - - public static class AssertionResponseExtensions - { - public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) - { - return new HttpResponseMessageAssertions(instance); - } - - public static AndWhichConstraint HaveCommonBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) - { - return assertions.HaveJsonBody(because, becauseArgs); - } - - public static AndWhichConstraint> HaveCommonDataBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) - { - return assertions.HaveJsonBody>(because, becauseArgs); - } - - public static void BePut(this HttpResponseMessageAssertions assertions, bool create, string because = "", params object[] becauseArgs) - { - var body = assertions.HaveStatusCode(create ? 201 : 200, because, becauseArgs) - .And.Should().HaveJsonBody(because, becauseArgs) - .Which; - body.Code.Should().Be(0); - body.Data.Create.Should().Be(create); - } - - public static void BeDelete(this HttpResponseMessageAssertions assertions, bool delete, string because = "", params object[] becauseArgs) - { - var body = assertions.HaveStatusCode(200, because, becauseArgs) - .And.Should().HaveJsonBody(because, becauseArgs) - .Which; - body.Code.Should().Be(0); - body.Data.Delete.Should().Be(delete); - } - - public static void BeInvalidModel(this HttpResponseMessageAssertions assertions, string message = null) - { - message = string.IsNullOrEmpty(message) ? "" : ", " + message; - assertions.HaveStatusCode(400, "Invalid Model Error must have 400 status code{0}", message) - .And.Should().HaveCommonBody("Invalid Model Error must have CommonResponse body{0}", message) - .Which.Code.Should().Be(ErrorCodes.Http.Common.InvalidModel, - "Invalid Model Error must have code {0} in body{1}", - ErrorCodes.Http.Common.InvalidModel, message); - } - } -} diff --git a/Timeline.Tests/Helpers/ParameterInfoAssertions.cs b/Timeline.Tests/Helpers/ParameterInfoAssertions.cs new file mode 100644 index 00000000..e3becee1 --- /dev/null +++ b/Timeline.Tests/Helpers/ParameterInfoAssertions.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Formatting; +using FluentAssertions.Primitives; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Timeline.Tests.Helpers +{ + public class ParameterInfoValueFormatter : IValueFormatter + { + public bool CanHandle(object value) + { + return value is ParameterInfo; + } + + public string Format(object value, FormattingContext context, FormatChild formatChild) + { + var param = (ParameterInfo)value; + return $"{param.Member.DeclaringType.FullName}.{param.Member.Name}#{param.Name}"; + } + } + + public class ParameterInfoAssertions : ReferenceTypeAssertions + { + static ParameterInfoAssertions() + { + Formatter.AddFormatter(new ParameterInfoValueFormatter()); + } + + public ParameterInfoAssertions(ParameterInfo parameterInfo) + { + Subject = parameterInfo; + } + + protected override string Identifier => "parameter"; + + public AndWhichConstraint BeDecoratedWith(string because = "", params object[] becauseArgs) + where TAttribute : Attribute + { + var attribute = Subject.GetCustomAttribute(false); + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(attribute != null) + .FailWith("Expected {0} {1} to be decorated with {2}{reason}, but that attribute was not found.", + Identifier, Subject, typeof(TAttribute).FullName); + + return new AndWhichConstraint(this, attribute); + } + } + + public static class ParameterInfoAssertionExtensions + { + public static ParameterInfoAssertions Should(this ParameterInfo parameterInfo) + { + return new ParameterInfoAssertions(parameterInfo); + } + } +} diff --git a/Timeline.Tests/Helpers/ReflectionHelper.cs b/Timeline.Tests/Helpers/ReflectionHelper.cs new file mode 100644 index 00000000..3f6036e3 --- /dev/null +++ b/Timeline.Tests/Helpers/ReflectionHelper.cs @@ -0,0 +1,13 @@ +using System.Linq; +using System.Reflection; + +namespace Timeline.Tests.Helpers +{ + public static class ReflectionHelper + { + public static ParameterInfo GetParameter(this MethodInfo methodInfo, string name) + { + return methodInfo.GetParameters().Where(p => p.Name == name).Single(); + } + } +} diff --git a/Timeline.Tests/Helpers/ResponseAssertions.cs b/Timeline.Tests/Helpers/ResponseAssertions.cs new file mode 100644 index 00000000..08f10b2b --- /dev/null +++ b/Timeline.Tests/Helpers/ResponseAssertions.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Formatting; +using FluentAssertions.Primitives; +using Newtonsoft.Json; +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using Timeline.Models.Http; + +namespace Timeline.Tests.Helpers +{ + public class HttpResponseMessageValueFormatter : IValueFormatter + { + public bool CanHandle(object value) + { + return value is HttpResponseMessage; + } + + public string Format(object value, FormattingContext context, FormatChild formatChild) + { + string newline = context.UseLineBreaks ? Environment.NewLine : ""; + string padding = new string('\t', context.Depth); + + var res = (HttpResponseMessage)value; + + var builder = new StringBuilder(); + builder.Append($"{newline}{padding} Status Code: {res.StatusCode} ; Body: "); + + try + { + var body = res.Content.ReadAsStringAsync().Result; + if (body.Length > 40) + { + body = body[0..40] + " ..."; + } + builder.Append(body); + } + catch (AggregateException) + { + builder.Append("NOT A STRING."); + } + + return builder.ToString(); + } + } + + public class HttpResponseMessageAssertions + : ReferenceTypeAssertions + { + static HttpResponseMessageAssertions() + { + Formatter.AddFormatter(new HttpResponseMessageValueFormatter()); + } + + public HttpResponseMessageAssertions(HttpResponseMessage instance) + { + Subject = instance; + } + + protected override string Identifier => "HttpResponseMessage"; + + public AndConstraint HaveStatusCode(int expected, string because = "", params object[] becauseArgs) + { + return HaveStatusCode((HttpStatusCode)expected, because, becauseArgs); + } + + public AndConstraint HaveStatusCode(HttpStatusCode expected, string because = "", params object[] becauseArgs) + { + Execute.Assertion.BecauseOf(because, becauseArgs) + .ForCondition(Subject.StatusCode == expected) + .FailWith("Expected status code of {context:HttpResponseMessage} to be {0}{reason}, but found {1}.", expected, Subject.StatusCode); + return new AndConstraint(Subject); + } + + public AndWhichConstraint HaveJsonBody(string because = "", params object[] becauseArgs) + { + var a = Execute.Assertion.BecauseOf(because, becauseArgs); + string body; + try + { + body = Subject.Content.ReadAsStringAsync().Result; + } + catch (AggregateException e) + { + a.FailWith("Expected response body of {context:HttpResponseMessage} to be json string{reason}, but failed to read it or it was not a string. Exception is {0}.", e.InnerExceptions); + return new AndWhichConstraint(Subject, null); + } + + var result = JsonConvert.DeserializeObject(body); + return new AndWhichConstraint(Subject, result); + } + } + + public static class AssertionResponseExtensions + { + public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) + { + return new HttpResponseMessageAssertions(instance); + } + + public static AndWhichConstraint HaveCommonBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) + { + return assertions.HaveJsonBody(because, becauseArgs); + } + + public static AndWhichConstraint> HaveCommonDataBody(this HttpResponseMessageAssertions assertions, string because = "", params object[] becauseArgs) + { + return assertions.HaveJsonBody>(because, becauseArgs); + } + + public static void BePut(this HttpResponseMessageAssertions assertions, bool create, string because = "", params object[] becauseArgs) + { + var body = assertions.HaveStatusCode(create ? 201 : 200, because, becauseArgs) + .And.Should().HaveJsonBody(because, becauseArgs) + .Which; + body.Code.Should().Be(0); + body.Data.Create.Should().Be(create); + } + + public static void BeDelete(this HttpResponseMessageAssertions assertions, bool delete, string because = "", params object[] becauseArgs) + { + var body = assertions.HaveStatusCode(200, because, becauseArgs) + .And.Should().HaveJsonBody(because, becauseArgs) + .Which; + body.Code.Should().Be(0); + body.Data.Delete.Should().Be(delete); + } + + public static void BeInvalidModel(this HttpResponseMessageAssertions assertions, string message = null) + { + message = string.IsNullOrEmpty(message) ? "" : ", " + message; + assertions.HaveStatusCode(400, "Invalid Model Error must have 400 status code{0}", message) + .And.Should().HaveCommonBody("Invalid Model Error must have CommonResponse body{0}", message) + .Which.Code.Should().Be(ErrorCodes.Http.Common.InvalidModel, + "Invalid Model Error must have code {0} in body{1}", + ErrorCodes.Http.Common.InvalidModel, message); + } + } +} diff --git a/Timeline/Controllers/UserDetailController.cs b/Timeline/Controllers/UserDetailController.cs new file mode 100644 index 00000000..ef13b462 --- /dev/null +++ b/Timeline/Controllers/UserDetailController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Timeline.Filters; +using Timeline.Models.Validation; +using Timeline.Services; +using System.ComponentModel.DataAnnotations; + +namespace Timeline.Controllers +{ + [ApiController] + public class UserDetailController : Controller + { + private readonly IUserDetailService _service; + + public UserDetailController(IUserDetailService service) + { + _service = service; + } + + [HttpGet("users/{username}/nickname")] + [CatchUserNotExistException] + public async Task> GetNickname([FromRoute][Username] string username) + { + return Ok(await _service.GetNickname(username)); + } + + [HttpPut("users/{username}/nickname")] + [CatchUserNotExistException] + public async Task PutNickname([FromRoute][Username] string username, + [FromBody][StringLength(10, MinimumLength = 1)] string body) + { + await _service.SetNickname(username, body); + return Ok(); + } + + [HttpDelete("users/{username}/nickname")] + [CatchUserNotExistException] + public async Task DeleteNickname([FromRoute][Username] string username) + { + await _service.SetNickname(username, null); + return Ok(); + } + } +} diff --git a/Timeline/Filters/User.cs b/Timeline/Filters/User.cs new file mode 100644 index 00000000..22fae938 --- /dev/null +++ b/Timeline/Filters/User.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System; +using Timeline.Models.Http; + +namespace Timeline +{ + public static partial class ErrorCodes + { + public static partial class Http + { + public static partial class Filter // bxx = 1xx + { + public static class User // bbb = 101 + { + public const int NotExist = 11010001; + } + + } + } + } +} + +namespace Timeline.Filters +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class CatchUserNotExistExceptionAttribute : ExceptionFilterAttribute + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "ASP.Net already checked.")] + public override void OnException(ExceptionContext context) + { + var body = new CommonResponse( + ErrorCodes.Http.Filter.User.NotExist, + Resources.Filters.MessageUserNotExist); + + if (context.HttpContext.Request.Method == "GET") + context.Result = new NotFoundObjectResult(body); + else + context.Result = new BadRequestObjectResult(body); + } + } +} diff --git a/Timeline/Resources/Filters.Designer.cs b/Timeline/Resources/Filters.Designer.cs index ae3565f7..e3c8be41 100644 --- a/Timeline/Resources/Filters.Designer.cs +++ b/Timeline/Resources/Filters.Designer.cs @@ -86,5 +86,14 @@ namespace Timeline.Resources { return ResourceManager.GetString("MessageHeaderContentTypeMissing", resourceCulture); } } + + /// + /// Looks up a localized string similar to The user does not exist.. + /// + internal static string MessageUserNotExist { + get { + return ResourceManager.GetString("MessageUserNotExist", resourceCulture); + } + } } } diff --git a/Timeline/Resources/Filters.resx b/Timeline/Resources/Filters.resx index d2b7e68a..ba1fcee8 100644 --- a/Timeline/Resources/Filters.resx +++ b/Timeline/Resources/Filters.resx @@ -126,4 +126,7 @@ Header Content-Type is required. + + The user does not exist. + \ No newline at end of file diff --git a/Timeline/Resources/Filters.zh.resx b/Timeline/Resources/Filters.zh.resx index 90e97e49..690a3e39 100644 --- a/Timeline/Resources/Filters.zh.resx +++ b/Timeline/Resources/Filters.zh.resx @@ -126,4 +126,7 @@ 缺少必需的请求头Content-Type。 + + 用户不存在。 + \ No newline at end of file -- cgit v1.2.3 From 2198ad257a2c049f3601a6f95b8906c5be8b27d5 Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Thu, 31 Oct 2019 00:56:46 +0800 Subject: Continue to construct feature and tests. --- Timeline.Tests/Controllers/UserControllerTest.cs | 1 - .../Controllers/UserDetailControllerTest.cs | 5 ++ Timeline.Tests/Helpers/HttpClientExtensions.cs | 12 +++ Timeline.Tests/Helpers/ResponseAssertions.cs | 5 ++ Timeline.Tests/IntegratedTests/UserAvatarTest.cs | 4 +- Timeline.Tests/IntegratedTests/UserDetailTest.cs | 83 ++++++++++++++++++ Timeline.Tests/Properties/launchSettings.json | 52 ++++++------ Timeline.Tests/Timeline.Tests.csproj | 4 + Timeline/Auth/Attribute.cs | 21 +++++ Timeline/Auth/MyAuthenticationHandler.cs | 99 ++++++++++++++++++++++ Timeline/Auth/PrincipalExtensions.cs | 13 +++ Timeline/Authentication/Attribute.cs | 21 ----- Timeline/Authentication/AuthHandler.cs | 99 ---------------------- Timeline/Authentication/PrincipalExtensions.cs | 13 --- .../Controllers/Testing/TestingAuthController.cs | 2 +- Timeline/Controllers/UserAvatarController.cs | 2 +- Timeline/Controllers/UserController.cs | 2 +- Timeline/Controllers/UserDetailController.cs | 5 ++ Timeline/Filters/User.cs | 66 ++++++++++++--- Timeline/Formatters/StringInputFormatter.cs | 27 ++++++ Timeline/Resources/Filters.Designer.cs | 36 ++++++++ Timeline/Resources/Filters.resx | 12 +++ Timeline/Resources/Filters.zh.resx | 3 + Timeline/Startup.cs | 18 ++-- 24 files changed, 424 insertions(+), 181 deletions(-) create mode 100644 Timeline.Tests/IntegratedTests/UserDetailTest.cs create mode 100644 Timeline/Auth/Attribute.cs create mode 100644 Timeline/Auth/MyAuthenticationHandler.cs create mode 100644 Timeline/Auth/PrincipalExtensions.cs delete mode 100644 Timeline/Authentication/Attribute.cs delete mode 100644 Timeline/Authentication/AuthHandler.cs delete mode 100644 Timeline/Authentication/PrincipalExtensions.cs create mode 100644 Timeline/Formatters/StringInputFormatter.cs (limited to 'Timeline/Filters') diff --git a/Timeline.Tests/Controllers/UserControllerTest.cs b/Timeline.Tests/Controllers/UserControllerTest.cs index a9cce970..83b8cdcf 100644 --- a/Timeline.Tests/Controllers/UserControllerTest.cs +++ b/Timeline.Tests/Controllers/UserControllerTest.cs @@ -13,7 +13,6 @@ using Timeline.Models.Http; using Timeline.Services; using Timeline.Tests.Helpers; using Timeline.Tests.Mock.Data; -using Timeline.Tests.Mock.Services; using Xunit; using static Timeline.ErrorCodes.Http.User; diff --git a/Timeline.Tests/Controllers/UserDetailControllerTest.cs b/Timeline.Tests/Controllers/UserDetailControllerTest.cs index 99341c40..ffd88790 100644 --- a/Timeline.Tests/Controllers/UserDetailControllerTest.cs +++ b/Timeline.Tests/Controllers/UserDetailControllerTest.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Moq; using System; @@ -42,6 +43,8 @@ namespace Timeline.Tests.Controllers var putNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.PutNickname)); putNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith() + .And.BeDecoratedWith() .And.BeDecoratedWith(); putNickname.GetParameter("username").Should().BeDecoratedWith() .And.BeDecoratedWith(); @@ -53,6 +56,8 @@ namespace Timeline.Tests.Controllers var deleteNickname = typeof(UserDetailController).GetMethod(nameof(UserDetailController.DeleteNickname)); deleteNickname.Should().BeDecoratedWith() + .And.BeDecoratedWith() + .And.BeDecoratedWith() .And.BeDecoratedWith(); deleteNickname.GetParameter("username").Should().BeDecoratedWith() .And.BeDecoratedWith(); diff --git a/Timeline.Tests/Helpers/HttpClientExtensions.cs b/Timeline.Tests/Helpers/HttpClientExtensions.cs index 38641f90..6513bbe7 100644 --- a/Timeline.Tests/Helpers/HttpClientExtensions.cs +++ b/Timeline.Tests/Helpers/HttpClientExtensions.cs @@ -35,5 +35,17 @@ namespace Timeline.Tests.Helpers content.Headers.ContentType = new MediaTypeHeaderValue(mimeType); return client.PutAsync(url, content); } + + public static Task PutStringAsync(this HttpClient client, string url, string body, string mimeType = null) + { + return client.PutStringAsync(new Uri(url, UriKind.RelativeOrAbsolute), body, mimeType); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")] + public static Task PutStringAsync(this HttpClient client, Uri url, string body, string mimeType = null) + { + var content = new StringContent(body, Encoding.UTF8, mimeType ?? MediaTypeNames.Text.Plain); + return client.PutAsync(url, content); + } } } diff --git a/Timeline.Tests/Helpers/ResponseAssertions.cs b/Timeline.Tests/Helpers/ResponseAssertions.cs index 08f10b2b..db86ff59 100644 --- a/Timeline.Tests/Helpers/ResponseAssertions.cs +++ b/Timeline.Tests/Helpers/ResponseAssertions.cs @@ -91,6 +91,11 @@ namespace Timeline.Tests.Helpers var result = JsonConvert.DeserializeObject(body); return new AndWhichConstraint(Subject, result); } + + internal void HaveStatusCode(object statusCode) + { + throw new NotImplementedException(); + } } public static class AssertionResponseExtensions diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs index ad2e11df..b338665e 100644 --- a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs +++ b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs @@ -22,12 +22,12 @@ using static Timeline.ErrorCodes.Http.UserAvatar; namespace Timeline.Tests.IntegratedTests { - public class UserAvatarUnitTest : IClassFixture>, IDisposable + public class UserAvatarTest : IClassFixture>, IDisposable { private readonly TestApplication _testApp; private readonly WebApplicationFactory _factory; - public UserAvatarUnitTest(WebApplicationFactory factory) + public UserAvatarTest(WebApplicationFactory factory) { _testApp = new TestApplication(factory); _factory = _testApp.Factory; diff --git a/Timeline.Tests/IntegratedTests/UserDetailTest.cs b/Timeline.Tests/IntegratedTests/UserDetailTest.cs new file mode 100644 index 00000000..ff2c03a5 --- /dev/null +++ b/Timeline.Tests/IntegratedTests/UserDetailTest.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System; +using System.Net; +using System.Threading.Tasks; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Timeline.Tests.Mock.Data; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public class UserDetailTest : IClassFixture>, IDisposable + { + private readonly TestApplication _testApp; + private readonly WebApplicationFactory _factory; + + public UserDetailTest(WebApplicationFactory factory) + { + _testApp = new TestApplication(factory); + _factory = _testApp.Factory; + } + + public void Dispose() + { + _testApp.Dispose(); + } + + [Fact] + public async Task PermissionTest() + { + { // unauthorize + using var client = _factory.CreateDefaultClient(); + { // GET + var res = await client.GetAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // PUT + var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); + } + { // DELETE + var res = await client.DeleteAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); + } + } + { // user + using var client = await _factory.CreateClientAsUser(); + { // GET + var res = await client.GetAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // PUT self + var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // PUT other + var res = await client.PutStringAsync($"users/{MockUser.Admin.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden); + } + { // DELETE self + var res = await client.DeleteAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // DELETE other + var res = await client.DeleteAsync($"users/{MockUser.Admin.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.Forbidden); + } + } + { // user + using var client = await _factory.CreateClientAsAdmin(); + { // PUT other + var res = await client.PutStringAsync($"users/{MockUser.User.Username}/nickname", "aaa"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + { // DELETE other + var res = await client.DeleteAsync($"users/{MockUser.User.Username}/nickname"); + res.Should().HaveStatusCode(HttpStatusCode.OK); + } + } + } + } +} diff --git a/Timeline.Tests/Properties/launchSettings.json b/Timeline.Tests/Properties/launchSettings.json index 0c1cae5d..7a94d57a 100644 --- a/Timeline.Tests/Properties/launchSettings.json +++ b/Timeline.Tests/Properties/launchSettings.json @@ -1,27 +1,27 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:11197/", - "sslPort": 0 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "Timeline.Tests": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:11199/;http://localhost:11198/" - } - } +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52040/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Timeline.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:52041/" + } + } } \ No newline at end of file diff --git a/Timeline.Tests/Timeline.Tests.csproj b/Timeline.Tests/Timeline.Tests.csproj index 497a00b7..21e887eb 100644 --- a/Timeline.Tests/Timeline.Tests.csproj +++ b/Timeline.Tests/Timeline.Tests.csproj @@ -31,4 +31,8 @@ + + + + diff --git a/Timeline/Auth/Attribute.cs b/Timeline/Auth/Attribute.cs new file mode 100644 index 00000000..86d0109b --- /dev/null +++ b/Timeline/Auth/Attribute.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authorization; +using Timeline.Entities; + +namespace Timeline.Auth +{ + public class AdminAuthorizeAttribute : AuthorizeAttribute + { + public AdminAuthorizeAttribute() + { + Roles = UserRoles.Admin; + } + } + + public class UserAuthorizeAttribute : AuthorizeAttribute + { + public UserAuthorizeAttribute() + { + Roles = UserRoles.User; + } + } +} diff --git a/Timeline/Auth/MyAuthenticationHandler.cs b/Timeline/Auth/MyAuthenticationHandler.cs new file mode 100644 index 00000000..f5dcd697 --- /dev/null +++ b/Timeline/Auth/MyAuthenticationHandler.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using System; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Timeline.Models; +using Timeline.Services; +using static Timeline.Resources.Authentication.AuthHandler; + +namespace Timeline.Auth +{ + public static class AuthenticationConstants + { + public const string Scheme = "Bearer"; + public const string DisplayName = "My Jwt Auth Scheme"; + } + + public class MyAuthenticationOptions : AuthenticationSchemeOptions + { + /// + /// The query param key to search for token. If null then query params are not searched for token. Default to "token". + /// + public string TokenQueryParamKey { get; set; } = "token"; + } + + public class MyAuthenticationHandler : AuthenticationHandler + { + private readonly ILogger _logger; + private readonly IUserService _userService; + + public MyAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserService userService) + : base(options, logger, encoder, clock) + { + _logger = logger.CreateLogger(); + _userService = userService; + } + + // return null if no token is found + private string? ExtractToken() + { + // check the authorization header + string header = Request.Headers[HeaderNames.Authorization]; + if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.InvariantCultureIgnoreCase)) + { + var token = header.Substring("Bearer ".Length).Trim(); + _logger.LogInformation(LogTokenFoundInHeader, token); + return token; + } + + // check the query params + var paramQueryKey = Options.TokenQueryParamKey; + if (!string.IsNullOrEmpty(paramQueryKey)) + { + string token = Request.Query[paramQueryKey]; + if (!string.IsNullOrEmpty(token)) + { + _logger.LogInformation(LogTokenFoundInQuery, paramQueryKey, token); + return token; + } + } + + // not found anywhere then return null + return null; + } + + protected override async Task HandleAuthenticateAsync() + { + var token = ExtractToken(); + if (string.IsNullOrEmpty(token)) + { + _logger.LogInformation(LogTokenNotFound); + return AuthenticateResult.NoResult(); + } + + try + { + var userInfo = await _userService.VerifyToken(token); + + var identity = new ClaimsIdentity(AuthenticationConstants.Scheme); + identity.AddClaim(new Claim(identity.NameClaimType, userInfo.Username, ClaimValueTypes.String)); + identity.AddClaims(UserRoleConvert.ToArray(userInfo.Administrator).Select(role => new Claim(identity.RoleClaimType, role, ClaimValueTypes.String))); + + var principal = new ClaimsPrincipal(); + principal.AddIdentity(identity); + + return AuthenticateResult.Success(new AuthenticationTicket(principal, AuthenticationConstants.Scheme)); + } + catch (Exception e) when (!(e is ArgumentException)) + { + _logger.LogInformation(e, LogTokenValidationFail); + return AuthenticateResult.Fail(e); + } + } + } +} diff --git a/Timeline/Auth/PrincipalExtensions.cs b/Timeline/Auth/PrincipalExtensions.cs new file mode 100644 index 00000000..ad7a887f --- /dev/null +++ b/Timeline/Auth/PrincipalExtensions.cs @@ -0,0 +1,13 @@ +using System.Security.Principal; +using Timeline.Entities; + +namespace Timeline.Auth +{ + internal static class PrincipalExtensions + { + internal static bool IsAdministrator(this IPrincipal principal) + { + return principal.IsInRole(UserRoles.Admin); + } + } +} diff --git a/Timeline/Authentication/Attribute.cs b/Timeline/Authentication/Attribute.cs deleted file mode 100644 index 370b37e1..00000000 --- a/Timeline/Authentication/Attribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Timeline.Entities; - -namespace Timeline.Authentication -{ - public class AdminAuthorizeAttribute : AuthorizeAttribute - { - public AdminAuthorizeAttribute() - { - Roles = UserRoles.Admin; - } - } - - public class UserAuthorizeAttribute : AuthorizeAttribute - { - public UserAuthorizeAttribute() - { - Roles = UserRoles.User; - } - } -} diff --git a/Timeline/Authentication/AuthHandler.cs b/Timeline/Authentication/AuthHandler.cs deleted file mode 100644 index 2b457eb1..00000000 --- a/Timeline/Authentication/AuthHandler.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -using System; -using System.Linq; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Timeline.Models; -using Timeline.Services; -using static Timeline.Resources.Authentication.AuthHandler; - -namespace Timeline.Authentication -{ - static class AuthConstants - { - public const string Scheme = "Bearer"; - public const string DisplayName = "My Jwt Auth Scheme"; - } - - public class AuthOptions : AuthenticationSchemeOptions - { - /// - /// The query param key to search for token. If null then query params are not searched for token. Default to "token". - /// - public string TokenQueryParamKey { get; set; } = "token"; - } - - public class AuthHandler : AuthenticationHandler - { - private readonly ILogger _logger; - private readonly IUserService _userService; - - public AuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserService userService) - : base(options, logger, encoder, clock) - { - _logger = logger.CreateLogger(); - _userService = userService; - } - - // return null if no token is found - private string? ExtractToken() - { - // check the authorization header - string header = Request.Headers[HeaderNames.Authorization]; - if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.InvariantCultureIgnoreCase)) - { - var token = header.Substring("Bearer ".Length).Trim(); - _logger.LogInformation(LogTokenFoundInHeader, token); - return token; - } - - // check the query params - var paramQueryKey = Options.TokenQueryParamKey; - if (!string.IsNullOrEmpty(paramQueryKey)) - { - string token = Request.Query[paramQueryKey]; - if (!string.IsNullOrEmpty(token)) - { - _logger.LogInformation(LogTokenFoundInQuery, paramQueryKey, token); - return token; - } - } - - // not found anywhere then return null - return null; - } - - protected override async Task HandleAuthenticateAsync() - { - var token = ExtractToken(); - if (string.IsNullOrEmpty(token)) - { - _logger.LogInformation(LogTokenNotFound); - return AuthenticateResult.NoResult(); - } - - try - { - var userInfo = await _userService.VerifyToken(token); - - var identity = new ClaimsIdentity(AuthConstants.Scheme); - identity.AddClaim(new Claim(identity.NameClaimType, userInfo.Username, ClaimValueTypes.String)); - identity.AddClaims(UserRoleConvert.ToArray(userInfo.Administrator).Select(role => new Claim(identity.RoleClaimType, role, ClaimValueTypes.String))); - - var principal = new ClaimsPrincipal(); - principal.AddIdentity(identity); - - return AuthenticateResult.Success(new AuthenticationTicket(principal, AuthConstants.Scheme)); - } - catch (Exception e) when (!(e is ArgumentException)) - { - _logger.LogInformation(e, LogTokenValidationFail); - return AuthenticateResult.Fail(e); - } - } - } -} diff --git a/Timeline/Authentication/PrincipalExtensions.cs b/Timeline/Authentication/PrincipalExtensions.cs deleted file mode 100644 index 8d77ab62..00000000 --- a/Timeline/Authentication/PrincipalExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Security.Principal; -using Timeline.Entities; - -namespace Timeline.Authentication -{ - internal static class PrincipalExtensions - { - internal static bool IsAdministrator(this IPrincipal principal) - { - return principal.IsInRole(UserRoles.Admin); - } - } -} diff --git a/Timeline/Controllers/Testing/TestingAuthController.cs b/Timeline/Controllers/Testing/TestingAuthController.cs index 67b5b2ef..4d3b3ec7 100644 --- a/Timeline/Controllers/Testing/TestingAuthController.cs +++ b/Timeline/Controllers/Testing/TestingAuthController.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Timeline.Authentication; +using Timeline.Auth; namespace Timeline.Controllers.Testing { diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index 7c77897d..7625f962 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -6,7 +6,7 @@ using Microsoft.Net.Http.Headers; using System; using System.Linq; using System.Threading.Tasks; -using Timeline.Authentication; +using Timeline.Auth; using Timeline.Filters; using Timeline.Helpers; using Timeline.Models.Http; diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 7b441c3a..0d950cd7 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System.Globalization; using System.Threading.Tasks; -using Timeline.Authentication; +using Timeline.Auth; using Timeline.Helpers; using Timeline.Models; using Timeline.Models.Http; diff --git a/Timeline/Controllers/UserDetailController.cs b/Timeline/Controllers/UserDetailController.cs index ef13b462..9de9899e 100644 --- a/Timeline/Controllers/UserDetailController.cs +++ b/Timeline/Controllers/UserDetailController.cs @@ -4,6 +4,7 @@ using Timeline.Filters; using Timeline.Models.Validation; using Timeline.Services; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; namespace Timeline.Controllers { @@ -25,6 +26,8 @@ namespace Timeline.Controllers } [HttpPut("users/{username}/nickname")] + [Authorize] + [SelfOrAdmin] [CatchUserNotExistException] public async Task PutNickname([FromRoute][Username] string username, [FromBody][StringLength(10, MinimumLength = 1)] string body) @@ -34,6 +37,8 @@ namespace Timeline.Controllers } [HttpDelete("users/{username}/nickname")] + [Authorize] + [SelfOrAdmin] [CatchUserNotExistException] public async Task DeleteNickname([FromRoute][Username] string username) { diff --git a/Timeline/Filters/User.cs b/Timeline/Filters/User.cs index 22fae938..16c76750 100644 --- a/Timeline/Filters/User.cs +++ b/Timeline/Filters/User.cs @@ -1,7 +1,13 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; +using Timeline.Auth; using Timeline.Models.Http; +using Timeline.Services; +using static Timeline.Resources.Filters; namespace Timeline { @@ -13,9 +19,10 @@ namespace Timeline { public static class User // bbb = 101 { - public const int NotExist = 11010001; - } + public const int NotExist = 11010101; + public const int NotSelfOrAdminForbid = 11010201; + } } } } @@ -23,20 +30,59 @@ namespace Timeline namespace Timeline.Filters { + public class SelfOrAdminAttribute : ActionFilterAttribute + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] + public override void OnActionExecuting(ActionExecutingContext context) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + var user = context.HttpContext.User; + + if (user == null) + { + logger.LogError(LogSelfOrAdminNoUser); + return; + } + + if (context.ModelState.TryGetValue("username", out var model)) + { + if (model.RawValue is string username) + { + if (!user.IsAdministrator() && user.Identity.Name != username) + { + context.Result = new ObjectResult( + new CommonResponse(ErrorCodes.Http.Filter.User.NotSelfOrAdminForbid, MessageSelfOrAdminForbid)) + { StatusCode = StatusCodes.Status403Forbidden }; + } + } + else + { + logger.LogError(LogSelfOrAdminUsernameNotString); + } + } + else + { + logger.LogError(LogSelfOrAdminNoUsername); + } + } + } + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] public class CatchUserNotExistExceptionAttribute : ExceptionFilterAttribute { [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "ASP.Net already checked.")] public override void OnException(ExceptionContext context) { - var body = new CommonResponse( - ErrorCodes.Http.Filter.User.NotExist, - Resources.Filters.MessageUserNotExist); + if (context.Exception is UserNotExistException) + { + var body = new CommonResponse(ErrorCodes.Http.Filter.User.NotExist, MessageUserNotExist); - if (context.HttpContext.Request.Method == "GET") - context.Result = new NotFoundObjectResult(body); - else - context.Result = new BadRequestObjectResult(body); + if (context.HttpContext.Request.Method == "GET") + context.Result = new NotFoundObjectResult(body); + else + context.Result = new BadRequestObjectResult(body); + } } } } diff --git a/Timeline/Formatters/StringInputFormatter.cs b/Timeline/Formatters/StringInputFormatter.cs new file mode 100644 index 00000000..90847e36 --- /dev/null +++ b/Timeline/Formatters/StringInputFormatter.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; +using System.IO; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; + +namespace Timeline.Formatters +{ + public class StringInputFormatter : TextInputFormatter + { + public StringInputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Text.Plain)); + SupportedEncodings.Add(Encoding.UTF8); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods")] + public override async Task ReadRequestBodyAsync(InputFormatterContext context, Encoding effectiveEncoding) + { + var request = context.HttpContext.Request; + using var reader = new StreamReader(request.Body, effectiveEncoding); + var stringContent = await reader.ReadToEndAsync(); + return await InputFormatterResult.SuccessAsync(stringContent); + } + } +} diff --git a/Timeline/Resources/Filters.Designer.cs b/Timeline/Resources/Filters.Designer.cs index e3c8be41..3481e4ae 100644 --- a/Timeline/Resources/Filters.Designer.cs +++ b/Timeline/Resources/Filters.Designer.cs @@ -60,6 +60,33 @@ namespace Timeline.Resources { } } + /// + /// Looks up a localized string similar to You apply a SelfOrAdminAttribute on an action, but there is no user. Try add AuthorizeAttribute.. + /// + internal static string LogSelfOrAdminNoUser { + get { + return ResourceManager.GetString("LogSelfOrAdminNoUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You apply a SelfOrAdminAttribute on an action, but it does not have a model named username.. + /// + internal static string LogSelfOrAdminNoUsername { + get { + return ResourceManager.GetString("LogSelfOrAdminNoUsername", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You apply a SelfOrAdminAttribute on an action, found a model named username, but it is not string.. + /// + internal static string LogSelfOrAdminUsernameNotString { + get { + return ResourceManager.GetString("LogSelfOrAdminUsernameNotString", resourceCulture); + } + } + /// /// Looks up a localized string similar to Header Content-Length is missing or of bad format.. /// @@ -87,6 +114,15 @@ namespace Timeline.Resources { } } + /// + /// Looks up a localized string similar to You can't access the resource unless you are the owner or administrator.. + /// + internal static string MessageSelfOrAdminForbid { + get { + return ResourceManager.GetString("MessageSelfOrAdminForbid", resourceCulture); + } + } + /// /// Looks up a localized string similar to The user does not exist.. /// diff --git a/Timeline/Resources/Filters.resx b/Timeline/Resources/Filters.resx index ba1fcee8..b91d4612 100644 --- a/Timeline/Resources/Filters.resx +++ b/Timeline/Resources/Filters.resx @@ -117,6 +117,15 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + You apply a SelfOrAdminAttribute on an action, but there is no user. Try add AuthorizeAttribute. + + + You apply a SelfOrAdminAttribute on an action, but it does not have a model named username. + + + You apply a SelfOrAdminAttribute on an action, found a model named username, but it is not string. + Header Content-Length is missing or of bad format. @@ -126,6 +135,9 @@ Header Content-Type is required. + + You can't access the resource unless you are the owner or administrator. + The user does not exist. diff --git a/Timeline/Resources/Filters.zh.resx b/Timeline/Resources/Filters.zh.resx index 690a3e39..159ac04a 100644 --- a/Timeline/Resources/Filters.zh.resx +++ b/Timeline/Resources/Filters.zh.resx @@ -126,6 +126,9 @@ 缺少必需的请求头Content-Type。 + + 你无权访问该资源除非你是资源的拥有者或者管理员。 + 用户不存在。 diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index b44add6f..f6abf36d 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -8,9 +8,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.Collections.Generic; using System.Globalization; -using Timeline.Authentication; +using Timeline.Auth; using Timeline.Configs; using Timeline.Entities; +using Timeline.Formatters; using Timeline.Helpers; using Timeline.Services; @@ -31,17 +32,22 @@ namespace Timeline // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddControllers() + services.AddControllers(setup => + { + setup.InputFormatters.Add(new StringInputFormatter()); + }) .ConfigureApiBehaviorOptions(options => { options.InvalidModelStateResponseFactory = InvalidModelResponseFactory.Factory; }) - .AddNewtonsoftJson(); + .AddNewtonsoftJson(); // TODO: Remove this. services.Configure(Configuration.GetSection(nameof(JwtConfig))); var jwtConfig = Configuration.GetSection(nameof(JwtConfig)).Get(); - services.AddAuthentication(AuthConstants.Scheme) - .AddScheme(AuthConstants.Scheme, AuthConstants.DisplayName, o => { }); + services.AddAuthentication(AuthenticationConstants.Scheme) + .AddScheme(AuthenticationConstants.Scheme, AuthenticationConstants.DisplayName, o => { }); + services.AddAuthorization(); + var corsConfig = Configuration.GetSection("Cors").Get(); services.AddCors(setup => @@ -62,8 +68,8 @@ namespace Timeline services.AddScoped(); services.AddTransient(); services.AddTransient(); - services.AddUserAvatarService(); + services.AddScoped(); var databaseConfig = Configuration.GetSection(nameof(DatabaseConfig)).Get(); -- cgit v1.2.3