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

Isolated testing #450

Merged
merged 19 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions FU.API/FU.API/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
bin/
obj/
Dockerfile
docker-compose.yml
README.md
.env
27 changes: 25 additions & 2 deletions FU.API/FU.API/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly ISearchService _searchService;
private readonly IStorageService _storageService;

public UsersController(IUserService userService, ISearchService searchService)
public UsersController(IUserService userService, ISearchService searchService, IStorageService storageService)
{
_userService = userService;
_searchService = searchService;
_storageService = storageService;
}

[HttpGet]
Expand Down Expand Up @@ -74,7 +76,28 @@ public async Task<IActionResult> UpdateProfile([FromBody] UserProfile profileCha
// Overrides any client given id that may differ from userId.
profileChanges.Id = userId;

var newProfile = await _userService.UpdateUserProfile(profileChanges);
// Make sure its an image already in our blob storage
// Otherwise we are unure if the image is cropped, resized, and in the right format
if (profileChanges?.PfpUrl is not null)
{
Uri avatarUri;

try
{
avatarUri = new(profileChanges.PfpUrl);
}
catch (UriFormatException)
{
throw new UnprocessableException("Invalid avatar url format.");
}

if (!(await _storageService.IsInStorageAsync(avatarUri)))
{
throw new UnprocessableException("Invalid profile picture. The image must be uploaded to our storage system");
}
}

var newProfile = await _userService.UpdateUserProfile(profileChanges!);

return Ok(newProfile);
}
Expand Down
15 changes: 15 additions & 0 deletions FU.API/FU.API/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build

# copy everything and build app
WORKDIR /source
COPY . .
RUN mkdir /app
RUN dotnet publish -o /app

# final stage/image
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /app
COPY --from=build /app ./
ENTRYPOINT ["dotnet", "FU.API.dll"]

2 changes: 1 addition & 1 deletion FU.API/FU.API/FU.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.7" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
1 change: 0 additions & 1 deletion FU.API/FU.API/Helpers/AuthHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,4 @@ private static string GetJwtSecretFromConfig(this IConfiguration configuration)
{
return configuration[ConfigKey.JwtSecret] ?? string.Empty;
}

}
2 changes: 2 additions & 0 deletions FU.API/FU.API/Interfaces/IStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ public interface IStorageService
public Task<Uri> UploadAsync(Stream stream, string fileName);

public Task DeleteOldUnusedFilesAsync();

public Task<bool> IsInStorageAsync(Uri uri);
}
8 changes: 4 additions & 4 deletions FU.API/FU.API/Migrations/20240317012258_ConfirmAccount.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable
#nullable disable

namespace FU.API.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;

/// <inheritdoc />
public partial class ConfirmAccount : Migration
{
Expand All @@ -22,7 +22,7 @@ protected override void Up(MigrationBuilder migrationBuilder)
table: "Users",
type: "text",
nullable: false,
defaultValue: "");
defaultValue: string.Empty);
}

/// <inheritdoc />
Expand Down
42 changes: 31 additions & 11 deletions FU.API/FU.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ private static void Main(string[] args)
{
WebApplication app = BuildApp(args);
ConfigureApp(app);
ApplyDbMigrations(app.Configuration);
app.Run();
}

Expand Down Expand Up @@ -56,6 +57,17 @@ private static void ConfigureApp(in WebApplication app)
app.MapHub<ChatHub>("/chathub");
}

private static void ApplyDbMigrations(IConfiguration config)
{
// Create a DbContext
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql(config[ConfigKey.ConnectionString]);
using AppDbContext dbContext = new(optionsBuilder.Options);

// Dangerous. See https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying?tabs=dotnet-core-cli#apply-migrations-at-runtime
dbContext.Database.Migrate();
}

private static WebApplication BuildApp(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
Expand Down Expand Up @@ -115,7 +127,25 @@ private static WebApplication BuildApp(string[] args)
builder.Services.AddScoped<IRelationService, RelationService>();
builder.Services.AddScoped<ICommonService, CommonService>();
builder.Services.AddScoped<IStorageService, AzureBlobStorageService>();
builder.Services.AddSingleton<IEmailService, EmailService>();

if (builder.Configuration[ConfigKey.BaseSpaUrl] is null || builder.Configuration[ConfigKey.EmailConnectionString] is null)
{
if (builder.Configuration[ConfigKey.EmailConnectionString] is null)
{
Console.WriteLine($"Email service connection string is not configured. Missing {ConfigKey.EmailConnectionString}. See README for adding. Will use mock email service.");
}

if (builder.Configuration[ConfigKey.BaseSpaUrl] is null)
{
Console.WriteLine($"The base SPA Url is not configured. Missing {ConfigKey.BaseSpaUrl}. See README for adding. Will use mock email service.");
}

builder.Services.AddSingleton<IEmailService, MockEmailService>();
}
else
{
builder.Services.AddSingleton<IEmailService, EmailService>();
}

builder.Services.AddSignalR(options =>
{
Expand Down Expand Up @@ -200,15 +230,5 @@ private static void AssertCriticalConfigValuesExist(in ConfigurationManager conf
{
throw new Exception($"Storage connection string is not configured. Missing {ConfigKey.StorageConnectionString}. See README for adding.");
}

if (config[ConfigKey.EmailConnectionString] is null)
{
throw new Exception($"Email service connection string is not configured. Missing {ConfigKey.EmailConnectionString}. See README for adding.");
}

if (config[ConfigKey.BaseSpaUrl] is null)
{
throw new Exception($"The base SPA Url is not configured. Missing {ConfigKey.BaseSpaUrl}. See README for adding.");
}
}
}
26 changes: 19 additions & 7 deletions FU.API/FU.API/Services/AzureBlobStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public AzureBlobStorageService(IConfiguration config, AppDbContext dbContext, IL

public async Task<Uri> UploadAsync(Stream stream, string fileName)
{
BlobClient blob = GetBlobClient(fileName);
BlobClient blob = await GetBlobClientAsync(fileName);

if (await blob.ExistsAsync())
{
Expand All @@ -43,7 +43,7 @@ public async Task<Uri> UploadAsync(Stream stream, string fileName)

public async Task DeleteOldUnusedFilesAsync()
{
BlobContainerClient container = GetBlobContainer();
BlobContainerClient container = await GetBlobContainerAsync();

// Assumes file name like 355975c6-7c1e-42dc-99c6-a62ddacf0452.jpg with a length of 40
// Assumes PfpUrl doesn't have any parameters
Expand Down Expand Up @@ -86,20 +86,32 @@ public async Task DeleteOldUnusedFilesAsync()
}
}

public async Task<bool> IsInStorageAsync(Uri uri)
{
BlobClient blobClient = new(uri);

return await blobClient.ExistsAsync();
}

private async Task<bool> DeleteFileAsync(string fileName)
{
BlobClient blob = GetBlobClient(fileName);
BlobClient blob = await GetBlobClientAsync(fileName);

return await blob.DeleteIfExistsAsync();
}

private BlobContainerClient GetBlobContainer()
private async Task<BlobContainerClient> GetBlobContainerAsync()
{
return new BlobContainerClient(_config[ConfigKey.StorageConnectionString], _config[ConfigKey.AvatarContainerName]);
BlobContainerClient containerClient = new(_config[ConfigKey.StorageConnectionString], _config[ConfigKey.AvatarContainerName]);

await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob);

return containerClient;
}

private BlobClient GetBlobClient(string blobName)
private async Task<BlobClient> GetBlobClientAsync(string blobName)
{
return GetBlobContainer().GetBlobClient(blobName);
BlobContainerClient containerClient = await GetBlobContainerAsync();
return containerClient.GetBlobClient(blobName);
}
}
34 changes: 34 additions & 0 deletions FU.API/FU.API/Services/MockEmailService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace FU.API.Services;

using FU.API.Data;
using FU.API.Exceptions;
using FU.API.Interfaces;
using FU.API.Models;
using System.Threading.Tasks;

/// <summary>
/// A mock email service.
/// </summary>
/// <remarks>
/// Auto verifies an account without sending an email. Used for testing and development purposes.
/// </remarks>
public class MockEmailService : IEmailService
{
private readonly AppDbContext _dbContext;

public MockEmailService(AppDbContext dbContext)
{
_dbContext = dbContext;
}

public async Task SendEmail(EmailType emailType, ApplicationUser user)
{
var userEntity = _dbContext.Users.Find(user.UserId)
?? throw new NotFoundException("User not found", "The requested user was not found");

userEntity.AccountConfirmed = true;

_dbContext.Update(userEntity);
await _dbContext.SaveChangesAsync();
}
}
8 changes: 0 additions & 8 deletions FU.API/FU.API/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ namespace FU.API.Services;
using FU.API.Helpers;
using FU.API.Interfaces;
using Microsoft.EntityFrameworkCore;
using FU.API.Exceptions;

public class UserService : CommonService, IUserService
{
Expand Down Expand Up @@ -39,13 +38,6 @@ public UserService(AppDbContext dbContext)

if (profileChanges.PfpUrl is not null)
{
// Make sure its an image already in our blob storage
// Otherwise we are unure if the image is cropped, resized, and in the right format
if (!profileChanges.PfpUrl.Contains("storagefu.blob.core.windows.net"))
{
throw new UnprocessableException("Invalid profile picture. The image must be uploaded to our storage system");
}

user.PfpUrl = profileChanges.PfpUrl;
}

Expand Down
47 changes: 47 additions & 0 deletions FU.API/FU.API/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
services:
fu-api:
build:
context: .
depends_on:
db:
condition: service_healthy
storage:
condition: service_started
environment:
JWT_SECRET: my-32-character-ultra-secure-and-ultra-long-secret
CONNECTION_STRING: Host=db; Database=fu_db; Username=fu_user; Password=fu_pass
STORAGE_CONNECTION_STRING: DefaultEndpointsProtocol=http;AccountName=account1;AccountKey=key1;BlobEndpoint=http://storage:10000/account1
AVATAR_CONTAINER_NAME: container1
ports:
- "5278:80"
db:
image: postgres:alpine
environment:
POSTGRES_USER: fu_user
POSTGRES_PASSWORD: fu_pass
POSTGRES_DB: fu_db
ports:
- "5432:5432"
healthcheck:
test:
[
"CMD",
"pg_isready",
"-h",
"localhost",
"-p",
"5432",
"-U",
"fu_user",
"-d",
"fu_db",
]
interval: 2s
timeout: 2s
retries: 10
storage:
image: mcr.microsoft.com/azure-storage/azurite
environment:
AZURITE_ACCOUNTS: account1:key1
ports:
- "10000:10000"
Loading
Loading