Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Local-only SQL Server password used by docker compose and the Aspire AppHost.
# This file is optional; docker-compose.yml already has the same default.
MSSQL_SA_PASSWORD=LocalDev_Sql#2026
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,6 @@ FodyWeavers.xsd

# JetBrains Rider
*.sln.iml

# Local docker compose overrides
.env
1 change: 1 addition & 0 deletions Authorization.API.AppHost/Authorization.API.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.0.0" />
<PackageReference Include="Aspire.Hosting.SqlServer" Version="9.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
13 changes: 12 additions & 1 deletion Authorization.API.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
var builder = DistributedApplication.CreateBuilder(args);

builder.AddProject<Projects.Authorization_API>("authorization-api");
// Fixed local password so a persisted data volume keeps working across restarts.
// Override with the sql-password parameter / user secret if needed.
var sqlPassword = builder.AddParameter("sql-password", "LocalDev_Sql#2026", secret: true);

var database = builder.AddSqlServer("sql", password: sqlPassword)
.WithDataVolume("authorization-sql-data")
.AddDatabase("DefaultConnection");

builder.AddProject<Projects.Authorization_API>("authorization-api")
.WithReference(database)
.WaitFor(database)
.WithExternalHttpEndpoints();

builder.Build().Run();
3 changes: 3 additions & 0 deletions Authorization.API.AppHost/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Parameters": {
"sql-password": "LocalDev_Sql#2026"
}
}
1 change: 1 addition & 0 deletions Authorization.API.Tests/Authorization.API.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
Expand Down
47 changes: 47 additions & 0 deletions Authorization.API.Tests/DatabaseInitializerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Authorization.API.Context;
using Authorization.API.Data;
using Authorization.API.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;

namespace Authorization.API.Tests;

public class DatabaseInitializerTests
{
[Fact]
public async Task SeedAsync_CreatesDemoUsersAndClient()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();

var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connection));
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();

await using var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await db.Database.EnsureCreatedAsync();

var options = new SeedOptions { Enabled = true };
await DatabaseInitializer.SeedAsync(scope.ServiceProvider, options, NullLogger.Instance);

var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var admin = await userManager.FindByEmailAsync(options.AdminEmail);
var demo = await userManager.FindByEmailAsync(options.DemoUserEmail);
var client = await db.Clients.SingleAsync(c => c.ClientId == options.DemoClientId);

Assert.NotNull(admin);
Assert.NotNull(demo);
Assert.True(await userManager.IsInRoleAsync(admin!, "Administrator"));
Assert.Equal(options.DemoClientSecret, client.ClientSecret);
Assert.Contains("api", client.AllowedScopes);
Assert.True(await db.ApiScopes.AnyAsync(s => s.Name == "openid"));
}
}
4 changes: 4 additions & 0 deletions Authorization.API/Authorization.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.0.1" />
Expand Down
161 changes: 161 additions & 0 deletions Authorization.API/Data/DatabaseInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using Authorization.API.Context;
using Authorization.API.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace Authorization.API.Data;

public static class DatabaseInitializer
{
public static async Task InitializeAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
await using var scope = services.CreateAsyncScope();
var provider = scope.ServiceProvider;
var config = provider.GetRequiredService<IConfiguration>();
var logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(DatabaseInitializer));

var migrateOnStartup = config.GetValue("Database:MigrateOnStartup", false);
if (!migrateOnStartup)
{
return;
}

var db = provider.GetRequiredService<ApplicationDbContext>();
await WaitAndMigrateAsync(db, logger, cancellationToken);

var seedOptions = provider.GetRequiredService<IOptions<SeedOptions>>().Value;
if (!seedOptions.Enabled)
{
return;
}

await SeedAsync(provider, seedOptions, logger, cancellationToken);
}

public static async Task WaitAndMigrateAsync(
ApplicationDbContext db,
ILogger logger,
CancellationToken cancellationToken = default)
{
var delaysSeconds = new[] { 2, 3, 5, 8, 8, 8, 8, 8, 8, 8 };

for (var attempt = 0; attempt < delaysSeconds.Length; attempt++)
{
try
{
if (db.Database.IsRelational())
{
if (db.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite")
{
await db.Database.EnsureCreatedAsync(cancellationToken);
}
else
{
await db.Database.MigrateAsync(cancellationToken);
}
}
else
{
await db.Database.EnsureCreatedAsync(cancellationToken);
}

logger.LogInformation("Database is ready.");
return;
}
catch (Exception ex) when (attempt < delaysSeconds.Length - 1 && !cancellationToken.IsCancellationRequested)
{
logger.LogWarning(
ex,
"Database is not ready (attempt {Attempt}/{Total}). Retrying in {Delay}s.",
attempt + 1,
delaysSeconds.Length,
delaysSeconds[attempt]);
await Task.Delay(TimeSpan.FromSeconds(delaysSeconds[attempt]), cancellationToken);
}
}
}

public static async Task SeedAsync(
IServiceProvider services,
SeedOptions options,
ILogger logger,
CancellationToken cancellationToken = default)
{
var roleManager = services.GetRequiredService<RoleManager<ApplicationRole>>();
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
var db = services.GetRequiredService<ApplicationDbContext>();

foreach (var roleName in new[] { "Administrator", "User" })
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new ApplicationRole(roleName));
}
}

await EnsureUserAsync(userManager, options.AdminEmail, options.AdminPassword, "Administrator");
await EnsureUserAsync(userManager, options.DemoUserEmail, options.DemoUserPassword, "User");

foreach (var (name, description) in new[]
{
("openid", "OpenID"),
("profile", "User profile"),
("email", "Email address"),
("api", "API access")
})
{
if (!await db.ApiScopes.AnyAsync(s => s.Name == name, cancellationToken))
{
db.ApiScopes.Add(new ApiScope { Name = name, Description = description });
}
}

if (!await db.Clients.AnyAsync(c => c.ClientId == options.DemoClientId, cancellationToken))
{
db.Clients.Add(new Client
{
ClientId = options.DemoClientId,
Description = "Local demo confidential client",
ClientSecret = options.DemoClientSecret,
RedirectUri = options.DemoRedirectUri,
RequirePkce = true,
AllowRefreshToken = true,
AllowedScopes = ["openid", "profile", "email", "api"]
});
}

await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Development seed data is in place.");
}

private static async Task EnsureUserAsync(
UserManager<ApplicationUser> userManager,
string email,
string password,
string role)
{
var user = await userManager.FindByEmailAsync(email);
if (user == null)
{
user = new ApplicationUser
{
UserName = email,
Email = email,
EmailConfirmed = true
};

var result = await userManager.CreateAsync(user, password);
if (!result.Succeeded)
{
throw new InvalidOperationException(
$"Failed to seed user {email}: {string.Join(", ", result.Errors.Select(e => e.Description))}");
}
}

if (!await userManager.IsInRoleAsync(user, role))
{
await userManager.AddToRoleAsync(user, role);
}
}
}
22 changes: 22 additions & 0 deletions Authorization.API/Data/SeedOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Authorization.API.Data;

public class SeedOptions
{
public const string SectionName = "Seed";

public bool Enabled { get; set; }

public string AdminEmail { get; set; } = "admin@localhost";

public string AdminPassword { get; set; } = "Admin123!";

public string DemoUserEmail { get; set; } = "demo@example.com";

public string DemoUserPassword { get; set; } = "Password1!";

public string DemoClientId { get; set; } = "demo-client";

public string DemoClientSecret { get; set; } = "demo-secret";

public string DemoRedirectUri { get; set; } = "http://localhost:3000/callback";
}
Loading