Skip to content

Commit

Permalink
Attachment backend (#79)
Browse files Browse the repository at this point in the history
* Support uploading and downloading of attachments

* Application handlers

* Database operation

* Tests

* Interface for storage operations

* Implementation of storage interface

* Local development

* Update infra

* Require authorization on "Delete" endpoint because code quality requires it.

* Use IAttachmentStatusRepository for status operatons instead

* Block duplicates at application layer instead to avoid concurrency issue where a subsequent upload request id performed while first one is still uploading.

* Format
  • Loading branch information
Ceredron authored May 30, 2024
1 parent c85b93a commit e95e681
Show file tree
Hide file tree
Showing 23 changed files with 571 additions and 164 deletions.
8 changes: 4 additions & 4 deletions .azure/infrastructure/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ module postgresql '../modules/postgreSql/create.bicep' = {
}
}

module migrationsStorageAccount '../modules/storageAccount/create.bicep' = {
module storageAccount '../modules/storageAccount/create.bicep' = {
scope: resourceGroup
name: migrationsStorageAccountName
params: {
migrationsStorageAccountName: migrationsStorageAccountName
storageAccountName: migrationsStorageAccountName
location: location
fileshare: 'migrations'
}
Expand All @@ -87,12 +87,12 @@ module migrationsStorageAccount '../modules/storageAccount/create.bicep' = {
module containerAppEnv '../modules/containerAppEnvironment/main.bicep' = {
scope: resourceGroup
name: 'container-app-environment'
dependsOn: [migrationsStorageAccount]
dependsOn: [storageAccount]
params: {
keyVaultName: sourceKeyVaultName
location: location
namePrefix: namePrefix
migrationsStorageAccountName: migrationsStorageAccountName
storageAccountName: migrationsStorageAccountName
}
}
output resourceGroupName string = resourceGroup.name
Expand Down
6 changes: 6 additions & 0 deletions .azure/modules/containerApp/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var containerAppEnvVars = [
{ name: 'ASPNETCORE_ENVIRONMENT', value: environment }
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', secretRef: 'application-insights-connection-string' }
{ name: 'DatabaseOptions__ConnectionString', secretRef: 'correspondence-ado-connection-string' }
{ name: 'AttachmentStorageOptions__ConnectionString', secretRef: 'storage-account-key'}
{ name: 'AzureResourceManagerOptions__SubscriptionId', value: subscription_id }
{ name: 'AzureResourceManagerOptions__Location', value: 'norwayeast' }
{ name: 'AzureResourceManagerOptions__Environment', value: environment }
Expand Down Expand Up @@ -69,6 +70,11 @@ resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
keyVaultUrl: '${keyVaultUrl}/secrets/correspondence-ado-connection-string'
name: 'correspondence-ado-connection-string'
}
{
identity: principal_id
keyVaultUrl: '${keyVaultUrl}/secrets/storage-account-key'
name: 'storage-account-key'
}
]
}

Expand Down
16 changes: 13 additions & 3 deletions .azure/modules/containerAppEnvironment/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ param location string
param namePrefix string
@secure()
param keyVaultName string
param migrationsStorageAccountName string
param storageAccountName string

resource log_analytics_workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: '${namePrefix}-log'
Expand Down Expand Up @@ -41,7 +41,7 @@ resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2023-11-02-p
}

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' existing = {
name: migrationsStorageAccountName
name: storageAccountName
}

resource containerAppEnvironmentStorage 'Microsoft.App/managedEnvironments/storages@2023-11-02-preview' = {
Expand All @@ -51,7 +51,7 @@ resource containerAppEnvironmentStorage 'Microsoft.App/managedEnvironments/stora
azureFile: {
accessMode: 'ReadOnly'
accountKey: storageAccount.listKeys().keys[0].value
accountName: migrationsStorageAccountName
accountName: storageAccountName
shareName: 'migrations'
}
}
Expand All @@ -77,4 +77,14 @@ module containerAppEnvIdSecret '../keyvault/upsertSecret.bicep' = {
}
}

var storageAccountKeySecretName = 'storage-account-key'
module storageAccountKeySecret '../keyvault/upsertSecret.bicep' = {
name: storageAccountKeySecretName
params: {
destKeyVaultName: keyVaultName
secretName: storageAccountKeySecretName
secretValue: storageAccount.listKeys().keys[0].value
}
}

output containerAppEnvironmentId string = containerAppEnvironment.id
15 changes: 12 additions & 3 deletions .azure/modules/storageAccount/create.bicep
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
@secure()
param migrationsStorageAccountName string
param storageAccountName string
param fileshare string
param location string

resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = {
name: migrationsStorageAccountName
name: storageAccountName
location: location
kind: 'StorageV2'
sku: {
Expand All @@ -20,10 +20,19 @@ resource storageAccountFileServices 'Microsoft.Storage/storageAccounts/fileServi
parent: storageAccount
}


resource storageAccountFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-01-01' = {
name: fileshare
parent: storageAccountFileServices
}

resource storageAccountBlobServices 'Microsoft.Storage/storageAccounts/blobServices@2023-04-01' = {
name: 'default'
parent: storageAccount
}

resource storageAccountAttachmentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-04-01' = {
name: 'attachments'
parent: storageAccountBlobServices
}

output storageAccountId string = storageAccount.id
98 changes: 97 additions & 1 deletion Test/Altinn.Correspondence.Tests/AttachmentControllerTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.Net.Http.Json;
using Altinn.Correspondece.Tests.Factories;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

namespace Altinn.Correspondence.Tests;

Expand Down Expand Up @@ -39,4 +42,97 @@ public async Task GetAttachmentDetails()
var getAttachmentOverviewResponse = await _client.GetAsync($"correspondence/api/v1/attachment/{attachmentId}/details");
Assert.True(getAttachmentOverviewResponse.IsSuccessStatusCode, await getAttachmentOverviewResponse.Content.ReadAsStringAsync());
}

[Fact]
public async Task UploadAttachmentData_WhenAttachmentDoesNotExist_ReturnsNotFound()
{
var attachmentData = new byte[] { 1, 2, 3, 4 };
var content = new ByteArrayContent(attachmentData);

var uploadResponse = await _client.PostAsync("correspondence/api/v1/attachment/00000000-0100-0000-0000-000000000000/upload", content);

Assert.Equal(HttpStatusCode.NotFound, uploadResponse.StatusCode);
}

[Fact]
public async Task UploadAttachmentData_WhenAttachmentExists_Succeeds()
{
var attachment = InitializeAttachmentFactory.BasicAttachment();
var initializeResponse = await _client.PostAsJsonAsync("correspondence/api/v1/attachment", attachment);
initializeResponse.EnsureSuccessStatusCode();

var attachmentId = await initializeResponse.Content.ReadAsStringAsync();
var attachmentData = new byte[] { 1, 2, 3, 4 };
var content = new ByteArrayContent(attachmentData);

var uploadResponse = await _client.PostAsync($"correspondence/api/v1/attachment/{attachmentId}/upload", content);

Assert.True(uploadResponse.IsSuccessStatusCode, await uploadResponse.Content.ReadAsStringAsync());
}

[Fact]
public async Task UploadAttachmentData_UploadsTwice_FailsSecondAttempt()
{
var attachment = InitializeAttachmentFactory.BasicAttachment();
var initializeResponse = await _client.PostAsJsonAsync("correspondence/api/v1/attachment", attachment);
initializeResponse.EnsureSuccessStatusCode();

var attachmentId = await initializeResponse.Content.ReadAsStringAsync();
var attachmentData = new byte[] { 1, 2, 3, 4 };
var content = new ByteArrayContent(attachmentData);

// First upload
var firstUploadResponse = await _client.PostAsync($"correspondence/api/v1/attachment/{attachmentId}/upload", content);
Assert.True(firstUploadResponse.IsSuccessStatusCode, await firstUploadResponse.Content.ReadAsStringAsync());

// Second upload
content = new ByteArrayContent(attachmentData);
var secondUploadResponse = await _client.PostAsync($"correspondence/api/v1/attachment/{attachmentId}/upload", content);

Assert.False(secondUploadResponse.IsSuccessStatusCode);
Assert.Equal(HttpStatusCode.BadRequest, secondUploadResponse.StatusCode);
}

[Fact]
public async Task UploadAttachmentData_UploadFails_GetErrorMessage()
{
var attachment = InitializeAttachmentFactory.BasicAttachment();
var initializeResponse = await _client.PostAsJsonAsync("correspondence/api/v1/attachment", attachment);
initializeResponse.EnsureSuccessStatusCode();

var attachmentId = await initializeResponse.Content.ReadAsStringAsync();
var content = new StreamContent(new MemoryStream([])); // Empty content to simulate failure

var uploadResponse = await _client.PostAsync($"correspondence/api/v1/attachment/{attachmentId}/upload", content);

Assert.False(uploadResponse.IsSuccessStatusCode);
var errorMessage = await uploadResponse.Content.ReadAsStringAsync();
var error = JsonSerializer.Deserialize<ProblemDetails>(errorMessage);
Assert.NotNull(error);
}

[Fact]
public async Task UploadAttachmentData_Succeeds_DownloadedBytesAreSame()
{
var attachment = InitializeAttachmentFactory.BasicAttachment();
var initializeResponse = await _client.PostAsJsonAsync("correspondence/api/v1/attachment", attachment);
initializeResponse.EnsureSuccessStatusCode();

var attachmentId = await initializeResponse.Content.ReadAsStringAsync();
var originalAttachmentData = new byte[] { 1, 2, 3, 4 };
var content = new ByteArrayContent(originalAttachmentData);

// Upload the attachment data
var uploadResponse = await _client.PostAsync($"correspondence/api/v1/attachment/{attachmentId}/upload", content);
uploadResponse.EnsureSuccessStatusCode();

// Download the attachment data
var downloadResponse = await _client.GetAsync($"correspondence/api/v1/attachment/{attachmentId}/download");
downloadResponse.EnsureSuccessStatusCode();

var downloadedAttachmentData = await downloadResponse.Content.ReadAsByteArrayAsync();

// Assert that the uploaded and downloaded bytes are the same
Assert.Equal(originalAttachmentData, downloadedAttachmentData);
}
}
28 changes: 22 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,28 @@ services:
image: 'postgres:latest'
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 30s
retries: 30
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: correspondence
POSTGRES_DB: correspondence
storage:
image: mcr.microsoft.com/azure-storage/azurite:latest
ports:
- "10000:10000"
- "10001:10001"
healthcheck:
test: nc 127.0.0.1 10000 -z
interval: 1s
retries: 30
storage_init:
image: mcr.microsoft.com/azure-cli:latest
command:
- /bin/sh
- -c
- |
az storage container create --name attachments
depends_on:
storage:
condition: service_healthy
environment:
AZURE_STORAGE_CONNECTION_STRING: DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://storage:10000/devstoreaccount1;
Loading

0 comments on commit e95e681

Please sign in to comment.