Compare commits

...

15 Commits

Author SHA1 Message Date
b3d78ff56d Merge pull request 'UDT-001: Login (scaffolding + JWT RS256 end-to-end)' (#1) from feature/UDT-001 into main 2026-04-14 14:44:28 +00:00
a15d8c166e chore(udt-001): vite scaffold default assets 2026-04-13 21:36:49 -03:00
4fa891f340 chore(udt-001): add skill registry 2026-04-13 21:36:41 -03:00
6c4d572111 docs(udt-001): smoke test checklist 2026-04-13 21:36:41 -03:00
f4f063f5f0 test(udt-001): frontend tests (authStore, authApi, LoginPage - 11 tests) 2026-04-13 21:36:40 -03:00
a692576bc3 feat(udt-001): frontend auth UI (Zustand store, TanStack Query, LoginPage, router) 2026-04-13 21:36:32 -03:00
5f6ebccb54 feat(udt-001): frontend scaffold (Vite 6 + React 19 + TS strict + Tailwind 4) 2026-04-13 21:36:17 -03:00
b657dc0d2a test(udt-001): backend unit and integration tests (30 tests) 2026-04-13 21:36:09 -03:00
9891f96618 feat(udt-001): api layer with AuthController, Program.cs and Serilog 2026-04-13 21:36:08 -03:00
ca57ce33b5 feat(udt-001): infrastructure (Dapper, BCrypt, JWT RS256, dispatcher) 2026-04-13 21:36:02 -03:00
8c26cd3ac5 feat(udt-001): application layer with LoginCommandHandler and ports 2026-04-13 21:36:01 -03:00
2111070c77 feat(udt-001): domain layer with Usuario entity 2026-04-13 21:36:00 -03:00
88ecaa2c7f chore(udt-001): RSA key generation script 2026-04-13 21:35:56 -03:00
1e5cac737b feat(udt-001): db schema for Usuario with admin seed 2026-04-13 21:35:55 -03:00
c666729685 chore(udt-001): repo scaffold with central package management 2026-04-13 21:35:51 -03:00
89 changed files with 11159 additions and 0 deletions

61
.atl/skill-registry.md Normal file
View File

@@ -0,0 +1,61 @@
# Skill Registry — sig-cm2
Generated: 2026-04-13
## User Skills
| Skill | Trigger |
|-------|---------|
| `sdd-init` | User says "sdd init", "iniciar sdd", "openspec init" |
| `sdd-explore` | Orchestrator launches exploration of a feature or codebase area |
| `sdd-propose` | Orchestrator launches proposal for a change |
| `sdd-spec` | Orchestrator launches spec writing for a change |
| `sdd-design` | Orchestrator launches technical design for a change |
| `sdd-tasks` | Orchestrator launches task breakdown for a change |
| `sdd-apply` | Orchestrator launches implementation of tasks |
| `sdd-verify` | Orchestrator launches verification of a completed change |
| `sdd-archive` | Orchestrator launches archival of a completed change |
| `sdd-onboard` | User wants a guided SDD walkthrough |
| `judgment-day` | User says "judgment day", "review adversarial", "doble review", "juzgar" |
| `go-testing` | Writing Go tests, using teatest, Bubbletea TUI testing |
| `skill-creator` | Creating a new AI agent skill |
| `branch-pr` | Creating a pull request, preparing changes for review |
| `issue-creation` | Creating a GitHub issue, bug report, or feature request |
| `skill-registry` | Update skill registry, "actualizar skills" |
| `obsidian-cli` | Interact with Obsidian vault via CLI |
| `obsidian-markdown` | Creating/editing Obsidian Flavored Markdown (.md files in vault) |
| `gitea-workflow` | Agile workflow for Gitea repos, "run the workflow", "what's next" |
| `find-skills` | "Find a skill for X", "how do I do X", discover capabilities |
## Project Conventions
| File | Role |
|------|------|
| `Obsidian/SPEC.md` | Source of truth — visión, módulos, tech stack |
| `Obsidian/STATUS.md` | Estado de UDTs — ÚNICO lugar para marcar tareas `[x]` |
| `Obsidian/INSTRUCCIONES_IA.md` | SOP del agente: bucle de ejecución, reglas de lectura |
| `Obsidian/02-ARQUITECTURA-y-TECH-STACK/` | UDTs por módulo con CMV (Contexto Mínimo Viable) |
| `Obsidian/04-DOMINIO-y-REGLAS-de-NEGOCIO/` | Reglas de negocio — consultar ante dudas |
## Compact Rules
### SIG-CM2 Development Rules
- Orden de implementación SIEMPRE: BD → Backend → Frontend
- Rama por UDT: `feature/UDT-XXX` (o VTA-XXX, TAS-XXX, INT-XXX, ADM-XXX)
- Commits: `tipo(módulo): descripción` — feat/fix/docs/refactor/test/chore/security
- NUNCA leer `Obsidian/07-RELEVAMIENTOS/` sin instrucción humana explícita
- Para dudas de negocio: consultar `04-DOMINIO-y-REGLAS-de-NEGOCIO/` o `SPEC.md`
- Antes de cada UDT: leer STATUS.md → leer UDT en carpeta 02 → cargar solo el CMV indicado
### Architecture
- Clean Architecture: SIGCM2.Api / SIGCM2.Application / SIGCM2.Domain / SIGCM2.Infrastructure
- Backend ORM: Dapper 2.x (NO Entity Framework — decisión arquitectural)
- Lógica crítica de negocio: Stored Procedures en SQL Server
- Frontend state: Zustand (global) + TanStack Query (server state)
- Frontend estructura: src/api, src/components/{ui,features}, src/features/*, src/hooks, src/layouts, src/pages, src/stores, src/utils
### Strict TDD Mode (ACTIVE)
- Tests ANTES del código de producción (Red → Green → Refactor)
- Backend: xUnit + NSubstitute — comando: `dotnet test`
- Frontend: Vitest + React Testing Library — comando: `vitest`
- Coverage backend: `dotnet test --collect:"XPlat Code Coverage"`
- Coverage frontend: `vitest --coverage`

35
.gitignore vendored
View File

@@ -29,6 +29,41 @@ yarn-error.log*
#.env.production.local
# ----------------------------------------------------------------------------
# ## .NET Build Artifacts ##
# ----------------------------------------------------------------------------
[Bb]in/
[Oo]bj/
*.user
*.suo
*.userosscache
*.sln.docstates
.vs/
TestResults/
*.trx
*.coverage
*.coveragexml
# ----------------------------------------------------------------------------
# ## JWT / Security Keys ##
# ----------------------------------------------------------------------------
src/api/SIGCM2.Api/keys/*.pem
# ----------------------------------------------------------------------------
# ## ASP.NET Core local secrets ##
# ----------------------------------------------------------------------------
src/api/SIGCM2.Api/appsettings.Development.json
src/api/SIGCM2.Api/appsettings.Test.json
tests/SIGCM2.Api.Tests/appsettings.Test.json
tests/SIGCM2.Application.Tests/appsettings.Test.json
# ----------------------------------------------------------------------------
# ## Frontend Build Artifacts ##
# ----------------------------------------------------------------------------
src/web/dist/
src/web/node_modules/
src/web/.vite/
# ----------------------------------------------------------------------------
# ## Documentación ##
# ----------------------------------------------------------------------------

30
Directory.Packages.props Normal file
View File

@@ -0,0 +1,30 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Production dependencies -->
<ItemGroup>
<PackageVersion Include="Dapper" Version="2.1.35" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageVersion Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.3.25172.1" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
</ItemGroup>
<!-- Test dependencies -->
<ItemGroup>
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.3.25172.1" />
<PackageVersion Include="Respawn" Version="6.2.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>

14
SIGCM2.slnx Normal file
View File

@@ -0,0 +1,14 @@
<Solution>
<Folder Name="/src/" />
<Folder Name="/src/api/">
<Project Path="src/api/SIGCM2.Api/SIGCM2.Api.csproj" />
<Project Path="src/api/SIGCM2.Application/SIGCM2.Application.csproj" />
<Project Path="src/api/SIGCM2.Domain/SIGCM2.Domain.csproj" />
<Project Path="src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj" />
<Project Path="tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj" />
<Project Path="tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,43 @@
-- V001__create_usuario.sql
-- Creates the core Usuario table for SIG-CM2
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
IF OBJECT_ID('dbo.Usuario', 'U') IS NULL
BEGIN
CREATE TABLE dbo.Usuario (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Usuario PRIMARY KEY,
Username NVARCHAR(50) NOT NULL,
PasswordHash NVARCHAR(255) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Apellido NVARCHAR(100) NOT NULL,
Email NVARCHAR(150) NULL,
Rol VARCHAR(30) NOT NULL,
PermisosJson NVARCHAR(MAX) NOT NULL CONSTRAINT DF_Usuario_Permisos DEFAULT('[]'),
Activo BIT NOT NULL CONSTRAINT DF_Usuario_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Usuario_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
UltimoLogin DATETIME2(3) NULL,
CONSTRAINT UQ_Usuario_Username UNIQUE (Username),
CONSTRAINT CK_Usuario_Rol CHECK (Rol IN ('admin','vendedor','tasador','consulta'))
);
PRINT 'Table dbo.Usuario created successfully.';
END
ELSE
BEGIN
PRINT 'Table dbo.Usuario already exists — skipping.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Usuario_Username_Activo' AND object_id = OBJECT_ID('dbo.Usuario'))
BEGIN
CREATE INDEX IX_Usuario_Username_Activo
ON dbo.Usuario(Username)
WHERE Activo = 1;
PRINT 'Index IX_Usuario_Username_Activo created.';
END
GO

View File

@@ -0,0 +1,30 @@
-- S001__seed_admin.sql
-- Seeds the default admin user for SIG-CM2
-- BCrypt hash of '@Diego550@' at cost 12
-- Generated: 2026-04-13
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
BEGIN
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Email, Rol, PermisosJson, Activo)
VALUES (
'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
'Administrador',
'Sistema',
NULL,
'admin',
'["*"]',
1
);
PRINT 'Admin user seeded successfully.';
END
ELSE
BEGIN
PRINT 'Admin user already exists — skipping.';
END
GO

134
docs/smoke-test-udt-001.md Normal file
View File

@@ -0,0 +1,134 @@
# Smoke Test — UDT-001 Login
Manual checklist para verificar la integración completa del flujo de login.
## Pre-requisitos
- SQL Server TECNICA3 con base `SIGCM2` y seed admin ejecutado (`database/seeds/S001__seed_admin.sql`)
- Claves RSA generadas: `scripts/generate-keys.ps1` ya corrido
- `src/api/SIGCM2.Api/appsettings.Development.json` configurado con connection string y rutas de claves
- Node.js 18+ instalado
- .NET 10 SDK instalado
## Pasos
### 1. Arrancar el backend
Abrir Terminal 1 en la raíz del repositorio:
```bash
dotnet run --project src/api/SIGCM2.Api
```
Verificar que la consola muestre algo similar a:
```
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
```
### 2. Arrancar el frontend
Abrir Terminal 2:
```bash
cd src/web
npm run dev
```
Verificar que la consola muestre:
```
VITE v8.x.x ready in Xms
➜ Local: http://localhost:5173/
```
### 3. Verificar redirect a /login
- Abrir `http://localhost:5173` en el navegador
- Debe redirigir automáticamente a `http://localhost:5173/login`
- Debe mostrar el formulario de login con campos **Usuario** y **Contraseña**
**Esperado**: Formulario visible, sin errores en consola del navegador.
### 4. Login con credenciales válidas
- Ingresar `admin` en el campo **Usuario**
- Ingresar `@Diego550@` en el campo **Contraseña**
- Hacer click en **Ingresar**
**Esperado**: Botón se deshabilita brevemente mientras carga.
### 5. Verificar Network tab — POST /api/v1/auth/login
- Abrir DevTools → pestaña **Network**
- Buscar la request `POST /api/v1/auth/login`
- Verificar:
- Status: `200 OK`
- Response body contiene: `accessToken`, `refreshToken`, `expiresIn`, `usuario`
- `usuario.username` = `"admin"`, `usuario.rol` = `"admin"`
**Esperado**: Respuesta 200 con JWT válido.
### 6. Verificar LocalStorage — auth-storage
- DevTools → pestaña **Application** → Storage → Local Storage → `http://localhost:5173`
- Buscar clave `auth-storage`
- Verificar que el JSON contenga:
```json
{
"state": {
"user": { "username": "admin", "rol": "admin", ... },
"accessToken": "eyJ..."
}
}
```
**Esperado**: Token y datos de usuario persistidos correctamente.
### 7. Verificar redirect a Dashboard
- Luego del login exitoso, la URL debe cambiar a `http://localhost:5173/`
- Debe mostrarse: **"SIG-CM2 — Dashboard — Bienvenido al SIG-CM2."**
**Esperado**: Placeholder de Dashboard visible.
### 8. Verificar firma JWT en jwt.io
- Copiar el valor de `accessToken` del LocalStorage
- Abrir [https://jwt.io](https://jwt.io)
- Pegar el token en el campo "Encoded"
- En "VERIFY SIGNATURE" → sección "Public Key or Certificate": pegar el contenido de `src/api/SIGCM2.Api/keys/public.pem`
- Verificar:
- Header: `"alg": "RS256"`
- Payload contiene: `sub`, `name` (= `"admin"`), `rol` (= `"admin"`), `permisos` (= `["*"]`), `iss`, `aud`, `exp`
- Footer muestra: **"Signature Verified"** (fondo azul)
**Esperado**: Firma RS256 válida, claims correctos.
### 9. Probar login fallido
- Volver a `http://localhost:5173/login` (o hacer logout si hubiera botón)
- Ingresar `admin` / `wrongpass`
- Hacer click en **Ingresar**
- Verificar en **Network**: `POST /api/v1/auth/login` → Status `401`
- Verificar en la UI: mensaje de error visible con texto **"Credenciales inválidas"** (sin stack trace)
**Esperado**: Error visible en UI, sin exposición de detalles internos.
---
## Resultado esperado global
| Paso | Resultado |
|------|-----------|
| 1. Backend arranca en :5000 | ✅ / ❌ |
| 2. Frontend arranca en :5173 | ✅ / ❌ |
| 3. Redirect a /login | ✅ / ❌ |
| 4. Login con admin/@Diego550@ | ✅ / ❌ |
| 5. Network: POST 200 + JWT | ✅ / ❌ |
| 6. LocalStorage: auth-storage con token | ✅ / ❌ |
| 7. Redirect a / Dashboard | ✅ / ❌ |
| 8. JWT verificado en jwt.io (RS256) | ✅ / ❌ |
| 9. Login fallido: error en UI, 401 en Network | ✅ / ❌ |

30
scripts/generate-keys.ps1 Normal file
View File

@@ -0,0 +1,30 @@
# generate-keys.ps1
# Generates RSA 2048 key pair for JWT RS256 signing
# Requires: PowerShell 7+ (pwsh)
# Usage: pwsh -File scripts/generate-keys.ps1
# Keys are written to src/api/SIGCM2.Api/keys/ (gitignored)
$keysDir = Join-Path $PSScriptRoot "..\src\api\SIGCM2.Api\keys"
$keysDir = [System.IO.Path]::GetFullPath($keysDir)
if (-not (Test-Path $keysDir)) {
New-Item -ItemType Directory -Path $keysDir | Out-Null
}
$privatePath = Join-Path $keysDir "private.pem"
$publicPath = Join-Path $keysDir "public.pem"
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
$priv = $rsa.ExportRSAPrivateKeyPem()
$pub = $rsa.ExportRSAPublicKeyPem()
$rsa.Dispose()
Set-Content -Path $privatePath -Value $priv -Encoding UTF8 -NoNewline
Set-Content -Path $publicPath -Value $pub -Encoding UTF8 -NoNewline
Write-Host "RSA 2048 key pair generated:"
Write-Host " Private: $privatePath"
Write-Host " Public: $publicPath"
Write-Host ""
Write-Host "IMPORTANT: These files are gitignored. Regenerate on each dev machine."
Write-Host "For production: set env vars JWT__PrivateKey and JWT__PublicKey (PEM content)."

View File

@@ -0,0 +1,48 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
namespace SIGCM2.Api.Controllers;
[ApiController]
[Route("api/v1/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<LoginCommand> _validator;
public AuthController(IDispatcher dispatcher, IValidator<LoginCommand> validator)
{
_dispatcher = dispatcher;
_validator = validator;
}
/// <summary>Authenticates a user and returns a JWT access token.</summary>
/// <response code="200">Returns access token and refresh token.</response>
/// <response code="400">Validation error — missing or empty fields.</response>
/// <response code="401">Invalid credentials.</response>
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty);
var validation = await _validator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<LoginCommand, LoginResponseDto>(command);
return Ok(result);
}
}
/// <summary>Login request body — nullable to catch missing field scenarios.</summary>
public sealed record LoginRequest(string? Username, string? Password);

View File

@@ -0,0 +1,50 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Api.Filters;
public sealed class ExceptionFilter : IExceptionFilter
{
private readonly ILogger<ExceptionFilter> _logger;
public ExceptionFilter(ILogger<ExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
switch (context.Exception)
{
case InvalidCredentialsException:
context.Result = new ObjectResult(new { error = "Credenciales inválidas" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
context.ExceptionHandled = true;
break;
case ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
context.Result = new BadRequestObjectResult(new { errors });
context.ExceptionHandled = true;
break;
default:
_logger.LogError(context.Exception, "Unhandled exception");
context.Result = new ObjectResult(new { error = "Internal server error" })
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
break;
}
}
}

View File

@@ -0,0 +1,69 @@
using Serilog;
using Scalar.AspNetCore;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
using SIGCM2.Api.Filters;
// Bootstrap logger — before DI is built
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
Log.Information("Starting SIGCM2 API");
var builder = WebApplication.CreateBuilder(args);
// Serilog — reads from appsettings.json "Serilog" section
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration));
// Application + Infrastructure DI
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// Controllers with exception filter
builder.Services.AddControllers(opts =>
{
opts.Filters.Add<ExceptionFilter>();
});
// OpenAPI / Scalar
builder.Services.AddOpenApi();
// CORS
var allowedOrigins = builder.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? [];
builder.Services.AddCors(opts =>
{
opts.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod());
});
var app = builder.Build();
// Middleware pipeline
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
{
app.MapOpenApi();
app.MapScalarApiReference(opts =>
{
opts.Title = "SIGCM2 API";
});
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
// Exposed for WebApplicationFactory in integration tests
public partial class Program { }

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7280;http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SIGCM2.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Seq" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="FluentValidation.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@SIGCM2.Api_HostAddress = http://localhost:5212
GET {{SIGCM2.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,13 @@
{
"ConnectionStrings": {
"SqlServer": "Server=<YOUR_SERVER>;Database=SIGCM2;User Id=<YOUR_USER>;Password=<YOUR_PASSWORD>;TrustServerCertificate=True;"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft.AspNetCore": "Information"
}
}
}
}

View File

@@ -0,0 +1,35 @@
{
"ConnectionStrings": {
"SqlServer": "Server=__SET_IN_DEV_OR_ENV__;Database=SIGCM2;User Id=__SET_IN_DEV_OR_ENV__;Password=__SET_IN_DEV_OR_ENV__;TrustServerCertificate=True;"
},
"Jwt": {
"Issuer": "sigcm2.api",
"Audience": "sigcm2.web",
"AccessTokenMinutes": 60,
"PrivateKeyPath": "keys/private.pem",
"PublicKeyPath": "keys/public.pem",
"PrivateKey": null,
"PublicKey": null
},
"Cors": {
"AllowedOrigins": [ "http://localhost:5173" ]
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "Seq",
"Args": { "serverUrl": "http://localhost:5341" }
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"AllowedHosts": "*"
}

View File

View File

@@ -0,0 +1,28 @@
# JWT RSA Keys
This directory holds the RSA 2048 key pair used for JWT RS256 signing.
## Files (gitignored)
- `private.pem` — RSA private key (NEVER commit this)
- `public.pem` — RSA public key (NEVER commit this)
- `.gitkeep` — keeps this directory tracked in git
## Regenerate keys
Run from the repo root (requires PowerShell 7 / pwsh):
```powershell
pwsh -File scripts/generate-keys.ps1
```
## Production
In production, set these environment variables instead of files:
```
JWT__PrivateKey=<base64-encoded PEM content>
JWT__PublicKey=<base64-encoded PEM content>
```
The API's `RsaKeyLoader` checks environment variables first, falls back to files.

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Abstractions;
public interface ICommandHandler<TCommand, TResult>
{
Task<TResult> Handle(TCommand command);
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Abstractions;
public interface IDispatcher
{
Task<TResult> Send<TCommand, TResult>(TCommand command);
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Abstractions;
public interface IQueryHandler<TQuery, TResult>
{
Task<TResult> Handle(TQuery query);
}

View File

@@ -0,0 +1,8 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IUsuarioRepository
{
Task<Usuario?> GetByUsernameAsync(string username);
}

View File

@@ -0,0 +1,8 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Security;
public interface IJwtService
{
string GenerateAccessToken(Usuario usuario);
}

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Abstractions.Security;
public interface IPasswordHasher
{
bool Verify(string plainPassword, string hash);
string Hash(string plainPassword);
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Auth.Login;
public sealed record LoginCommand(string Username, string Password);

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Auth.Login;
public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginResponseDto>
{
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
private readonly IJwtService _jwtService;
public LoginCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher,
IJwtService jwtService)
{
_repository = repository;
_hasher = hasher;
_jwtService = jwtService;
}
public async Task<LoginResponseDto> Handle(LoginCommand command)
{
var usuario = await _repository.GetByUsernameAsync(command.Username);
// Deliberately vague — never reveal which check failed
if (usuario is null || !usuario.Activo)
throw new InvalidCredentialsException();
if (!_hasher.Verify(command.Password, usuario.PasswordHash))
throw new InvalidCredentialsException();
var accessToken = _jwtService.GenerateAccessToken(usuario);
var refreshToken = Guid.NewGuid().ToString("N"); // opaque, not persisted in UDT-001
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson)
?? Array.Empty<string>();
return new LoginResponseDto(
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: 3600,
Usuario: new UsuarioDto(
Id: usuario.Id,
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),
Rol: usuario.Rol,
Permisos: permisos
)
);
}
}

View File

@@ -0,0 +1,17 @@
using FluentValidation;
namespace SIGCM2.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.Username)
.NotEmpty()
.WithMessage("El nombre de usuario es requerido.");
RuleFor(x => x.Password)
.NotEmpty()
.WithMessage("La contraseña es requerida.");
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Application.Auth.Login;
public sealed record LoginResponseDto(
string AccessToken,
string RefreshToken,
int ExpiresIn,
UsuarioDto Usuario
);
public sealed record UsuarioDto(
int Id,
string Nombre,
string Rol,
string[] Permisos
);

View File

@@ -0,0 +1,20 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
namespace SIGCM2.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Register command handlers
services.AddScoped<ICommandHandler<LoginCommand, LoginResponseDto>, LoginCommandHandler>();
// Register FluentValidation validators from this assembly
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
return services;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SIGCM2.Application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
namespace SIGCM2.Domain.Entities;
public sealed class Usuario
{
public int Id { get; }
public string Username { get; }
public string PasswordHash { get; }
public string Nombre { get; }
public string Apellido { get; }
public string? Email { get; }
public string Rol { get; }
public string PermisosJson { get; }
public bool Activo { get; }
public Usuario(
int id,
string username,
string passwordHash,
string nombre,
string apellido,
string? email,
string rol,
string permisosJson,
bool activo)
{
Id = id;
Username = username;
PasswordHash = passwordHash;
Nombre = nombre;
Apellido = apellido;
Email = email;
Rol = rol;
PermisosJson = permisosJson;
Activo = activo;
}
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when login credentials are invalid (user not found, wrong password, or inactive).
/// Deliberately vague to prevent user enumeration.
/// </summary>
public sealed class InvalidCredentialsException : Exception
{
public InvalidCredentialsException()
: base("Credenciales inválidas") { }
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SIGCM2.Domain</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Infrastructure.Messaging;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.Infrastructure.Security;
namespace SIGCM2.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Database
var connectionString = configuration.GetConnectionString("SqlServer")
?? throw new InvalidOperationException("Missing ConnectionStrings:SqlServer");
services.AddSingleton(new SqlConnectionFactory(connectionString));
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
// Also expose as JwtOptions directly for convenience (resolves via IOptions<JwtOptions>)
services.AddSingleton<JwtOptions>(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
// RSA key pair — loaded lazily as singletons from the fully-resolved JwtOptions
services.AddSingleton<RSA>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return RsaKeyLoader.LoadPrivateKey(opts);
});
services.AddSingleton<RsaSecurityKey>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return new RsaSecurityKey(RsaKeyLoader.LoadPublicKey(opts));
});
services.AddScoped<IJwtService>(sp =>
new JwtService(sp.GetRequiredService<RSA>(), sp.GetRequiredService<JwtOptions>()));
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();
// JWT Bearer authentication
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
// Post-configure JWT Bearer — wire RSA public key + validation params from resolved options
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.PostConfigure<RsaSecurityKey, JwtOptions>((jwtBearerOpts, rsaKey, jwtOpts) =>
{
jwtBearerOpts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = rsaKey,
ValidateIssuer = true,
ValidIssuer = jwtOpts.Issuer,
ValidateAudience = true,
ValidAudience = jwtOpts.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
});
return services;
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
using SIGCM2.Application.Abstractions;
namespace SIGCM2.Infrastructure.Messaging;
public sealed class Dispatcher : IDispatcher
{
private readonly IServiceProvider _serviceProvider;
public Dispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public Task<TResult> Send<TCommand, TResult>(TCommand command)
{
var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand, TResult>>();
return handler.Handle(command!);
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.Data.SqlClient;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class SqlConnectionFactory
{
private readonly string _connectionString;
public SqlConnectionFactory(string connectionString)
{
_connectionString = connectionString;
}
public SqlConnection CreateConnection() => new(_connectionString);
}

View File

@@ -0,0 +1,60 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class UsuarioRepository : IUsuarioRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public UsuarioRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<Usuario?> GetByUsernameAsync(string username)
{
const string sql = """
SELECT
Id, Username, PasswordHash,
Nombre, Apellido, Email,
Rol, PermisosJson, Activo
FROM dbo.Usuario
WHERE Username = @Username
AND Activo = 1
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync();
var row = await connection.QuerySingleOrDefaultAsync<UsuarioRow>(sql, new { Username = username });
if (row is null) return null;
return new Usuario(
id: row.Id,
username: row.Username,
passwordHash: row.PasswordHash,
nombre: row.Nombre,
apellido: row.Apellido,
email: row.Email,
rol: row.Rol,
permisosJson: row.PermisosJson,
activo: row.Activo
);
}
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
private sealed record UsuarioRow(
int Id,
string Username,
string PasswordHash,
string Nombre,
string Apellido,
string? Email,
string Rol,
string PermisosJson,
bool Activo
);
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SIGCM2.Infrastructure</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using SIGCM2.Application.Abstractions.Security;
using BCryptNet = BCrypt.Net.BCrypt;
namespace SIGCM2.Infrastructure.Security;
public sealed class BcryptPasswordHasher : IPasswordHasher
{
private const int WorkFactor = 12;
public bool Verify(string plainPassword, string hash)
=> BCryptNet.Verify(plainPassword, hash);
public string Hash(string plainPassword)
=> BCryptNet.HashPassword(plainPassword, WorkFactor);
}

View File

@@ -0,0 +1,20 @@
namespace SIGCM2.Infrastructure.Security;
public sealed class JwtOptions
{
public string Issuer { get; set; } = "sigcm2.api";
public string Audience { get; set; } = "sigcm2.web";
public int AccessTokenMinutes { get; set; } = 60;
/// <summary>Path to private.pem file (dev). Used if PrivateKey is null.</summary>
public string? PrivateKeyPath { get; set; }
/// <summary>Path to public.pem file (dev). Used if PublicKey is null.</summary>
public string? PublicKeyPath { get; set; }
/// <summary>PEM content from env var (production). Takes precedence over file.</summary>
public string? PrivateKey { get; set; }
/// <summary>PEM content from env var (production). Takes precedence over file.</summary>
public string? PublicKey { get; set; }
}

View File

@@ -0,0 +1,68 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Security;
public sealed class JwtService : IJwtService
{
private readonly RSA _rsa;
private readonly JwtOptions _options;
public JwtService(RSA rsa, JwtOptions options)
{
_rsa = rsa;
_options = options;
}
public string GenerateAccessToken(Usuario usuario)
{
var signingKey = new RsaSecurityKey(_rsa);
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
var permisos = DeserializePermisos(usuario.PermisosJson);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, usuario.Id.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("name", usuario.Username),
new("rol", usuario.Rol),
};
// Add each permission as a separate claim
foreach (var permiso in permisos)
claims.Add(new Claim("permisos", permiso));
var now = DateTime.UtcNow;
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.Issuer,
Audience = _options.Audience,
IssuedAt = now,
Expires = now.AddMinutes(_options.AccessTokenMinutes),
SigningCredentials = credentials
};
var handler = new JwtSecurityTokenHandler();
var token = handler.CreateToken(descriptor);
return handler.WriteToken(token);
}
private static string[] DeserializePermisos(string permisosJson)
{
try
{
return JsonSerializer.Deserialize<string[]>(permisosJson) ?? [];
}
catch
{
return [];
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Security.Cryptography;
namespace SIGCM2.Infrastructure.Security;
public static class RsaKeyLoader
{
/// <summary>
/// Loads the RSA private key from environment variable (production)
/// or from the PEM file on disk (development).
/// </summary>
public static RSA LoadPrivateKey(JwtOptions options)
{
var pem = options.PrivateKey ?? ReadPemFile(options.PrivateKeyPath, "private.pem");
var rsa = RSA.Create();
rsa.ImportFromPem(pem);
return rsa;
}
/// <summary>
/// Loads the RSA public key from environment variable (production)
/// or from the PEM file on disk (development).
/// </summary>
public static RSA LoadPublicKey(JwtOptions options)
{
var pem = options.PublicKey ?? ReadPemFile(options.PublicKeyPath, "public.pem");
var rsa = RSA.Create();
rsa.ImportFromPem(pem);
return rsa;
}
private static string ReadPemFile(string? path, string fallbackName)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException(
$"JWT key not configured. Set the env var or provide a path for {fallbackName}. " +
$"Run: pwsh -File scripts/generate-keys.ps1");
}
if (!File.Exists(path))
{
throw new FileNotFoundException(
$"JWT key file not found at '{path}'. " +
$"Run: pwsh -File scripts/generate-keys.ps1", path);
}
return File.ReadAllText(path);
}
}

24
src/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
src/web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:5000

23
src/web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7911
src/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
src/web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@tanstack/react-query": "^5.99.0",
"axios": "1.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.13.2",
"tailwindcss": "^4.0.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
"vitest": "^2.1.9"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
src/web/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
src/web/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

22
src/web/src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AppRoutes } from './router'
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 1000 * 60 * 5 },
mutations: { retry: 0 },
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

View File

@@ -0,0 +1,10 @@
import axios from 'axios'
const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5000'
export const axiosClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
})

BIN
src/web/src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,21 @@
import { axiosClient } from '../../../api/axiosClient'
export interface LoginResponseDto {
accessToken: string
refreshToken: string
expiresIn: number
usuario: {
id: number
username: string
nombre: string
rol: string
}
}
export async function login(username: string, password: string): Promise<LoginResponseDto> {
const response = await axiosClient.post<LoginResponseDto>('/api/v1/auth/login', {
username,
password,
})
return response.data
}

View File

@@ -0,0 +1,66 @@
import type { FormEvent } from 'react'
interface LoginFormProps {
onSubmit: (username: string, password: string) => void
isLoading: boolean
error: string | null
}
export function LoginForm({ onSubmit, isLoading, error }: LoginFormProps) {
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
const username = data.get('username') as string
const password = data.get('password') as string
onSubmit(username, password)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
<div className="flex flex-col gap-1">
<label htmlFor="username" className="text-sm font-medium text-gray-700">
Usuario
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="username"
disabled={isLoading}
className="rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium text-gray-700">
Contraseña
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
disabled={isLoading}
className="rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Ingresando...' : 'Ingresar'}
</button>
</form>
)
}

View File

@@ -0,0 +1,29 @@
import { useMutation } from '@tanstack/react-query'
import { login } from '../api/authApi'
import { useAuthStore } from '../../../stores/authStore'
interface LoginVars {
username: string
password: string
}
export function useLogin() {
const setAuth = useAuthStore((s) => s.setAuth)
return useMutation({
mutationFn: ({ username, password }: LoginVars) => login(username, password),
onSuccess: (data) => {
setAuth({
user: {
id: data.usuario.id,
username: data.usuario.username,
nombre: data.usuario.nombre,
rol: data.usuario.rol,
},
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresIn: data.expiresIn,
})
},
})
}

View File

@@ -0,0 +1,44 @@
import { useNavigate } from 'react-router-dom'
import { useLogin } from '../hooks/useLogin'
import { LoginForm } from '../components/LoginForm'
import { isAxiosError } from 'axios'
export function LoginPage() {
const navigate = useNavigate()
const { mutate, isPending, error } = useLogin()
function handleSubmit(username: string, password: string) {
mutate(
{ username, password },
{
onSuccess: () => {
void navigate('/')
},
},
)
}
function resolveErrorMessage(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string }
return data.error ?? 'Error al iniciar sesión'
}
return 'Error al iniciar sesión'
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="rounded-lg bg-white p-8 shadow-md w-full max-w-sm">
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900">
SIG-CM2
</h1>
<LoginForm
onSubmit={handleSubmit}
isLoading={isPending}
error={resolveErrorMessage(error)}
/>
</div>
</div>
)
}

81
src/web/src/index.css Normal file
View File

@@ -0,0 +1,81 @@
@import "tailwindcss";
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
interface ProtectedLayoutProps {
children: ReactNode
}
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
return (
<div className="min-h-screen bg-white">
{children}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
interface PublicLayoutProps {
children: ReactNode
}
export function PublicLayout({ children }: PublicLayoutProps) {
return (
<div className="min-h-screen bg-gray-50">
{children}
</div>
)
}

10
src/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,8 @@
export function HomePage() {
return (
<div className="p-8">
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Bienvenido al SIG-CM2.</p>
</div>
)
}

50
src/web/src/router.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import { LoginPage } from './features/auth/pages/LoginPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user)
if (!user) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user)
if (user) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
export function AppRoutes() {
return (
<Routes>
<Route
path="/login"
element={
<PublicRoute>
<PublicLayout>
<LoginPage />
</PublicLayout>
</PublicRoute>
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<ProtectedLayout>
<HomePage />
</ProtectedLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,53 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface AuthUser {
id: number
username: string
nombre: string
rol: string
}
interface SetAuthPayload {
user: AuthUser
accessToken: string
refreshToken: string
expiresIn: number
}
interface AuthState {
user: AuthUser | null
accessToken: string | null
setAuth: (payload: SetAuthPayload) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
setAuth: (payload: SetAuthPayload) => {
set({
user: payload.user,
accessToken: payload.accessToken,
})
},
logout: () => {
set({
user: null,
accessToken: null,
})
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
}),
},
),
)

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { LoginPage } from '../../../features/auth/pages/LoginPage'
import { useAuthStore } from '../../../stores/authStore'
// Must be at top level for Vitest hoisting
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>()
return { ...actual, useNavigate: () => mockNavigate }
})
const API_URL = 'http://localhost:5000'
const mockLoginResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig',
refreshToken: 'refresh-token-abc',
expiresIn: 3600,
usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
}
const server = setupServer(
http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => {
const body = await request.json() as { username: string; password: string }
if (body.username === 'admin' && body.password === '@Diego550@') {
return HttpResponse.json(mockLoginResponse, { status: 200 })
}
return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 })
}),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().logout()
localStorage.clear()
mockNavigate.mockClear()
})
afterAll(() => server.close())
function renderLoginPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<LoginPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('LoginPage', () => {
it('renders username and password inputs and submit button', () => {
renderLoginPage()
expect(screen.getByLabelText(/usuario/i)).toBeInTheDocument()
expect(screen.getByLabelText(/contraseña/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /ingresar/i })).toBeInTheDocument()
})
it('shows error message on 401 invalid credentials', async () => {
const user = userEvent.setup()
renderLoginPage()
await user.type(screen.getByLabelText(/usuario/i), 'admin')
await user.type(screen.getByLabelText(/contraseña/i), 'wrongpassword')
await user.click(screen.getByRole('button', { name: /ingresar/i }))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/credenciales/i)
})
})
it('disables submit button while loading', async () => {
const user = userEvent.setup()
renderLoginPage()
server.use(
http.post(`${API_URL}/api/v1/auth/login`, async () => {
await new Promise((resolve) => setTimeout(resolve, 300))
return HttpResponse.json(mockLoginResponse)
}),
)
await user.type(screen.getByLabelText(/usuario/i), 'admin')
await user.type(screen.getByLabelText(/contraseña/i), '@Diego550@')
const button = screen.getByRole('button', { name: /ingresar/i })
await user.click(button)
// Button should be disabled during the pending request
expect(button).toBeDisabled()
})
it('saves auth to store on successful login', async () => {
const user = userEvent.setup()
renderLoginPage()
await user.type(screen.getByLabelText(/usuario/i), 'admin')
await user.type(screen.getByLabelText(/contraseña/i), '@Diego550@')
await user.click(screen.getByRole('button', { name: /ingresar/i }))
await waitFor(() => {
const state = useAuthStore.getState()
expect(state.accessToken).toBe(mockLoginResponse.accessToken)
expect(state.user?.username).toBe('admin')
})
})
})

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { login } from '../../../features/auth/api/authApi'
const API_URL = 'http://localhost:5000'
const mockLoginResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.signature',
refreshToken: 'opaque-refresh-token-abc123',
expiresIn: 3600,
usuario: {
id: 1,
username: 'admin',
nombre: 'Admin',
rol: 'admin',
},
}
const server = setupServer(
http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => {
const body = await request.json() as { username: string; password: string }
if (body.username === 'admin' && body.password === '@Diego550@') {
return HttpResponse.json(mockLoginResponse, { status: 200 })
}
return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 })
}),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('login()', () => {
it('returns auth data on valid credentials', async () => {
const result = await login('admin', '@Diego550@')
expect(result.accessToken).toBe(mockLoginResponse.accessToken)
expect(result.refreshToken).toBe(mockLoginResponse.refreshToken)
expect(result.expiresIn).toBe(3600)
expect(result.usuario.username).toBe('admin')
})
it('throws on invalid credentials (401)', async () => {
await expect(login('admin', 'wrongpassword')).rejects.toThrow()
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useAuthStore } from '../../stores/authStore'
describe('authStore', () => {
beforeEach(() => {
// Reset store state before each test
useAuthStore.getState().logout()
localStorage.clear()
})
describe('initial state', () => {
it('starts with null user and null accessToken', () => {
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
})
describe('setAuth', () => {
it('stores user and accessToken in state', () => {
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
refreshToken: 'opaque-refresh-token',
expiresIn: 3600,
}
useAuthStore.getState().setAuth(payload)
const state = useAuthStore.getState()
expect(state.user).toEqual(payload.user)
expect(state.accessToken).toBe(payload.accessToken)
})
it('persists auth data to localStorage under auth-storage key', () => {
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
refreshToken: 'opaque-refresh-token',
expiresIn: 3600,
}
useAuthStore.getState().setAuth(payload)
const stored = localStorage.getItem('auth-storage')
expect(stored).not.toBeNull()
const parsed = JSON.parse(stored!)
expect(parsed.state.accessToken).toBe(payload.accessToken)
expect(parsed.state.user.username).toBe('admin')
})
})
describe('logout', () => {
it('clears user and accessToken from state', () => {
// Setup: set auth first
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token',
refreshToken: 'some-refresh',
expiresIn: 3600,
})
useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
it('removes auth-storage from localStorage on logout', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token',
refreshToken: 'some-refresh',
expiresIn: 3600,
})
useAuthStore.getState().logout()
const stored = localStorage.getItem('auth-storage')
// After logout the persisted state should have null user/token
if (stored !== null) {
const parsed = JSON.parse(stored)
expect(parsed.state.user).toBeNull()
expect(parsed.state.accessToken).toBeNull()
}
})
})
})

27
src/web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client", "vitest/globals"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Strict mode */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false
},
"include": ["src"]
}

7
src/web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

11
src/web/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})

17
src/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/**/*.test.{ts,tsx}', 'src/tests/**'],
},
},
})

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Auth;
[Collection("ApiIntegration")]
public class AuthControllerTests : IClassFixture<TestWebAppFactory>
{
private readonly HttpClient _client;
public AuthControllerTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
// Scenario: happy path — valid admin credentials return 200 with token shape + usuario
[Fact]
public async Task Login_ValidCredentials_Returns200WithTokenShape()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = "admin",
password = "@Diego550@"
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("accessToken", out var token), "Response missing 'accessToken'");
Assert.True(json.TryGetProperty("refreshToken", out var refresh), "Response missing 'refreshToken'");
Assert.True(json.TryGetProperty("expiresIn", out var expires), "Response missing 'expiresIn'");
Assert.False(string.IsNullOrWhiteSpace(token.GetString()), "'accessToken' must not be empty");
Assert.False(string.IsNullOrWhiteSpace(refresh.GetString()), "'refreshToken' must not be empty");
Assert.Equal(3600, expires.GetInt32());
// Contract: response must include usuario object
Assert.True(json.TryGetProperty("usuario", out var usuario), "Response missing 'usuario'");
Assert.True(usuario.TryGetProperty("id", out var id), "usuario missing 'id'");
Assert.True(usuario.TryGetProperty("nombre", out var nombre), "usuario missing 'nombre'");
Assert.True(usuario.TryGetProperty("rol", out var rol), "usuario missing 'rol'");
Assert.True(usuario.TryGetProperty("permisos", out var permisos), "usuario missing 'permisos'");
Assert.True(id.GetInt32() > 0, "'usuario.id' must be positive");
Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty");
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
}
// Scenario: invalid credentials return 401 with opaque error
[Fact]
public async Task Login_InvalidCredentials_Returns401()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = "admin",
password = "WrongPassword1!"
});
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("error", out var error));
Assert.Equal("Credenciales inválidas", error.GetString());
}
// Scenario: malformed body (missing password) returns 400
[Fact]
public async Task Login_MissingPassword_Returns400()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = "admin"
// password intentionally missing — JSON serializes as no field
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("errors", out var errors), "Response missing 'errors'");
}
// Triangulation: empty username returns 400
[Fact]
public async Task Login_EmptyUsername_Returns400()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = "",
password = "@Diego550@"
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>SIGCM2.Api.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\SIGCM2.Api\SIGCM2.Api.csproj" />
<ProjectReference Include="..\SIGCM2.TestSupport\SIGCM2.TestSupport.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,103 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Auth.Login;
public class LoginCommandHandlerTests
{
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly LoginCommandHandler _handler;
public LoginCommandHandlerTests()
{
_handler = new LoginCommandHandler(_repository, _hasher, _jwtService);
}
// Scenario: valid credentials → returns token response with usuario populated
[Fact]
public async Task Handle_ValidCredentials_ReturnsTokenResponse()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here");
var command = new LoginCommand("admin", "@Diego550@");
var result = await _handler.Handle(command);
Assert.Equal("jwt.token.here", result.AccessToken);
Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken));
Assert.Equal(3600, result.ExpiresIn);
// Contract: Usuario must be populated
Assert.NotNull(result.Usuario);
Assert.Equal(1, result.Usuario.Id);
Assert.Equal("Admin Sys", result.Usuario.Nombre);
Assert.Equal("admin", result.Usuario.Rol);
Assert.NotNull(result.Usuario.Permisos);
Assert.Contains("*", result.Usuario.Permisos);
}
// Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user
[Fact]
public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser()
{
var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "Cajero",
"[\"ventas:contado:create\",\"ventas:contado:read\"]", true);
_repository.GetByUsernameAsync("cajero1").Returns(usuario);
_hasher.Verify("pass123", "$2a$12$hash3").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token");
var command = new LoginCommand("cajero1", "pass123");
var result = await _handler.Handle(command);
Assert.Equal(42, result.Usuario.Id);
Assert.Equal("María González", result.Usuario.Nombre);
Assert.Equal("Cajero", result.Usuario.Rol);
Assert.Equal(2, result.Usuario.Permisos.Length);
Assert.Contains("ventas:contado:create", result.Usuario.Permisos);
Assert.Contains("ventas:contado:read", result.Usuario.Permisos);
}
// Scenario: user does not exist → throws InvalidCredentialsException
[Fact]
public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException()
{
_repository.GetByUsernameAsync("noexiste").Returns((Usuario?)null);
var command = new LoginCommand("noexiste", "anything");
await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
}
// Scenario: user is inactive → throws InvalidCredentialsException
[Fact]
public async Task Handle_InactiveUser_ThrowsInvalidCredentialsException()
{
var inactive = new Usuario(2, "operador", "$2a$12$hash2", "Juan", "Pérez", null, "vendedor", "[]", false);
_repository.GetByUsernameAsync("operador").Returns(inactive);
var command = new LoginCommand("operador", "correctpassword");
await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
}
// Scenario: wrong password → throws InvalidCredentialsException
[Fact]
public async Task Handle_WrongPassword_ThrowsInvalidCredentialsException()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("WrongPass1", "$2a$12$hash").Returns(false);
var command = new LoginCommand("admin", "WrongPass1");
await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
}
}

View File

@@ -0,0 +1,55 @@
using FluentValidation.TestHelper;
using SIGCM2.Application.Auth.Login;
namespace SIGCM2.Application.Tests.Auth.Login;
public class LoginCommandValidatorTests
{
private readonly LoginCommandValidator _validator = new();
// Happy path: valid command passes validation
[Fact]
public void Validate_ValidCommand_ShouldHaveNoErrors()
{
var command = new LoginCommand("admin", "@Diego550@");
var result = _validator.TestValidate(command);
result.ShouldNotHaveAnyValidationErrors();
}
// Scenario: empty username → validation error referencing Username
[Fact]
public void Validate_EmptyUsername_ShouldHaveErrorForUsername()
{
var command = new LoginCommand("", "@Diego550@");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(c => c.Username);
}
// Triangulation: whitespace-only username
[Fact]
public void Validate_WhitespaceUsername_ShouldHaveErrorForUsername()
{
var command = new LoginCommand(" ", "@Diego550@");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(c => c.Username);
}
// Scenario: missing password → validation error referencing Password
[Fact]
public void Validate_EmptyPassword_ShouldHaveErrorForPassword()
{
var command = new LoginCommand("admin", "");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(c => c.Password);
}
// Triangulation: null-equivalent (empty string is how records serialize missing fields)
[Fact]
public void Validate_BothEmpty_ShouldHaveErrorsForBothFields()
{
var command = new LoginCommand("", "");
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(c => c.Username);
result.ShouldHaveValidationErrorFor(c => c.Password);
}
}

View File

@@ -0,0 +1,72 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
public class UsuarioTests
{
// Happy path: constructor sets all properties correctly
[Fact]
public void Constructor_SetsAllProperties()
{
var usuario = new Usuario(
id: 1,
username: "admin",
passwordHash: "$2a$12$hash",
nombre: "Administrador",
apellido: "Sistema",
email: null,
rol: "admin",
permisosJson: "[\"*\"]",
activo: true
);
Assert.Equal(1, usuario.Id);
Assert.Equal("admin", usuario.Username);
Assert.Equal("$2a$12$hash", usuario.PasswordHash);
Assert.Equal("Administrador", usuario.Nombre);
Assert.Equal("Sistema", usuario.Apellido);
Assert.Null(usuario.Email);
Assert.Equal("admin", usuario.Rol);
Assert.Equal("[\"*\"]", usuario.PermisosJson);
Assert.True(usuario.Activo);
}
// Triangulation: inactive user
[Fact]
public void Constructor_WithActivo_False_SetsActivo_False()
{
var usuario = new Usuario(
id: 2,
username: "vendedor",
passwordHash: "$2a$12$hash2",
nombre: "Juan",
apellido: "Pérez",
email: "juan@example.com",
rol: "vendedor",
permisosJson: "[]",
activo: false
);
Assert.Equal(2, usuario.Id);
Assert.Equal("vendedor", usuario.Username);
Assert.Equal("juan@example.com", usuario.Email);
Assert.Equal("vendedor", usuario.Rol);
Assert.Equal("[]", usuario.PermisosJson);
Assert.False(usuario.Activo);
}
// Activo property reflects the actual state
[Fact]
public void Activo_IsTrue_WhenConstructedActive()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
Assert.True(usuario.Activo);
}
[Fact]
public void Activo_IsFalse_WhenConstructedInactive()
{
var usuario = new Usuario(2, "inactive", "$2a$12$hash", "Old", "User", null, "consulta", "[]", false);
Assert.False(usuario.Activo);
}
}

View File

@@ -0,0 +1,46 @@
using SIGCM2.Infrastructure.Security;
namespace SIGCM2.Application.Tests.Infrastructure;
public class BcryptPasswordHasherTests
{
private readonly BcryptPasswordHasher _hasher = new();
// The seed hash for '@Diego550@' generated at cost 12
private const string SeedHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW";
// Scenario: correct password verifies against seed hash
[Fact]
public void Verify_CorrectPassword_ReturnsTrue()
{
var result = _hasher.Verify("@Diego550@", SeedHash);
Assert.True(result);
}
// Triangulation: wrong password does not verify
[Fact]
public void Verify_WrongPassword_ReturnsFalse()
{
var result = _hasher.Verify("WrongPass1", SeedHash);
Assert.False(result);
}
// Hash + Verify round-trip: hash a new password and verify it
[Fact]
public void Hash_ThenVerify_ReturnsTrue()
{
var plain = "TestPassword123!";
var hash = _hasher.Hash(plain);
Assert.StartsWith("$2a$", hash); // BCrypt format
Assert.True(_hasher.Verify(plain, hash));
}
// Triangulation: verification of different password against generated hash fails
[Fact]
public void Hash_ThenVerifyWrong_ReturnsFalse()
{
var hash = _hasher.Hash("OriginalPassword1!");
Assert.False(_hasher.Verify("DifferentPassword1!", hash));
}
}

View File

@@ -0,0 +1,127 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Security;
namespace SIGCM2.Application.Tests.Infrastructure;
public class JwtServiceTests : IDisposable
{
private readonly RSA _rsa;
private readonly JwtOptions _options;
private readonly JwtService _jwtService;
public JwtServiceTests()
{
// Generate a test RSA key pair inline (no files needed for unit tests)
_rsa = RSA.Create(2048);
_options = new JwtOptions
{
Issuer = "sigcm2.api",
Audience = "sigcm2.web",
AccessTokenMinutes = 60
};
_jwtService = new JwtService(_rsa, _options);
}
public void Dispose() => _rsa.Dispose();
// Scenario: generated token uses RS256 algorithm
[Fact]
public void GenerateAccessToken_UsesRS256Algorithm()
{
var usuario = MakeUsuario();
var token = _jwtService.GenerateAccessToken(usuario);
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
Assert.Equal("RS256", parsed.Header.Alg);
}
// Scenario: claims contain expected values
[Fact]
public void GenerateAccessToken_ContainsExpectedClaims()
{
var usuario = MakeUsuario();
var token = _jwtService.GenerateAccessToken(usuario);
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
Assert.Equal("1", parsed.Subject); // sub = user ID
Assert.Equal("sigcm2.api", parsed.Issuer); // iss
Assert.Contains("sigcm2.web", parsed.Audiences); // aud
Assert.Contains(parsed.Claims, c => c.Type == "name" && c.Value == "admin");
Assert.Contains(parsed.Claims, c => c.Type == "rol" && c.Value == "admin");
}
// Scenario: token is verifiable with the public key
[Fact]
public void GenerateAccessToken_IsVerifiableWithPublicKey()
{
var usuario = MakeUsuario();
var token = _jwtService.GenerateAccessToken(usuario);
var publicKey = RSA.Create();
publicKey.ImportRSAPublicKey(_rsa.ExportRSAPublicKey(), out _);
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(publicKey),
ValidIssuer = "sigcm2.api",
ValidAudience = "sigcm2.web",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, validationParams, out var validatedToken);
Assert.NotNull(principal);
Assert.IsType<JwtSecurityToken>(validatedToken);
}
// Scenario: expiry is 60 minutes from now
[Fact]
public void GenerateAccessToken_ExpiryIs60MinutesFromNow()
{
var usuario = MakeUsuario();
var before = DateTime.UtcNow;
var token = _jwtService.GenerateAccessToken(usuario);
var after = DateTime.UtcNow;
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
var expectedMinExpiry = before.AddMinutes(59).AddSeconds(55);
var expectedMaxExpiry = after.AddMinutes(60).AddSeconds(5);
Assert.True(parsed.ValidTo >= expectedMinExpiry, $"exp {parsed.ValidTo} < expected min {expectedMinExpiry}");
Assert.True(parsed.ValidTo <= expectedMaxExpiry, $"exp {parsed.ValidTo} > expected max {expectedMaxExpiry}");
}
// Triangulation: different user produces token with different sub claim
[Fact]
public void GenerateAccessToken_DifferentUser_DifferentSubClaim()
{
var user1 = MakeUsuario(id: 1, username: "admin");
var user2 = MakeUsuario(id: 2, username: "vendedor");
var token1 = _jwtService.GenerateAccessToken(user1);
var token2 = _jwtService.GenerateAccessToken(user2);
var handler = new JwtSecurityTokenHandler();
var parsed1 = handler.ReadJwtToken(token1);
var parsed2 = handler.ReadJwtToken(token2);
Assert.NotEqual(parsed1.Subject, parsed2.Subject);
Assert.Equal("1", parsed1.Subject);
Assert.Equal("2", parsed2.Subject);
}
private static Usuario MakeUsuario(int id = 1, string username = "admin")
=> new(id, username, "$2a$12$hash", "Administrador", "Sistema", null, "admin", "[\"*\"]", true);
}

View File

@@ -0,0 +1,102 @@
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")]
public class UsuarioRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private UsuarioRepository _repository = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer
});
// Reset DB and seed admin user for each test class run
await _respawner.ResetAsync(_connection);
await SeedAdminAsync();
var factory = new SqlConnectionFactory(ConnectionString);
_repository = new UsuarioRepository(factory);
}
public async Task DisposeAsync()
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
// Scenario: GetByUsername returns correct entity when user exists
[Fact]
public async Task GetByUsernameAsync_ExistingUser_ReturnsUsuario()
{
var usuario = await _repository.GetByUsernameAsync("admin");
Assert.NotNull(usuario);
Assert.Equal("admin", usuario.Username);
Assert.Equal("admin", usuario.Rol);
Assert.True(usuario.Activo);
Assert.False(string.IsNullOrWhiteSpace(usuario.PasswordHash));
}
// Triangulation: GetByUsername returns null when user does not exist
[Fact]
public async Task GetByUsernameAsync_NonExistentUser_ReturnsNull()
{
var usuario = await _repository.GetByUsernameAsync("noexiste");
Assert.Null(usuario);
}
// Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default)
[Fact]
public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser()
{
// Insert a second user
await _connection.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " +
"VALUES ('vendedor1', '$2a$12$hash2', 'Juan', 'Pérez', 'vendedor', '[]')");
var admin = await _repository.GetByUsernameAsync("admin");
var vendedor = await _repository.GetByUsernameAsync("vendedor1");
Assert.NotNull(admin);
Assert.NotNull(vendedor);
Assert.NotEqual(admin.Id, vendedor.Id);
Assert.Equal("admin", admin.Rol);
Assert.Equal("vendedor", vendedor.Rol);
}
private async Task SeedAdminAsync()
{
await _connection.ExecuteAsync(
"SET QUOTED_IDENTIFIER ON; " +
"IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') " +
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " +
"VALUES ('admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', " +
"'Administrador', 'Sistema', 'admin', '[\"*\"]', 1)");
}
}
// Dapper extension helper for IDbConnection
file static class DapperHelper
{
public static async Task ExecuteAsync(this SqlConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync();
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>SIGCM2.Application.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>SIGCM2.TestSupport</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Respawn" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="xunit" />
<PackageReference Include="Dapper" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\api\SIGCM2.Api\SIGCM2.Api.csproj" />
<ProjectReference Include="..\..\src\api\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,66 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using Xunit;
namespace SIGCM2.TestSupport;
/// <summary>
/// Manages a real SQL Server test database.
/// Resets state between test runs using Respawn.
/// Seeds the admin user after each reset.
/// </summary>
public sealed class SqlTestFixture : IAsyncLifetime
{
private readonly string _connectionString;
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
public SqlTestFixture(string connectionString)
{
_connectionString = connectionString;
}
public async Task InitializeAsync()
{
_connection = new SqlConnection(_connectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer
});
await ResetAndSeedAsync();
}
public async Task ResetAndSeedAsync()
{
await _respawner.ResetAsync(_connection);
await SeedAdminAsync();
}
public async Task DisposeAsync()
{
if (_connection is not null)
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
}
private async Task SeedAdminAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
VALUES (
'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
'Administrador', 'Sistema', 'admin', '["*"]', 1
);
""";
await _connection.ExecuteAsync(sql);
}
}

View File

@@ -0,0 +1,94 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Infrastructure.Security;
using Xunit;
namespace SIGCM2.TestSupport;
/// <summary>
/// WebApplicationFactory for integration tests against SIGCM2.Api.
/// Uses SIGCM2_Test database (separate from production SIGCM2).
/// </summary>
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
// Resolved once — absolute paths independent of working directory
private static readonly string RepoRoot = ResolveRepoRoot();
private static readonly string PrivateKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "private.pem");
private static readonly string PublicKeyPath = Path.Combine(RepoRoot, "src", "api", "SIGCM2.Api", "keys", "public.pem");
private readonly SqlTestFixture _dbFixture = new(TestConnectionString);
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Step 1: Override configuration BEFORE services are built
builder.ConfigureAppConfiguration((ctx, config) =>
{
// Clear all existing sources and rebuild with test values
// This ensures our paths win over appsettings.json
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:SqlServer"] = TestConnectionString,
["Jwt:Issuer"] = "sigcm2.api",
["Jwt:Audience"] = "sigcm2.web",
["Jwt:AccessTokenMinutes"] = "60",
["Jwt:PrivateKeyPath"] = PrivateKeyPath,
["Jwt:PublicKeyPath"] = PublicKeyPath,
["Jwt:PrivateKey"] = null,
["Jwt:PublicKey"] = null,
["Cors:AllowedOrigins:0"] = "http://localhost:5173",
["Serilog:MinimumLevel:Default"] = "Warning",
});
});
builder.UseEnvironment("Testing");
}
public async Task InitializeAsync()
{
await _dbFixture.InitializeAsync();
}
public new async Task DisposeAsync()
{
await _dbFixture.DisposeAsync();
await base.DisposeAsync();
}
private static string ResolveRepoRoot()
{
// Walk up from AppContext.BaseDirectory looking for SIGCM2.slnx
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (dir.GetFiles("SIGCM2.slnx").Length > 0)
return dir.FullName;
dir = dir.Parent;
}
// Walk up from assembly location
var assemblyLocation = typeof(TestWebAppFactory).Assembly.Location;
dir = new DirectoryInfo(Path.GetDirectoryName(assemblyLocation)!);
while (dir is not null)
{
if (dir.GetFiles("SIGCM2.slnx").Length > 0)
return dir.FullName;
dir = dir.Parent;
}
// Known absolute path (last resort for this machine)
const string knownPath = @"E:\SIG-CM2.0";
if (Directory.Exists(knownPath) && File.Exists(Path.Combine(knownPath, "SIGCM2.slnx")))
return knownPath;
throw new InvalidOperationException(
$"Could not find repo root containing SIGCM2.slnx. " +
$"AppContext.BaseDirectory: {AppContext.BaseDirectory}");
}
}