From e4dda1319a4861f970023a37fb0979eca97031cd Mon Sep 17 00:00:00 2001 From: Codewriter90x Date: Thu, 9 Jul 2026 17:25:31 +0200 Subject: [PATCH 1/2] feat(setup): add first-run setup wizard --- Docs/setup/first-run-setup-wizard.md | 93 ++++++++++++++ README.md | 4 + .../Controllers/SetupController.cs | 15 ++- .../CompleteSetup/CompleteSetupCommand.cs | 1 - .../CompleteSetup/CompleteSetupResult.cs | 9 +- .../CompleteSetup/CompleteSetupUseCase.cs | 55 +++++++-- .../Setup/Ports/ISetupWriter.cs | 5 +- src/OpenCashFlow.Contracts/DTOs/Setup_DTO.cs | 17 ++- .../Setup/SetupWriter.cs | 8 +- .../Controllers/HomeController.Setup.cs | 19 ++- .../Models/Setup/SetupCompleteViewModel.cs | 7 ++ .../Services/SetupAPIService.cs | 9 +- .../Views/Home/Setup.cshtml | 19 +-- .../Views/Home/SetupComplete.cshtml | 43 +++++++ .../Setup/SetupUseCaseTests.cs | 26 +++- .../Factories/CustomWebApplicationFactory.cs | 9 +- .../Tests/API/Setup_Tests.cs | 116 ++++++++++++++++++ 17 files changed, 398 insertions(+), 57 deletions(-) create mode 100644 Docs/setup/first-run-setup-wizard.md create mode 100644 src/OpenCashFlow.WebApp/Models/Setup/SetupCompleteViewModel.cs create mode 100644 src/OpenCashFlow.WebApp/Views/Home/SetupComplete.cshtml create mode 100644 tests/OpenCashFlow.Test/Tests/API/Setup_Tests.cs diff --git a/Docs/setup/first-run-setup-wizard.md b/Docs/setup/first-run-setup-wizard.md new file mode 100644 index 0000000..8ce7901 --- /dev/null +++ b/Docs/setup/first-run-setup-wizard.md @@ -0,0 +1,93 @@ +# First-Run Setup Wizard + +OpenCashFlow includes a first-run setup wizard for fresh self-hosted installations. + +The wizard configures application data only. It does not create PostgreSQL users, create databases, change database permissions, or perform infrastructure provisioning. Database connectivity must already be configured through Docker Compose, environment variables, or the host deployment configuration. + +## Local Docker Flow + +```bash +cp .env.example .env +docker compose up --build +``` + +Then open: + +```text +http://localhost:5200 +``` + +If no company and no administrator exist, the WebApp redirects to: + +```text +/Setup +``` + +## Setup Inputs + +The first-run setup form asks for: + +- company name; +- owner/admin first name; +- optional owner/admin last name; +- admin email; +- language; +- currency; +- country; +- timezone. + +The setup wizard does not ask for an administrator password. OpenCashFlow generates a strong temporary password server-side. + +## What Setup Creates + +On a fresh instance, setup creates: + +- the first company/tenant; +- the first administrator user; +- standard self-hosted roles; +- the administrator role assignments; +- the company staff link for the administrator; +- the administrator contact email; +- an initial zero cash balance for the company. + +Cash Custody domain contracts exist, but there is no persisted multi-cash-account schema yet. Until that implementation lands, setup seeds the current legacy company-level `CashBalance` record rather than a new `CashAccount`. + +## Temporary Password + +After successful setup, the WebApp shows the generated temporary administrator password exactly once in the setup POST response. + +Store it immediately. Refreshing or revisiting setup after completion does not show the password again. + +The password is: + +- generated server-side with a cryptographic random generator; +- hashed before storage; +- never stored as plaintext; +- never logged intentionally by setup code; +- marked as temporary by setting the first admin to require password change after login. + +## Setup Lock + +Setup is only available while the instance has no company and no administrator user. + +After setup completes: + +- `GET /v1/Setup/status` reports `RequiresSetup = false`; +- `POST /v1/Setup` returns a conflict instead of creating another instance; +- the WebApp `/Setup` page redirects to login. + +If the database is partially configured, for example a company exists but no admin user exists, setup refuses to continue. That state requires an explicit recovery procedure rather than silent repair. + +## First Login + +Use the administrator email and the temporary password shown after setup completes. + +After login, OpenCashFlow redirects the user to the password-change screen because the first administrator is created with `UserMustChangePassword = true`. + +## Security Notes + +- Do not expose an unconfigured instance publicly. +- Set real secrets and database credentials before any public or shared deployment. +- Treat `.env.example` as a template only. +- Do not paste generated setup passwords into issue reports, logs, screenshots, or support channels. +- If the temporary password is lost before first login, use a controlled password reset or database recovery procedure. diff --git a/README.md b/README.md index 8de92b7..76fe54a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ Local endpoints: The default Docker Compose configuration is for local evaluation. Change secrets, database credentials, TLS, backups, reverse proxy configuration, and operational settings before exposing any instance. +On a fresh database, opening the WebApp redirects to `/Setup`. The first-run wizard creates the first company and admin +user, generates a temporary password, shows it once, and then requires a password change after login. See +[Docs/setup/first-run-setup-wizard.md](Docs/setup/first-run-setup-wizard.md). + ### Run Manually Configure `DEFAULT_CONN_STRING` or `ConnectionStrings:DefaultConnectionString`, then run: diff --git a/src/OpenCashFlow.API/Controllers/SetupController.cs b/src/OpenCashFlow.API/Controllers/SetupController.cs index 3862f6d..bd2768f 100644 --- a/src/OpenCashFlow.API/Controllers/SetupController.cs +++ b/src/OpenCashFlow.API/Controllers/SetupController.cs @@ -1,6 +1,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using OpenCashFlow.Application.Setup.CompleteSetup; using OpenCashFlow.Application.Setup.GetSetupStatus; using OpenCashFlow.Contracts.DTOs; @@ -23,6 +24,7 @@ public async Task> Status(CancellationToken cancel } [HttpPost] + [EnableRateLimiting("auth-limiter")] public async Task Create([FromBody] SetupRequest_DTO request, CancellationToken cancellationToken) { if (!ModelState.IsValid) @@ -33,7 +35,6 @@ public async Task Create([FromBody] SetupRequest_DTO request, Can var result = await completeSetupUseCase.ExecuteAsync(new CompleteSetupCommand( request.CompanyName, request.AdminEmail, - request.AdminPassword, request.AdminFirstName, request.AdminLastName, request.Language, @@ -43,7 +44,7 @@ public async Task Create([FromBody] SetupRequest_DTO request, Can if (result.Success && result.Status is not null) { - return CreatedAtAction(nameof(Status), ToDto(result.Status)); + return CreatedAtAction(nameof(Status), ToCompletedDto(result.Status, request.AdminEmail, result.TemporaryAdminPassword!)); } return result.Failure switch @@ -64,5 +65,15 @@ private static SetupStatus_DTO ToDto(SetupStatusResult status) HasAdminUsers = status.HasAdminUsers }; } + + private static SetupCompleted_DTO ToCompletedDto(SetupStatusResult status, string adminEmail, string temporaryAdminPassword) + { + return new SetupCompleted_DTO + { + Status = ToDto(status), + AdminEmail = adminEmail.Trim(), + TemporaryAdminPassword = temporaryAdminPassword + }; + } } } diff --git a/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupCommand.cs b/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupCommand.cs index acacb64..d9d7eef 100644 --- a/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupCommand.cs +++ b/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupCommand.cs @@ -3,7 +3,6 @@ namespace OpenCashFlow.Application.Setup.CompleteSetup; public sealed record CompleteSetupCommand( string CompanyName, string AdminEmail, - string AdminPassword, string AdminFirstName, string? AdminLastName, string Language, diff --git a/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupResult.cs b/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupResult.cs index 3a1fc21..8fc4bcc 100644 --- a/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupResult.cs +++ b/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupResult.cs @@ -15,11 +15,12 @@ public sealed record CompleteSetupResult( bool Success, CompleteSetupFailure Failure, string? Message, - SetupStatusResult? Status) + SetupStatusResult? Status, + string? TemporaryAdminPassword) { - public static CompleteSetupResult Ok(SetupStatusResult status) - => new(true, CompleteSetupFailure.None, null, status); + public static CompleteSetupResult Ok(SetupStatusResult status, string temporaryAdminPassword) + => new(true, CompleteSetupFailure.None, null, status, temporaryAdminPassword); public static CompleteSetupResult Fail(CompleteSetupFailure failure, string message) - => new(false, failure, message, null); + => new(false, failure, message, null, null); } diff --git a/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupUseCase.cs b/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupUseCase.cs index 41d9c76..f4927ee 100644 --- a/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupUseCase.cs +++ b/src/OpenCashFlow.Application/Setup/CompleteSetup/CompleteSetupUseCase.cs @@ -1,4 +1,5 @@ using OpenCashFlow.Application.Setup.Ports; +using System.Security.Cryptography; namespace OpenCashFlow.Application.Setup.CompleteSetup; @@ -28,24 +29,52 @@ public async Task ExecuteAsync(CompleteSetupCommand command return CompleteSetupResult.Fail(CompleteSetupFailure.PartiallyConfigured, "Setup cannot continue because this instance is partially configured."); } - if (!IsStrongPassword(command.AdminPassword)) + var temporaryAdminPassword = GenerateTemporaryPassword(); + var completedStatus = await setupWriter.CompleteAsync(command, temporaryAdminPassword, cancellationToken); + return CompleteSetupResult.Ok(completedStatus, temporaryAdminPassword); + } + + private static string GenerateTemporaryPassword() + { + const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + const string lower = "abcdefghijkmnopqrstuvwxyz"; + const string digits = "23456789"; + const string symbols = "!@#$%^&*()-_=+"; + const string all = upper + lower + digits + symbols; + + Span password = + [ + Pick(upper), + Pick(lower), + Pick(digits), + Pick(symbols), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all), + Pick(all) + ]; + + for (var i = password.Length - 1; i > 0; i--) { - return CompleteSetupResult.Fail( - CompleteSetupFailure.WeakPassword, - "Admin password must be at least 8 characters and include upper, lower, digit, and special characters."); + var j = RandomNumberGenerator.GetInt32(i + 1); + (password[i], password[j]) = (password[j], password[i]); } - var completedStatus = await setupWriter.CompleteAsync(command, cancellationToken); - return CompleteSetupResult.Ok(completedStatus); + return new string(password); } - private static bool IsStrongPassword(string password) + private static char Pick(string source) { - return !string.IsNullOrWhiteSpace(password) - && password.Length >= 8 - && password.Any(char.IsUpper) - && password.Any(char.IsLower) - && password.Any(char.IsDigit) - && password.Any(ch => !char.IsLetterOrDigit(ch)); + return source[RandomNumberGenerator.GetInt32(source.Length)]; } } diff --git a/src/OpenCashFlow.Application/Setup/Ports/ISetupWriter.cs b/src/OpenCashFlow.Application/Setup/Ports/ISetupWriter.cs index 79b7e89..923dad3 100644 --- a/src/OpenCashFlow.Application/Setup/Ports/ISetupWriter.cs +++ b/src/OpenCashFlow.Application/Setup/Ports/ISetupWriter.cs @@ -5,5 +5,8 @@ namespace OpenCashFlow.Application.Setup.Ports; public interface ISetupWriter { - Task CompleteAsync(CompleteSetupCommand command, CancellationToken cancellationToken = default); + Task CompleteAsync( + CompleteSetupCommand command, + string temporaryAdminPassword, + CancellationToken cancellationToken = default); } diff --git a/src/OpenCashFlow.Contracts/DTOs/Setup_DTO.cs b/src/OpenCashFlow.Contracts/DTOs/Setup_DTO.cs index 1fd0af1..4044814 100644 --- a/src/OpenCashFlow.Contracts/DTOs/Setup_DTO.cs +++ b/src/OpenCashFlow.Contracts/DTOs/Setup_DTO.cs @@ -19,12 +19,11 @@ public class SetupRequest_DTO [EmailAddress] public required string AdminEmail { get; set; } - [Required] - [StringLength(128, MinimumLength = 8)] - public required string AdminPassword { get; set; } + // Kept optional for older clients. First-run setup now generates the temporary password server-side. + public string? AdminPassword { get; set; } - [Compare(nameof(AdminPassword))] - public required string ConfirmPassword { get; set; } + // Kept optional for older clients. First-run setup now generates the temporary password server-side. + public string? ConfirmPassword { get; set; } [Required] [StringLength(80)] @@ -49,4 +48,12 @@ public class SetupRequest_DTO [StringLength(2, MinimumLength = 2)] public string Country { get; set; } = "IT"; } + + public class SetupCompleted_DTO + { + public required SetupStatus_DTO Status { get; set; } + public required string AdminEmail { get; set; } + public required string TemporaryAdminPassword { get; set; } + public string Message { get; set; } = "Setup completed. Store the temporary password now; it is shown only once."; + } } diff --git a/src/OpenCashFlow.Infrastructure/Setup/SetupWriter.cs b/src/OpenCashFlow.Infrastructure/Setup/SetupWriter.cs index 4eae6cc..f87e7bd 100644 --- a/src/OpenCashFlow.Infrastructure/Setup/SetupWriter.cs +++ b/src/OpenCashFlow.Infrastructure/Setup/SetupWriter.cs @@ -14,7 +14,10 @@ namespace OpenCashFlow.Infrastructure.Setup; public sealed class SetupWriter(ApplicationDbContext db, ILogger logger) : ISetupWriter { - public async Task CompleteAsync(CompleteSetupCommand command, CancellationToken cancellationToken = default) + public async Task CompleteAsync( + CompleteSetupCommand command, + string temporaryAdminPassword, + CancellationToken cancellationToken = default) { var normalizedEmail = command.AdminEmail.Trim(); var now = DateTime.UtcNow; @@ -65,7 +68,8 @@ public async Task CompleteAsync(CompleteSetupCommand command, PrivacyPolicyAcepted = true, PrivacyPolicyAcceptedDate = now, PasswordSalt = salt, - PasswordHash = PasswordHasher.HashPasswordArgon2(command.AdminPassword, salt), + PasswordHash = PasswordHasher.HashPasswordArgon2(temporaryAdminPassword, salt), + UserMustChangePassword = true, DateIns = now }); diff --git a/src/OpenCashFlow.WebApp/Controllers/HomeController.Setup.cs b/src/OpenCashFlow.WebApp/Controllers/HomeController.Setup.cs index 27696c0..eb85bfc 100644 --- a/src/OpenCashFlow.WebApp/Controllers/HomeController.Setup.cs +++ b/src/OpenCashFlow.WebApp/Controllers/HomeController.Setup.cs @@ -1,5 +1,6 @@ using OpenCashFlow.Contracts.DTOs; using Microsoft.AspNetCore.Mvc; +using OpenCashFlow.WebApp.Models.Setup; using OpenCashFlow.WebApp.Services; namespace OpenCashFlow.WebApp.Controllers @@ -22,8 +23,6 @@ public async Task Setup(CancellationToken cancellationToken) { CompanyName = string.Empty, AdminEmail = string.Empty, - AdminPassword = string.Empty, - ConfirmPassword = string.Empty, AdminFirstName = string.Empty, Language = "it", Currency = "EUR", @@ -42,6 +41,9 @@ public async Task Setup([Bind] SetupRequest_DTO model, Cancellati return View(model); } + model.AdminPassword = null; + model.ConfirmPassword = null; + var result = await _setupAPIService.CompleteSetupAsync(model, cancellationToken); if (!result.Success) { @@ -49,8 +51,17 @@ public async Task Setup([Bind] SetupRequest_DTO model, Cancellati return View(model); } - TempData["SetupCompleted"] = "Setup completed. Sign in with the administrator account."; - return RedirectToAction(nameof(Login)); + if (result.Setup is null || string.IsNullOrWhiteSpace(result.Setup.TemporaryAdminPassword)) + { + ViewBag.ErrorMessage = "Setup completed but the temporary password was not returned. Reset the admin password before signing in."; + return View(model); + } + + return View("SetupComplete", new SetupCompleteViewModel + { + AdminEmail = result.Setup.AdminEmail, + TemporaryAdminPassword = result.Setup.TemporaryAdminPassword + }); } } } diff --git a/src/OpenCashFlow.WebApp/Models/Setup/SetupCompleteViewModel.cs b/src/OpenCashFlow.WebApp/Models/Setup/SetupCompleteViewModel.cs new file mode 100644 index 0000000..79d2e5b --- /dev/null +++ b/src/OpenCashFlow.WebApp/Models/Setup/SetupCompleteViewModel.cs @@ -0,0 +1,7 @@ +namespace OpenCashFlow.WebApp.Models.Setup; + +public sealed class SetupCompleteViewModel +{ + public required string AdminEmail { get; init; } + public required string TemporaryAdminPassword { get; init; } +} diff --git a/src/OpenCashFlow.WebApp/Services/SetupAPIService.cs b/src/OpenCashFlow.WebApp/Services/SetupAPIService.cs index 08fcc0c..592b395 100644 --- a/src/OpenCashFlow.WebApp/Services/SetupAPIService.cs +++ b/src/OpenCashFlow.WebApp/Services/SetupAPIService.cs @@ -22,22 +22,23 @@ public class SetupAPIService(IHttpClientFactory httpClientFactory, ILogger CompleteSetupAsync(SetupRequest_DTO request, CancellationToken cancellationToken = default) + public async Task<(bool Success, string? Message, SetupCompleted_DTO? Setup)> CompleteSetupAsync(SetupRequest_DTO request, CancellationToken cancellationToken = default) { var response = await _httpClient.PostAsJsonAsync("/v1/Setup", request, cancellationToken); if (response.IsSuccessStatusCode) { - return (true, null); + var setup = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return (true, null, setup); } if (response.StatusCode == HttpStatusCode.Conflict) { - return (false, "This instance is already configured."); + return (false, "This instance is already configured.", null); } var body = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogWarning("Setup failed with status {Status}: {Body}", response.StatusCode, body); - return (false, "Unable to complete setup. Check the fields and try again."); + return (false, "Unable to complete setup. Check the fields and try again.", null); } } } diff --git a/src/OpenCashFlow.WebApp/Views/Home/Setup.cshtml b/src/OpenCashFlow.WebApp/Views/Home/Setup.cshtml index 617344a..765b939 100644 --- a/src/OpenCashFlow.WebApp/Views/Home/Setup.cshtml +++ b/src/OpenCashFlow.WebApp/Views/Home/Setup.cshtml @@ -21,7 +21,7 @@ @Html.AntiForgeryToken()

First setup

-

Create the first company and instance administrator.

+

Create the first company and instance administrator. OpenCashFlow will generate a temporary password after setup.

@if (!string.IsNullOrWhiteSpace(ViewBag.ErrorMessage as string)) { @@ -55,19 +55,6 @@
-
-
- - - -
-
- - - -
-
-
@@ -94,6 +81,10 @@
+
+ Database connection and PostgreSQL credentials are read from the container/environment configuration. This wizard only creates application data. +
+ diff --git a/src/OpenCashFlow.WebApp/Views/Home/SetupComplete.cshtml b/src/OpenCashFlow.WebApp/Views/Home/SetupComplete.cshtml new file mode 100644 index 0000000..689079f --- /dev/null +++ b/src/OpenCashFlow.WebApp/Views/Home/SetupComplete.cshtml @@ -0,0 +1,43 @@ +@model OpenCashFlow.WebApp.Models.Setup.SetupCompleteViewModel +@{ + Layout = "_BlankLayout"; + ViewData["Title"] = "Setup complete"; +} + +@section PageStyles { + +} + +
+
+ + +
+
+

Setup complete

+

Store this temporary administrator password now. It will not be shown again.

+ +
+ + +
+ +
+ + +
+ +
+ You will be asked to change this password after the first login. +
+ + Go to login +
+
+
+
diff --git a/tests/OpenCashFlow.Application.Tests/Setup/SetupUseCaseTests.cs b/tests/OpenCashFlow.Application.Tests/Setup/SetupUseCaseTests.cs index c3a2dc4..26b9b3c 100644 --- a/tests/OpenCashFlow.Application.Tests/Setup/SetupUseCaseTests.cs +++ b/tests/OpenCashFlow.Application.Tests/Setup/SetupUseCaseTests.cs @@ -33,16 +33,16 @@ public async Task CompleteSetup_WhenAlreadyConfigured_Fails() } [Fact] - public async Task CompleteSetup_WithWeakPassword_FailsBeforeWriter() + public async Task CompleteSetup_WithInvalidInput_FailsBeforeWriter() { var reader = new FakeSetupReader(new SetupStatusResult(true, false, false)); var writer = new FakeSetupWriter(); var useCase = new CompleteSetupUseCase(reader, writer); - var result = await useCase.ExecuteAsync(ValidCommand() with { AdminPassword = "weak" }); + var result = await useCase.ExecuteAsync(ValidCommand() with { CompanyName = " " }); Assert.False(result.Success); - Assert.Equal(CompleteSetupFailure.WeakPassword, result.Failure); + Assert.Equal(CompleteSetupFailure.InvalidInput, result.Failure); Assert.False(writer.Called); } @@ -58,6 +58,9 @@ public async Task CompleteSetup_WithValidEmptyInstance_CallsWriter() Assert.True(result.Success); Assert.True(writer.Called); Assert.False(result.Status!.RequiresSetup); + Assert.NotNull(result.TemporaryAdminPassword); + Assert.Equal(result.TemporaryAdminPassword, writer.TemporaryAdminPassword); + Assert.True(IsStrongPassword(result.TemporaryAdminPassword)); } private static CompleteSetupCommand ValidCommand() @@ -65,7 +68,6 @@ private static CompleteSetupCommand ValidCommand() return new CompleteSetupCommand( "OpenCashFlow Test", "admin@example.local", - "Str0ng!Pass", "Admin", "User", "it", @@ -88,11 +90,25 @@ public Task GetStatusAsync(CancellationToken cancellationToke private sealed class FakeSetupWriter : ISetupWriter { public bool Called { get; private set; } + public string? TemporaryAdminPassword { get; private set; } - public Task CompleteAsync(CompleteSetupCommand command, CancellationToken cancellationToken = default) + public Task CompleteAsync( + CompleteSetupCommand command, + string temporaryAdminPassword, + CancellationToken cancellationToken = default) { Called = true; + TemporaryAdminPassword = temporaryAdminPassword; return Task.FromResult(new SetupStatusResult(false, true, true)); } } + + private static bool IsStrongPassword(string password) + { + return password.Length >= 16 + && password.Any(char.IsUpper) + && password.Any(char.IsLower) + && password.Any(char.IsDigit) + && password.Any(ch => !char.IsLetterOrDigit(ch)); + } } diff --git a/tests/OpenCashFlow.Test/Factories/CustomWebApplicationFactory.cs b/tests/OpenCashFlow.Test/Factories/CustomWebApplicationFactory.cs index afbc8f7..8bd2848 100644 --- a/tests/OpenCashFlow.Test/Factories/CustomWebApplicationFactory.cs +++ b/tests/OpenCashFlow.Test/Factories/CustomWebApplicationFactory.cs @@ -19,11 +19,13 @@ public class CustomWebApplicationFactory : WebApplicationFactory private readonly bool _useFakeAuth; private readonly string _dbIdentifier; private readonly bool _usePostgres; + private readonly bool _seedTestData; - public CustomWebApplicationFactory(string dbIdentifier, bool useFakeAuth = true) + public CustomWebApplicationFactory(string dbIdentifier, bool useFakeAuth = true, bool seedTestData = true) { _dbIdentifier = dbIdentifier; _useFakeAuth = useFakeAuth; + _seedTestData = seedTestData; // If dbIdentifier contains connection string keywords, use PostgreSQL _usePostgres = dbIdentifier.Contains("Host=") || dbIdentifier.Contains("Server="); } @@ -111,7 +113,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) db.Database.EnsureCreated(); } - TestHelpers.SeedAllTestData(db); + if (_seedTestData) + { + TestHelpers.SeedAllTestData(db); + } }); } diff --git a/tests/OpenCashFlow.Test/Tests/API/Setup_Tests.cs b/tests/OpenCashFlow.Test/Tests/API/Setup_Tests.cs new file mode 100644 index 0000000..588eb32 --- /dev/null +++ b/tests/OpenCashFlow.Test/Tests/API/Setup_Tests.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using OpenCashFlow.Contracts.Auth; +using OpenCashFlow.Contracts.Core; +using OpenCashFlow.Contracts.DTOs; +using OpenCashFlow.Test.Factories; + +namespace OpenCashFlow.Test.Tests.API; + +[Trait("Layer", "API")] +[Trait("Feature", "Setup")] +[Trait("Type", "Integration")] +public sealed class SetupApiTests : IDisposable +{ + private readonly CustomWebApplicationFactory _factory = new( + $"SetupDb_{Guid.NewGuid():N}", + useFakeAuth: false, + seedTestData: false); + + [Fact] + public async Task Status_WithFreshDatabase_RequiresSetup() + { + var client = _factory.CreateClient(); + + var status = await client.GetFromJsonAsync("/v1/Setup/status"); + + Assert.NotNull(status); + Assert.True(status!.RequiresSetup); + Assert.False(status.HasCompanies); + Assert.False(status.HasAdminUsers); + } + + [Fact] + public async Task Create_WithFreshDatabase_CreatesFirstAdminAndTemporaryPassword() + { + var client = _factory.CreateClient(); + var request = ValidRequest(); + + var response = await client.PostAsJsonAsync("/v1/Setup", request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var completed = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(completed); + Assert.Equal(request.AdminEmail, completed!.AdminEmail); + Assert.True(IsStrongPassword(completed.TemporaryAdminPassword)); + Assert.False(completed.Status.RequiresSetup); + + using var db = _factory.CreateDbContext(); + var company = await db.Company_DS.AsNoTracking().SingleAsync(); + var admin = await db.AspNetUser_DS.AsNoTracking().SingleAsync(); + var cashBalance = await db.CashBalances.AsNoTracking().SingleAsync(); + + Assert.Equal(request.CompanyName, company.CompanyName); + Assert.Equal(request.AdminEmail, admin.Email); + Assert.True(admin.UserMustChangePassword); + Assert.Equal(company.TenantID, cashBalance.CompanyId); + + var secondResponse = await client.PostAsJsonAsync("/v1/Setup", request); + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + + var loginResponse = await client.PostAsJsonAsync("/v1/Authentication/login", new + { + Username = request.AdminEmail, + Password = completed.TemporaryAdminPassword + }); + + Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); + var login = await loginResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(login?.Data); + Assert.True(login!.Data!.Success); + Assert.True(login.Data.RequiresPasswordChange); + Assert.False(string.IsNullOrWhiteSpace(login.Data.Token)); + } + + [Fact] + public async Task Create_WithInvalidData_ReturnsBadRequest() + { + var client = _factory.CreateClient(); + var request = ValidRequest(); + request.CompanyName = string.Empty; + + var response = await client.PostAsJsonAsync("/v1/Setup", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + public void Dispose() + { + _factory.Dispose(); + } + + private static SetupRequest_DTO ValidRequest() + { + return new SetupRequest_DTO + { + CompanyName = "OpenCashFlow Fresh Install", + AdminEmail = $"owner-{Guid.NewGuid():N}@example.local", + AdminFirstName = "Owner", + AdminLastName = "Admin", + Language = "it", + Currency = "EUR", + Timezone = "Europe/Rome", + Country = "IT" + }; + } + + private static bool IsStrongPassword(string password) + { + return password.Length >= 16 + && password.Any(char.IsUpper) + && password.Any(char.IsLower) + && password.Any(char.IsDigit) + && password.Any(ch => !char.IsLetterOrDigit(ch)); + } +} From 9e2b144fea16c99efcdd8f61c82e3c750e3a46d6 Mon Sep 17 00:00:00 2001 From: Codewriter90x Date: Thu, 9 Jul 2026 17:40:31 +0200 Subject: [PATCH 2/2] docs(setup): add first-run setup smoke report --- Docs/setup/first-run-setup-smoke.md | 132 ++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 Docs/setup/first-run-setup-smoke.md diff --git a/Docs/setup/first-run-setup-smoke.md b/Docs/setup/first-run-setup-smoke.md new file mode 100644 index 0000000..6b10981 --- /dev/null +++ b/Docs/setup/first-run-setup-smoke.md @@ -0,0 +1,132 @@ +# First-Run Setup Smoke + +Date: 2026-07-09 + +Branch: `feature/first-run-setup-wizard` + +## Scope + +This smoke validated the first-run setup wizard against a clean Docker Compose stack. + +The smoke used the local Docker Compose services: + +- WebApp: `http://localhost:5200` +- API: `http://localhost:5100` +- PostgreSQL: Docker Compose `db` service with a fresh `opencashflow_pgdata` volume + +No deployment was performed. + +## Commands Used + +Clean stack: + +```bash +docker compose down -v +docker compose up -d --build +``` + +Fast repeat of the WebApp form path after images were built: + +```bash +docker compose down -v +docker compose up -d +``` + +Health and setup status: + +```bash +curl -i http://localhost:5100/health +curl -i http://localhost:5200/ +curl -i http://localhost:5200/Login +curl -i http://localhost:5100/v1/Setup/status +``` + +WebApp setup form: + +```bash +curl -sS -c /private/tmp/ocf-web.cookies \ + -o /private/tmp/ocf-web-setup.html \ + http://localhost:5200/Setup +``` + +The antiforgery token was read from the setup form and posted back to `POST /Setup` with: + +- company name: `Web Smoke Workshop SRL` +- admin email: `web-owner-smoke@example.local` +- admin first name: `Web` +- admin last name: `Owner` +- language: `it` +- currency: `EUR` +- country: `IT` +- timezone: `Europe/Rome` + +The temporary password was captured from the setup completion response and was not written to this document. + +API login and password change: + +```bash +curl -sS -X POST http://localhost:5100/v1/Authentication/login \ + -H "Content-Type: application/json" \ + -d '{"username":"web-owner-smoke@example.local","password":""}' + +curl -sS -i -X POST http://localhost:5100/v1/Authentication/change-password-required \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"newPassword":""}' + +curl -sS -X POST http://localhost:5100/v1/Authentication/login \ + -H "Content-Type: application/json" \ + -d '{"username":"web-owner-smoke@example.local","password":""}' +``` + +Log check: + +```bash +docker logs opencashflow-api +docker logs opencashflow-webapp +``` + +The generated temporary password was searched explicitly in API and WebApp logs and was not found. + +## Results + +| Check | Result | +| --- | --- | +| Clean DB/container state | Passed. `docker compose down -v` removed containers and `opencashflow_pgdata`. | +| Docker Compose startup | Passed. `api`, `webapp`, and `db` started. | +| API health | Passed. `GET /health` returned `{"status":"healthy","database":"ok"}`. | +| WebApp root | Passed. `GET /` returned `302` to `http://localhost:5200/Login`. | +| Unconfigured login redirect | Passed. `GET /Login` returned `302 Location: /Setup`. | +| Setup status before setup | Passed. API returned `requiresSetup=true`, `hasCompanies=false`, `hasAdminUsers=false`. | +| WebApp setup form | Passed. `GET /Setup` returned the first setup page with antiforgery token and no password input fields. | +| Complete setup through WebApp form | Passed. `POST /Setup` returned `200 OK` with the `Setup complete` page. | +| Temporary password shown once | Passed. Password appeared in the setup completion response. After setup, `GET /Setup` returned `302 Location: /Account/Login` and did not replay the password. | +| Login with temporary password | Passed. Login returned `success=true` and `requiresPasswordChange=true`. | +| Change password | Passed. `POST /v1/Authentication/change-password-required` returned `200 OK`. | +| Login with new password | Passed. Login returned `success=true` and `requiresPasswordChange=false`. | +| Setup locked after configuration | Passed. `GET /Setup` redirected to login after configuration. | +| Second setup POST | Passed. `POST /v1/Setup` returned `409 Conflict`. | +| Plaintext generated password in logs | Passed. Exact generated temporary password was not present in API or WebApp logs. | + +## Issues Found + +The API container logs this startup message: + +```text +Cannot load library libgssapi_krb5.so.2 +Error: libgssapi_krb5.so.2: cannot open shared object file: No such file or directory +``` + +The application still started and the health endpoint reported the database as healthy. This should be tracked separately because it creates noisy operational logs and may indicate a missing native package in the runtime image. + +During one repeated invalid-login check immediately after several auth attempts, the auth rate limiter returned `503 Service Unavailable`. That is consistent with rate limiting behavior during smoke repetition, not a setup failure. + +## Screenshots + +No screenshots were captured. The smoke used HTTP checks and saved local response HTML under `/private/tmp` during the run. + +## Remaining Gaps + +- The smoke did not exercise a full browser UI interaction beyond HTTP form submission. +- The setup wizard currently creates the legacy company-level `CashBalance`, not a persisted Cash Custody `CashAccount`; the Cash Custody persistence model is not implemented yet. +- The `libgssapi_krb5.so.2` startup log should be investigated in a separate Docker/runtime hardening task.