From 2ad20f20ba8db4f3b0d7e2349aa252c66ab4ec6c Mon Sep 17 00:00:00 2001 From: Andres Moschini Date: Fri, 2 Dec 2022 23:38:33 -0300 Subject: [PATCH 1/4] chore: prepare API for create a template based on another one --- .../_/PostTemplateFromTemplateTest.cs | 209 ++++++++++++++++++ .../ApiModels/ResourceCreated.cs | 3 + .../Controllers/TemplatesController.cs | 35 ++- .../DopplerTemplateRepository.cs | 15 ++ .../Repositories/ITemplateRepository.cs | 1 + 5 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/from-template/_/PostTemplateFromTemplateTest.cs create mode 100644 Doppler.HtmlEditorApi/ApiModels/ResourceCreated.cs diff --git a/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/from-template/_/PostTemplateFromTemplateTest.cs b/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/from-template/_/PostTemplateFromTemplateTest.cs new file mode 100644 index 00000000..41afda8d --- /dev/null +++ b/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/from-template/_/PostTemplateFromTemplateTest.cs @@ -0,0 +1,209 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; +using Doppler.HtmlEditorApi.Domain; +using Doppler.HtmlEditorApi.Repositories; +using Doppler.HtmlEditorApi.Test.Utils; +using Microsoft.AspNetCore.Mvc.Testing; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Doppler.HtmlEditorApi; + +public class PostTemplateFromTemplateTest : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + private readonly ITestOutputHelper _output; + + public PostTemplateFromTemplateTest(WebApplicationFactory factory, ITestOutputHelper output) + { + _factory = factory; + _output = output; + } + + [Theory] + [InlineData("/accounts/x@x.com/templates/from-template/456", HttpStatusCode.Unauthorized)] + public async Task GET_template_should_require_token(string url, HttpStatusCode expectedStatusCode) + { + // Arrange + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions()); + + // Act + var response = await client.PostAsync(url, null); + _output.WriteLine(response.GetHeadersAsString()); + + // Assert + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal("Bearer", response.Headers.WwwAuthenticate.ToString()); + } + + [Theory] + [InlineData("/accounts/x@x.com/templates/from-template/456", TestUsersData.TOKEN_TEST1_EXPIRE_20330518, HttpStatusCode.Forbidden)] + [InlineData("/accounts/x@x.com/templates/from-template/456", TestUsersData.TOKEN_EXPIRE_20330518, HttpStatusCode.Forbidden)] + public async Task GET_template_should_not_accept_the_token_of_another_account(string url, string token, HttpStatusCode expectedStatusCode) + { + // Arrange + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions()); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.PostAsync(url, null); + _output.WriteLine(response.GetHeadersAsString()); + + // Assert + Assert.Equal(expectedStatusCode, response.StatusCode); + } + + [Theory] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/456", TestUsersData.TOKEN_TEST1_EXPIRE_20010908, HttpStatusCode.Unauthorized)] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/456", TestUsersData.TOKEN_SUPERUSER_EXPIRE_20010908, HttpStatusCode.Unauthorized)] + public async Task GET_template_should_not_accept_a_expired_token(string url, string token, HttpStatusCode expectedStatusCode) + { + // Arrange + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions()); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.PostAsync(url, null); + _output.WriteLine(response.GetHeadersAsString()); + + // Assert + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Contains("Bearer", response.Headers.WwwAuthenticate.ToString()); + Assert.Contains("invalid_token", response.Headers.WwwAuthenticate.ToString()); + Assert.Contains("token expired", response.Headers.WwwAuthenticate.ToString()); + } + + [Theory] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/459", TestUsersData.TOKEN_TEST1_EXPIRE_20330518, TestUsersData.EMAIL_TEST1, 459)] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/459", TestUsersData.TOKEN_SUPERUSER_EXPIRE_20330518, TestUsersData.EMAIL_TEST1, 459)] + [InlineData("/accounts/otro@test.com/templates/from-template/459", TestUsersData.TOKEN_SUPERUSER_EXPIRE_20330518, "otro@test.com", 459)] + public async Task GET_template_should_accept_right_tokens_and_return_404_when_not_exist(string url, string token, string accountName, int baseTemplateId) + { + // Arrange + TemplateModel templateModel = null; + var repositoryMock = new Mock(); + + repositoryMock + .Setup(x => x.GetOwnOrPublicTemplate(accountName, baseTemplateId)) + .ReturnsAsync(templateModel); + + var client = _factory.CreateSutClient( + serviceToOverride1: repositoryMock.Object, + token: token); + + // Act + var response = await client.PostAsync(url, null); + _output.WriteLine(response.GetHeadersAsString()); + + // Assert + repositoryMock.VerifyAll(); + repositoryMock.VerifyNoOtherCalls(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/459", TestUsersData.TOKEN_TEST1_EXPIRE_20330518, TestUsersData.EMAIL_TEST1, 459)] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/459", TestUsersData.TOKEN_SUPERUSER_EXPIRE_20330518, TestUsersData.EMAIL_TEST1, 459)] + [InlineData("/accounts/otro@test.com/templates/from-template/459", TestUsersData.TOKEN_SUPERUSER_EXPIRE_20330518, "otro@test.com", 459)] + public async Task GET_template_should_accept_right_tokens_and_call_repository_and_return_createdResourceId(string url, string token, string accountName, int baseTemplateId) + { + // Arrange + var newTemplateId = 5; + var expectedSchemaVersion = 999; + var isPublic = true; + var previewImage = "PreviewImage"; + var name = "Name"; + var contentData = new UnlayerTemplateContentData( + HtmlComplete: "", + Meta: JsonSerializer.Serialize(new + { + schemaVersion = expectedSchemaVersion + })); + + var templateModel = new TemplateModel( + TemplateId: baseTemplateId, + IsPublic: isPublic, + PreviewImage: previewImage, + Name: name, + Content: contentData); + + var repositoryMock = new Mock(); + + repositoryMock + .Setup(x => x.GetOwnOrPublicTemplate(accountName, baseTemplateId)) + .ReturnsAsync(templateModel); + + repositoryMock + .Setup(x => x.CreatePrivateTemplate( + accountName, + It.Is(t => + t != templateModel + && !t.IsPublic + && t.TemplateId == 0))) + .ReturnsAsync(newTemplateId); + + var client = _factory.CreateSutClient( + serviceToOverride1: repositoryMock.Object, + token: token); + + // Act + var response = await client.PostAsync(url, null); + var headers = response.GetHeadersAsString(); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + repositoryMock.VerifyAll(); + repositoryMock.VerifyNoOtherCalls(); + Assert.Matches("""{"createdResourceId":5}""", responseContent); + Assert.Contains($"Location: http://localhost/accounts/{accountName}/templates/{newTemplateId}", headers); + } + + [Theory] + [InlineData($"/accounts/{TestUsersData.EMAIL_TEST1}/templates/from-template/459", TestUsersData.TOKEN_TEST1_EXPIRE_20330518, TestUsersData.EMAIL_TEST1, 459)] + public async Task GET_template_should_error_when_base_template_content_is_mseditor(string url, string token, string accountName, int baseTemplateId) + { + // Arrange + var editorType = 4; + var isPublic = true; + var previewImage = "PreviewImage"; + var name = "Name"; + var contentData = new UnknownTemplateContentData(editorType); + + var templateModel = new TemplateModel( + TemplateId: baseTemplateId, + IsPublic: isPublic, + PreviewImage: previewImage, + Name: name, + Content: contentData); + + var repositoryMock = new Mock(); + + repositoryMock + .Setup(x => x.GetOwnOrPublicTemplate(accountName, baseTemplateId)) + .ReturnsAsync(templateModel); + + var client = _factory.CreateSutClient( + serviceToOverride1: repositoryMock.Object, + token: token); + + // Act + var response = await client.PostAsync(url, null); + _output.WriteLine(response.GetHeadersAsString()); + var responseContent = await response.Content.ReadAsStringAsync(); + using var responseContentDoc = JsonDocument.Parse(responseContent); + var responseContentJson = responseContentDoc.RootElement; + + // Assert + repositoryMock.VerifyAll(); + repositoryMock.VerifyNoOtherCalls(); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("https://httpstatuses.io/500", responseContentJson.GetProperty("type").GetString()); + Assert.Equal("Internal Server Error", responseContentJson.GetProperty("title").GetString()); + Assert.Equal("Unsupported template content type Doppler.HtmlEditorApi.Domain.UnknownTemplateContentData", responseContentJson.GetProperty("detail").GetString()); + Assert.Equal(500, responseContentJson.GetProperty("status").GetInt32()); + } +} diff --git a/Doppler.HtmlEditorApi/ApiModels/ResourceCreated.cs b/Doppler.HtmlEditorApi/ApiModels/ResourceCreated.cs new file mode 100644 index 00000000..99f6b62b --- /dev/null +++ b/Doppler.HtmlEditorApi/ApiModels/ResourceCreated.cs @@ -0,0 +1,3 @@ +namespace Doppler.HtmlEditorApi.ApiModels; + +public record ResourceCreated(int createdResourceId); diff --git a/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs b/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs index 995bd5b1..a3df6934 100644 --- a/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs +++ b/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs @@ -5,6 +5,8 @@ using Doppler.HtmlEditorApi.DopplerSecurity; using Doppler.HtmlEditorApi.Repositories; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; namespace Doppler.HtmlEditorApi.Controllers @@ -21,7 +23,7 @@ public TemplatesController(ITemplateRepository templateRepository) } [Authorize(Policies.OwnResourceOrSuperUser)] - [HttpGet("/accounts/{accountName}/templates/{templateId}")] + [HttpGet("/accounts/{accountName}/templates/{templateId}", Name = "GetTemplate")] public async Task> GetTemplate(string accountName, int templateId) { // TODO: Considere refactoring accountName validation @@ -80,6 +82,37 @@ public async Task SaveTemplate(string accountName, int templateId return new OkObjectResult($"El template'{templateId}' del usuario '{accountName}' se guardó exitosamente."); } + [Authorize(Policies.OwnResourceOrSuperUser)] + [HttpPost("/accounts/{accountName}/templates/from-template/{baseTemplateId}")] + public async Task, CreatedAtRoute>> CreateTemplateFromTemplate(string accountName, int baseTemplateId) + { + var templateModel = await _templateRepository.GetOwnOrPublicTemplate(accountName, baseTemplateId); + if (templateModel == null) + { + return TypedResults.NotFound(new ProblemDetails() + { + Detail = "The template not exists or Inactive" + }); + } + + if (templateModel.Content is not UnlayerTemplateContentData) + { + throw new NotImplementedException($"Unsupported template content type {templateModel.Content.GetType()}"); + } + + // To avoid ambiguities + var newTemplate = templateModel with + { + TemplateId = 0, + IsPublic = false + }; + + var templateId = await _templateRepository.CreatePrivateTemplate(accountName, newTemplate); + + return TypedResults.CreatedAtRoute(new ResourceCreated(templateId), "GetTemplate", new { accountName, templateId }); + } + + [Authorize(Policies.OwnResourceOrSuperUser)] [HttpPost("/accounts/{accountName}/templates")] public Task CreateTemplate(string accountName, Template templateModel) diff --git a/Doppler.HtmlEditorApi/Repositories.DopplerDb/DopplerTemplateRepository.cs b/Doppler.HtmlEditorApi/Repositories.DopplerDb/DopplerTemplateRepository.cs index 0637032c..9e6234d6 100644 --- a/Doppler.HtmlEditorApi/Repositories.DopplerDb/DopplerTemplateRepository.cs +++ b/Doppler.HtmlEditorApi/Repositories.DopplerDb/DopplerTemplateRepository.cs @@ -59,4 +59,19 @@ public async Task UpdateTemplate(TemplateModel templateModel) await _dbContext.ExecuteAsync(updateTemplateQuery); } + + public Task CreatePrivateTemplate(string accountName, TemplateModel templateModel) + { + // To avoid ambiguities + if (templateModel.TemplateId > 0) + { + throw new ArgumentException("TemplateId should not be set to create a new private template", nameof(templateModel)); + } + if (templateModel.IsPublic) + { + throw new ArgumentException("IsPublic should be false to create a new private template", nameof(templateModel)); + } + + throw new NotImplementedException(); + } } diff --git a/Doppler.HtmlEditorApi/Repositories/ITemplateRepository.cs b/Doppler.HtmlEditorApi/Repositories/ITemplateRepository.cs index 3e19dda0..ab7c3546 100644 --- a/Doppler.HtmlEditorApi/Repositories/ITemplateRepository.cs +++ b/Doppler.HtmlEditorApi/Repositories/ITemplateRepository.cs @@ -7,4 +7,5 @@ public interface ITemplateRepository { Task GetOwnOrPublicTemplate(string accountName, int templateId); Task UpdateTemplate(TemplateModel templateModel); + Task CreatePrivateTemplate(string accountName, TemplateModel templateModel); } From e19d570ced9926419b8b6bb2301eb5b36f7f9065 Mon Sep 17 00:00:00 2001 From: Andres Moschini Date: Mon, 5 Dec 2022 09:11:26 -0300 Subject: [PATCH 2/4] refactor: use typed results in TemplatesController It also modifies the not-found responses, but our clients are not reading them yet. --- .../accounts/_/templates/_/GetTemplateTest.cs | 4 ++- .../accounts/_/templates/_/PutTemplateTest.cs | 8 +++-- .../Controllers/TemplatesController.cs | 29 ++++++++++++------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/GetTemplateTest.cs b/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/GetTemplateTest.cs index 411ad559..b94e28d2 100644 --- a/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/GetTemplateTest.cs +++ b/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/GetTemplateTest.cs @@ -284,6 +284,8 @@ public async Task GET_template_should_return_not_found_when_template_is_public(s // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal($"It is a public template, use /shared/templates/{idTemplate}", responseContent); + Assert.Contains($"\"detail\":\"It is a public template, use /shared/templates/{idTemplate}\"", responseContent); + Assert.Contains("\"title\":\"Not Found\"", responseContent); + Assert.Contains("\"status\":404", responseContent); } } diff --git a/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/PutTemplateTest.cs b/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/PutTemplateTest.cs index 1928b126..c8244447 100644 --- a/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/PutTemplateTest.cs +++ b/Doppler.HtmlEditorApi.Test/http/accounts/_/templates/_/PutTemplateTest.cs @@ -232,7 +232,9 @@ public async Task PUT_template_should_return_404_when_template_does_not_exist() // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal("Template not found, belongs to a different account, or it is a public template.", responseContent); + Assert.Contains("Template not found, belongs to a different account, or it is a public template", responseContent); + Assert.Contains("\"title\":\"Not Found\"", responseContent); + Assert.Contains("\"status\":404", responseContent); } [Fact] @@ -273,7 +275,9 @@ public async Task PUT_template_should_return_404_when_template_is_public() // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal("Template not found, belongs to a different account, or it is a public template.", responseContent); + Assert.Contains("\"detail\":\"Template not found, belongs to a different account, or it is a public template.\"", responseContent); + Assert.Contains("\"title\":\"Not Found\"", responseContent); + Assert.Contains("\"status\":404", responseContent); } [Fact] diff --git a/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs b/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs index a3df6934..7d58a9d3 100644 --- a/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs +++ b/Doppler.HtmlEditorApi/Controllers/TemplatesController.cs @@ -24,22 +24,28 @@ public TemplatesController(ITemplateRepository templateRepository) [Authorize(Policies.OwnResourceOrSuperUser)] [HttpGet("/accounts/{accountName}/templates/{templateId}", Name = "GetTemplate")] - public async Task> GetTemplate(string accountName, int templateId) + public async Task, Ok