Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-factored code to CQRS #214

Merged
merged 13 commits into from
Dec 8, 2023
8 changes: 7 additions & 1 deletion Altinn.Broker.sln
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
Dockerfile = Dockerfile
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.Broker.Tests", "Test\Altinn.Broker.Tests\Altinn.Broker.Tests.csproj", "{DDFFAD18-D87F-459C-9CEF-9F11B541C627}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.Broker.Tests", "Test\Altinn.Broker.Tests\Altinn.Broker.Tests.csproj", "{DDFFAD18-D87F-459C-9CEF-9F11B541C627}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.Broker.Application", "src\Altinn.Broker.Application\Altinn.Broker.Application.csproj", "{32DD52AF-A024-4359-9983-698AF514BB31}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -45,6 +47,10 @@ Global
{DDFFAD18-D87F-459C-9CEF-9F11B541C627}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DDFFAD18-D87F-459C-9CEF-9F11B541C627}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DDFFAD18-D87F-459C-9CEF-9F11B541C627}.Release|Any CPU.Build.0 = Release|Any CPU
{32DD52AF-A024-4359-9983-698AF514BB31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{32DD52AF-A024-4359-9983-698AF514BB31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{32DD52AF-A024-4359-9983-698AF514BB31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{32DD52AF-A024-4359-9983-698AF514BB31}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ If you need to re-initialize the database during local development, you can dele

### Authorization

To get access to the Broker API, a consumer needs to use a Maskinporten token. Recipients should use the scope altinn:broker.read and senders should use the scope altinn:broker.write. Tokens with both scopes also work. You can create a Maskinporten integration here:
To get access to the Broker API in staging/production, a consumer needs to use a Maskinporten integration. Recipients should use the scope altinn:broker.read and senders should use the scope altinn:broker.write. Tokens with both scopes also work. You can create a Maskinporten integration here:
https://selvbetjening-samarbeid-ver2.difi.no/integrations

For more on Maskinporten tokens see:
https://docs.digdir.no/docs/Maskinporten/maskinporten_guide_apikonsument

When running locally for development, you can use any Maskinporten token. It is not validated.

### Formatting

Formatting of the code base is handled by Dotnet format. [See how to configure it to format-on-save in Visual Studio here.](https://learn.microsoft.com/en-us/community/content/how-to-enforce-dotnet-format-using-editorconfig-github-actions#3---formatting-your-code-locally)
Expand Down
15 changes: 15 additions & 0 deletions Test/Altinn.Broker.Tests/Factories/FileInitializeExtTestFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Altinn.Broker.Models;

namespace Altinn.Broker.Tests.Factories;
internal static class FileInitializeExtTestFactory
{
internal static FileInitalizeExt BasicFile() => new FileInitalizeExt()
{
Checksum = null,
FileName = "input.txt",
PropertyList = [],
Recipients = new List<string> { "0192:986252932" },
Sender = "0192:991825827",
SendersFileReference = "test-data"
};
}
77 changes: 51 additions & 26 deletions Test/Altinn.Broker.Tests/FileControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
using Altinn.Broker.Core.Models;
using Altinn.Broker.Enums;
using Altinn.Broker.Models;
using Altinn.Broker.Tests.Factories;
using Altinn.Broker.Tests.Helpers;

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Identity.Web;

using Xunit;

Expand All @@ -21,12 +21,6 @@ public class FileControllerTests : IClassFixture<CustomWebApplicationFactory>
private readonly HttpClient _recipientClient;
private readonly JsonSerializerOptions _responseSerializerOptions;

/**
* Inject a mock bearer configuration that does not verify anything.
* Generate our own JWT with correct scope, expiry and issuer.
* Set it as default request header
* */

public FileControllerTests(CustomWebApplicationFactory factory)
{
_factory = factory;
Expand All @@ -44,49 +38,80 @@ public FileControllerTests(CustomWebApplicationFactory factory)


[Fact]
public async Task WhenAllIsOk_NormalFlow_Success()
public async Task NormalFlow_WhenAllIsOK_Success()
{
var initializeFileResponse = await _senderClient.PostAsJsonAsync("broker/api/v1/file", new FileInitalizeExt()
{
Checksum = null,
FileName = "input.txt",
PropertyList = [],
Recipients = new List<string> { "0192:986252932" },
Sender = "0192:991825827",
SendersFileReference = "test-data"
});
// Initialize
var initializeFileResponse = await _senderClient.PostAsJsonAsync("broker/api/v1/file", FileInitializeExtTestFactory.BasicFile());
Assert.Equal(System.Net.HttpStatusCode.OK, initializeFileResponse.StatusCode);
var fileId = await initializeFileResponse.Content.ReadAsStringAsync();
var fileAfterInitialize = await _senderClient.GetFromJsonAsync<FileOverviewExt>($"broker/api/v1/file/{fileId}", _responseSerializerOptions);
Assert.NotNull(fileAfterInitialize);
Assert.True(fileAfterInitialize.FileStatus == FileStatusExt.Initialized);

var initializedFile = await _senderClient.GetFromJsonAsync<FileOverviewExt>($"broker/api/v1/file/{fileId}", _responseSerializerOptions);
Assert.NotNull(initializedFile);
Assert.True(initializedFile.FileStatus == FileStatusExt.Initialized);

// Upload
var uploadedFileBytes = Encoding.UTF8.GetBytes("This is the contents of the uploaded file");
using (var content = new ByteArrayContent(uploadedFileBytes))
{
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var uploadResponse = await _senderClient.PostAsync($"broker/api/v1/file/{fileId}/upload", content);
Assert.True(uploadResponse.IsSuccessStatusCode);
}
var fileAfterUpload = await _senderClient.GetFromJsonAsync<FileOverviewExt>($"broker/api/v1/file/{fileId}", _responseSerializerOptions);
Assert.NotNull(fileAfterUpload);
Assert.True(fileAfterUpload.FileStatus == FileStatusExt.Published); // When running integration test this happens instantly as of now.

var uploadedFile = await _senderClient.GetFromJsonAsync<FileOverviewExt>($"broker/api/v1/file/{fileId}", _responseSerializerOptions);
Assert.NotNull(uploadedFile);
Assert.True(uploadedFile.FileStatus == FileStatusExt.Published); // When running integration test this happens instantly as of now.

// Download
var downloadedFile = await _recipientClient.GetAsync($"broker/api/v1/file/{fileId}/download");
var downloadedFileBytes = await downloadedFile.Content.ReadAsByteArrayAsync();
Assert.Equal(uploadedFileBytes, downloadedFileBytes);

// Details
var downloadedFileDetails = await _senderClient.GetFromJsonAsync<FileStatusDetailsExt>($"broker/api/v1/file/{fileId}/details", _responseSerializerOptions);
Assert.NotNull(downloadedFileDetails);
Assert.True(downloadedFileDetails.FileStatus == FileStatusExt.Published);
Assert.Contains(downloadedFileDetails.RecipientFileStatusHistory, recipient => recipient.RecipientFileStatusCode == RecipientFileStatusExt.DownloadStarted);

// Confirm
await _recipientClient.PostAsync($"broker/api/v1/file/{fileId}/confirmdownload", null);

var confirmedFileDetails = await _senderClient.GetFromJsonAsync<FileStatusDetailsExt>($"broker/api/v1/file/{fileId}/details", _responseSerializerOptions);
Assert.NotNull(confirmedFileDetails);
Assert.True(confirmedFileDetails.FileStatus == FileStatusExt.AllConfirmedDownloaded);
Assert.Contains(confirmedFileDetails.RecipientFileStatusHistory, recipient => recipient.RecipientFileStatusCode == RecipientFileStatusExt.DownloadConfirmed);
}

[Fact]
public async Task DownloadFile_WhenFileDownloadsTwice_ShowLastOccurenceInOverview()
{
// Arrange
var initializeFileResponse = await _senderClient.PostAsJsonAsync("broker/api/v1/file", FileInitializeExtTestFactory.BasicFile());
var fileId = await initializeFileResponse.Content.ReadAsStringAsync();
var initializedFile = await _senderClient.GetFromJsonAsync<FileOverviewExt>($"broker/api/v1/file/{fileId}", _responseSerializerOptions);
Assert.NotNull(initializedFile);
var uploadedFileBytes = Encoding.UTF8.GetBytes("This is the contents of the uploaded file");
using (var content = new ByteArrayContent(uploadedFileBytes))
{
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var uploadResponse = await _senderClient.PostAsync($"broker/api/v1/file/{fileId}/upload", content);
Assert.True(uploadResponse.IsSuccessStatusCode);
}
var uploadedFile = await _senderClient.GetFromJsonAsync<FileOverviewExt>($"broker/api/v1/file/{fileId}", _responseSerializerOptions);

// Act
var downloadedFile1 = await _recipientClient.GetAsync($"broker/api/v1/file/{fileId}/download");
await Task.Delay(1000);
var downloadedFile2 = await _recipientClient.GetAsync($"broker/api/v1/file/{fileId}/download");
var downloadedFile1Bytes = await downloadedFile1.Content.ReadAsByteArrayAsync();
var downloadedFile2Bytes = await downloadedFile2.Content.ReadAsByteArrayAsync();
Assert.Equal(downloadedFile1Bytes, downloadedFile2Bytes);

// Assert
var downloadedFileDetails = await _senderClient.GetFromJsonAsync<FileStatusDetailsExt>($"broker/api/v1/file/{fileId}/details", _responseSerializerOptions);
Assert.NotNull(downloadedFileDetails);
Assert.Contains(downloadedFileDetails.RecipientFileStatusHistory, recipient => recipient.RecipientFileStatusCode == RecipientFileStatusExt.DownloadStarted);
var downloadStartedEvents = downloadedFileDetails.RecipientFileStatusHistory.Where(recipientFileStatus => recipientFileStatus.RecipientFileStatusCode == RecipientFileStatusExt.DownloadStarted);
Assert.NotNull(downloadStartedEvents);
Assert.Equal(2, downloadStartedEvents.Count());
var lastEvent = downloadStartedEvents.OrderBy(recipientFileStatus => recipientFileStatus.RecipientFileStatusChanged).Last();
Assert.Equal(lastEvent.RecipientFileStatusChanged, downloadedFileDetails.Recipients.FirstOrDefault(recipient => recipient.Recipient == lastEvent.Recipient)?.CurrentRecipientFileStatusChanged);
}
}
27 changes: 27 additions & 0 deletions src/Altinn.Broker.Application/Altinn.Broker.Application.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<Folder Include="GetFileOverviewQuery\" />
<Folder Include="GetFileDetailsQuery\" />
<Folder Include="GetFilesQuery\" />
<Folder Include="UploadFileCommand\" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="OneOf" Version="3.0.263" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Altinn.Broker.Core\Altinn.Broker.Core.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Altinn.Broker.Application;
using Altinn.Broker.Application.ConfirmDownloadCommand;
using Altinn.Broker.Core.Application;
using Altinn.Broker.Core.Domain.Enums;
using Altinn.Broker.Core.Repositories;

using Microsoft.Extensions.Logging;

using OneOf;

public class ConfirmDownloadCommandHandler : IHandler<ConfirmDownloadCommandRequest, ConfirmDownloadCommandResponse>
{
private readonly IServiceOwnerRepository _serviceOwnerRepository;
private readonly IFileRepository _fileRepository;
private readonly ILogger<ConfirmDownloadCommandHandler> _logger;

public ConfirmDownloadCommandHandler(IServiceOwnerRepository serviceOwnerRepository, IFileRepository fileRepository, ILogger<ConfirmDownloadCommandHandler> logger)
{
_serviceOwnerRepository = serviceOwnerRepository;
_fileRepository = fileRepository;
_logger = logger;
}
public async Task<OneOf<ConfirmDownloadCommandResponse, Error>> Process(ConfirmDownloadCommandRequest request)
{
var serviceOwner = await _serviceOwnerRepository.GetServiceOwner(request.Supplier);
if (serviceOwner is null)
{
return Errors.ServiceOwnerNotConfigured;
};
var file = await _fileRepository.GetFile(request.FileId);
if (file is null)
{
return Errors.FileNotFound;
}
if (!file.ActorEvents.Any(actorEvent => actorEvent.Actor.ActorExternalId == request.Consumer))
{
return Errors.FileNotFound;
}
if (string.IsNullOrWhiteSpace(file?.FileLocation))
{
return Errors.NoFileUploaded;
}

await _fileRepository.AddReceipt(request.FileId, ActorFileStatus.DownloadConfirmed, request.Consumer);
var recipientStatuses = file.ActorEvents
.Where(actorEvent => actorEvent.Actor.ActorExternalId != file.Sender && actorEvent.Actor.ActorExternalId != request.Consumer)
.GroupBy(actorEvent => actorEvent.Actor.ActorExternalId)
.Select(group => group.Max(statusEvent => statusEvent.Status))
.ToList();
bool shouldConfirmAll = recipientStatuses.All(status => status >= ActorFileStatus.DownloadConfirmed);
await _fileRepository.InsertFileStatus(request.FileId, FileStatus.AllConfirmedDownloaded);

return new ConfirmDownloadCommandResponse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

namespace Altinn.Broker.Application.ConfirmDownloadCommand;
public class ConfirmDownloadCommandRequest
{
public Guid FileId { get; set; }
public string Consumer { get; set; }
public string Supplier { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Altinn.Broker.Application.ConfirmDownloadCommand;

public class ConfirmDownloadCommandResponse
{

}
21 changes: 21 additions & 0 deletions src/Altinn.Broker.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Altinn.Broker.Application.DownloadFileQuery;
using Altinn.Broker.Application.GetFileDetailsQuery;
using Altinn.Broker.Application.GetFileOverviewQuery;
using Altinn.Broker.Application.InitializeFileCommand;
using Altinn.Broker.Application.UploadFileCommand;

using Microsoft.Extensions.DependencyInjection;

namespace Altinn.Broker.Application;
public static class DependencyInjection
{
public static void AddApplicationHandlers(this IServiceCollection services)
{
services.AddScoped<InitializeFileCommandHandler>();
services.AddScoped<UploadFileCommandHandler>();
services.AddScoped<GetFileOverviewQueryHandler>();
services.AddScoped<GetFileDetailsQueryHandler>();
services.AddScoped<DownloadFileQueryHandler>();
services.AddScoped<ConfirmDownloadCommandHandler>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Altinn.Broker.Core.Application;
using Altinn.Broker.Core.Domain.Enums;
using Altinn.Broker.Core.Repositories;

using Microsoft.Extensions.Logging;

using OneOf;

namespace Altinn.Broker.Application.DownloadFileQuery;
public class DownloadFileQueryHandler : IHandler<DownloadFileQueryRequest, DownloadFileQueryResponse>
{
private readonly IServiceOwnerRepository _serviceOwnerRepository;
private readonly IFileRepository _fileRepository;
private readonly IBrokerStorageService _brokerStorageService;
private readonly ILogger<DownloadFileQueryHandler> _logger;

public DownloadFileQueryHandler(IServiceOwnerRepository serviceOwnerRepository, IFileRepository fileRepository, IBrokerStorageService brokerStorageService, ILogger<DownloadFileQueryHandler> logger)
{
_serviceOwnerRepository = serviceOwnerRepository;
_fileRepository = fileRepository;
_brokerStorageService = brokerStorageService;
_logger = logger;
}

public async Task<OneOf<DownloadFileQueryResponse, Error>> Process(DownloadFileQueryRequest request)
{
var serviceOwner = await _serviceOwnerRepository.GetServiceOwner(request.Supplier);
if (serviceOwner is null)
{
return Errors.ServiceOwnerNotConfigured;
};
var file = await _fileRepository.GetFile(request.FileId);
if (file is null)
{
return Errors.FileNotFound;
}
if (!file.ActorEvents.Any(actorEvent => actorEvent.Actor.ActorExternalId == request.Consumer))
{
return Errors.FileNotFound;
}
if (string.IsNullOrWhiteSpace(file?.FileLocation))
{
return Errors.NoFileUploaded;
}
var downloadStream = await _brokerStorageService.DownloadFile(serviceOwner, file);
await _fileRepository.AddReceipt(request.FileId, ActorFileStatus.DownloadStarted, request.Consumer);
return new DownloadFileQueryResponse()
{
Filename = file.Filename,
Stream = downloadStream
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

namespace Altinn.Broker.Application.DownloadFileQuery;
public class DownloadFileQueryRequest
{
public Guid FileId { get; set; }
public string Supplier { get; set; }
public string Consumer { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Altinn.Broker.Application.DownloadFileQuery;
public class DownloadFileQueryResponse
{
public string Filename { get; set; }
public Stream Stream { get; set; }
}
14 changes: 14 additions & 0 deletions src/Altinn.Broker.Application/Errors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Net;

namespace Altinn.Broker.Application;

public record Error(int ErrorCode, string Message, HttpStatusCode StatusCode);

internal static class Errors
{
public static Error FileNotFound = new Error(1, "The requested file was not found", HttpStatusCode.NotFound);
public static Error WrongTokenForSender = new Error(2, "You must use a bearer token that belongs to the sender", HttpStatusCode.Unauthorized);
public static Error ServiceOwnerNotConfigured = new Error(3, "Service owner needs to be configured to use the broker API", HttpStatusCode.BadRequest);
public static Error ServiceOwnerNotReadyInfrastructure = new Error(4, "Service owner infrastructure is not ready.", HttpStatusCode.UnprocessableEntity);
public static Error NoFileUploaded = new Error(5, "No file uploaded yet", HttpStatusCode.BadRequest);
}
Loading