diff --git a/src/FiscalOS.API/Data/AppDbContext.cs b/src/FiscalOS.API/Data/AppDbContext.cs index 446b509..aaaa724 100644 --- a/src/FiscalOS.API/Data/AppDbContext.cs +++ b/src/FiscalOS.API/Data/AppDbContext.cs @@ -27,12 +27,20 @@ internal sealed class AppDbContext(IOptions ctxOptions) : D { base.OnModelCreating(modelBuilder); - modelBuilder.Entity() - .Property(static u => u.Id) - .ValueGeneratedOnAdd(); + modelBuilder.Entity(static eb => + { + eb.HasMany(static u => u.RefreshTokens) + .WithOne(static t => t.User) + .HasForeignKey(static t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .Property(static t => t.Id) - .ValueGeneratedOnAdd(); + eb.Property(static u => u.Id).ValueGeneratedOnAdd(); + eb.Property(static u => u.Username); + }); + + modelBuilder.Entity(static eb => + { + eb.Property(static t => t.Id); + }); } } \ No newline at end of file diff --git a/src/FiscalOS.API/Identity/RefreshToken.cs b/src/FiscalOS.API/Identity/RefreshToken.cs index 4731515..f7d30a3 100644 --- a/src/FiscalOS.API/Identity/RefreshToken.cs +++ b/src/FiscalOS.API/Identity/RefreshToken.cs @@ -3,4 +3,7 @@ namespace FiscalOS.API.Identity; internal sealed class RefreshToken { public Guid Id { get; init; } + + public Guid UserId { get; init; } + public User User { get; init; } = new(); } \ No newline at end of file diff --git a/src/FiscalOS.API/Identity/User.cs b/src/FiscalOS.API/Identity/User.cs index c97b22b..f09389f 100644 --- a/src/FiscalOS.API/Identity/User.cs +++ b/src/FiscalOS.API/Identity/User.cs @@ -3,4 +3,7 @@ namespace FiscalOS.API.Identity; internal sealed class User { public Guid Id { get; init; } + public string Username { get; init; } = string.Empty; + + public ICollection RefreshTokens { get; init; } = []; } \ No newline at end of file diff --git a/src/FiscalOS.API/Login/Endpoint.cs b/src/FiscalOS.API/Login/Endpoint.cs index 89e7117..d505407 100644 --- a/src/FiscalOS.API/Login/Endpoint.cs +++ b/src/FiscalOS.API/Login/Endpoint.cs @@ -9,7 +9,10 @@ internal static class Endpoint return app.MapPost(Route, HandleAsync); } - private static async Task HandleAsync([FromBody] LoginRequest loginRequest) + private static async Task HandleAsync( + [FromBody] LoginRequest loginRequest, + [FromServices] AppDbContext appDbContext + ) { const string ADMIN_USERNAME = "Stevan"; const string ADMIN_PASSWORD = "@Password1"; @@ -30,6 +33,13 @@ internal static class Endpoint // 1. We need a user model // 2. We need a refresh token model + var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Username == loginRequest.Username); + + if (user is null) + { + return Results.Unauthorized(); + } + if (loginRequest.Username is not ADMIN_USERNAME || loginRequest.Password is not ADMIN_PASSWORD) { return Results.Unauthorized(); diff --git a/src/FiscalOS.API/Migrations/20260201115839_AddUsersAndRefreshTokens.cs b/src/FiscalOS.API/Migrations/20260201115839_AddUsersAndRefreshTokens.cs index 2e8d69e..54fc763 100644 --- a/src/FiscalOS.API/Migrations/20260201115839_AddUsersAndRefreshTokens.cs +++ b/src/FiscalOS.API/Migrations/20260201115839_AddUsersAndRefreshTokens.cs @@ -5,43 +5,43 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace FiscalOS.API.Migrations { - /// - public partial class AddUsersAndRefreshTokens : Migration - { /// - protected override void Up(MigrationBuilder migrationBuilder) + public partial class AddUsersAndRefreshTokens : Migration { - migrationBuilder.CreateTable( - name: "RefreshTokens", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_RefreshTokens", x => x.Id); - }); + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "Users"); + } } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "RefreshTokens"); - - migrationBuilder.DropTable( - name: "Users"); - } - } } \ No newline at end of file diff --git a/src/FiscalOS.API/Migrations/20260201125529_AddUsernameToUsers.Designer.cs b/src/FiscalOS.API/Migrations/20260201125529_AddUsernameToUsers.Designer.cs new file mode 100644 index 0000000..f0afd9e --- /dev/null +++ b/src/FiscalOS.API/Migrations/20260201125529_AddUsernameToUsers.Designer.cs @@ -0,0 +1,72 @@ +// +using System; +using FiscalOS.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260201125529_AddUsernameToUsers")] + partial class AddUsernameToUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.API.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.API.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.API.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.API.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.API.Identity.User", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.API/Migrations/20260201125529_AddUsernameToUsers.cs b/src/FiscalOS.API/Migrations/20260201125529_AddUsernameToUsers.cs new file mode 100644 index 0000000..612c7f5 --- /dev/null +++ b/src/FiscalOS.API/Migrations/20260201125529_AddUsernameToUsers.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.API.Migrations +{ + /// + public partial class AddUsernameToUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Username", + table: "Users", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "UserId", + table: "RefreshTokens", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_RefreshTokens_Users_UserId", + table: "RefreshTokens", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RefreshTokens_Users_UserId", + table: "RefreshTokens"); + + migrationBuilder.DropIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens"); + + migrationBuilder.DropColumn( + name: "Username", + table: "Users"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "RefreshTokens"); + } + } +} diff --git a/src/FiscalOS.API/Migrations/AppDbContextModelSnapshot.cs b/src/FiscalOS.API/Migrations/AppDbContextModelSnapshot.cs index e8930a9..8e86d70 100644 --- a/src/FiscalOS.API/Migrations/AppDbContextModelSnapshot.cs +++ b/src/FiscalOS.API/Migrations/AppDbContextModelSnapshot.cs @@ -23,8 +23,13 @@ namespace FiscalOS.API.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("UserId") + .HasColumnType("TEXT"); + b.HasKey("Id"); + b.HasIndex("UserId"); + b.ToTable("RefreshTokens"); }); @@ -34,10 +39,30 @@ namespace FiscalOS.API.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + b.HasKey("Id"); b.ToTable("Users"); }); + + modelBuilder.Entity("FiscalOS.API.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.API.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.API.Identity.User", b => + { + b.Navigation("RefreshTokens"); + }); #pragma warning restore 612, 618 } } diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index 8db0751..7162f7b 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -1,5 +1,6 @@ global using System.ComponentModel.DataAnnotations; +global using FiscalOS.API.Data; global using FiscalOS.API.Identity; global using FiscalOS.API.Login; diff --git a/tests/FiscalOS.API.Tests/Infra/TestApi.cs b/tests/FiscalOS.API.Tests/Infra/TestApi.cs index b535b1f..a5d46f3 100644 --- a/tests/FiscalOS.API.Tests/Infra/TestApi.cs +++ b/tests/FiscalOS.API.Tests/Infra/TestApi.cs @@ -1,3 +1,9 @@ +using FiscalOS.API.Data; + +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + namespace FiscalOS.API.Tests.Infra; public class TestApi : WebApplicationFactory @@ -7,5 +13,15 @@ public class TestApi : WebApplicationFactory base.ConfigureWebHost(builder); builder.ConfigureLogging(static c => c.ClearProviders()); + + builder.ConfigureTestServices(static c => + { + var opts = Options.Create(new AppDbContextOptions() + { + DatabaseFilePath = $"{Guid.NewGuid()}.db", + }); + + c.AddSingleton(opts); + }); } } \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Integration/IntegrationTest.cs b/tests/FiscalOS.API.Tests/Integration/IntegrationTest.cs new file mode 100644 index 0000000..844a659 --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/IntegrationTest.cs @@ -0,0 +1,39 @@ + +using FiscalOS.API.Data; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FiscalOS.API.Tests.Integration; + +public abstract class IntegrationTest(TestApi testApi) : IClassFixture, IAsyncLifetime +{ + protected TestApi TestApi { get; } = testApi; + + // TODO: This seems not correct + // would need to dispose of scope + // we need to clean up the test + // database files when the test ends + + protected DbContext TestDbContext + { + get + { + var scopeFactory = TestApi.Services.GetRequiredService(); + var scope = scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + } + + public ValueTask InitializeAsync() + { + return new(TestDbContext.Database.EnsureCreatedAsync()); + } + + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return new(TestDbContext.Database.EnsureDeletedAsync()); + } + +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs b/tests/FiscalOS.API.Tests/Integration/LoginTests.cs index 0a79a3b..d0d4267 100644 --- a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/LoginTests.cs @@ -1,14 +1,12 @@ namespace FiscalOS.API.Tests.Integration; -public class LoginTests(TestApi testApi) : IClassFixture +public class LoginTests(TestApi testApi) : IntegrationTest(testApi) { - private readonly TestApi _testApi = testApi; - [Theory] [ClassData] public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc) { - var client = _testApi.CreateClient(); + var client = TestApi.CreateClient(); var req = new { @@ -28,7 +26,7 @@ public class LoginTests(TestApi testApi) : IClassFixture [Fact] public async Task Login_WhenUserCredentialsAreIncorrect_ItShouldReturn401WithProblemDetails() { - var client = _testApi.CreateClient(); + var client = TestApi.CreateClient(); var req = new {