From 6aaeaa724ffba98b9ac5796aa397f59807faf64b Mon Sep 17 00:00:00 2001 From: edelmezamx Date: Sun, 5 Jul 2026 20:57:18 -0600 Subject: [PATCH 1/5] feat: add Empleado CRUD module with tests and security validation --- .opencode/plans/pipeline-mejoras.md | 101 +++ .opencode/skills/crud-module/SKILL.md | 840 ++++++++++++++++++ .opencode/skills/crud-pattern/SKILL.md | 527 +++++++++++ AGENTS.md | 69 ++ .../Controllers/EmpleadoController.cs | 308 +++++++ WebDevSecOps/Models/EmpCatTipoEmpleado.cs | 15 + WebDevSecOps/Models/Empleado.cs | 27 + .../Models/EmpleadoCreateViewModel.cs | 33 + .../Models/EmpleadoDeleteViewModel.cs | 31 + .../Models/EmpleadoDetailsViewModel.cs | 34 + .../Models/EmpleadoUpdateViewModel.cs | 42 + WebDevSecOps/Models/OperationResult.cs | 22 + WebDevSecOps/Pages/Shared/_Layout.cshtml | 3 + WebDevSecOps/Program.cs | 16 + WebDevSecOps/Services/EmpleadoService.cs | 331 +++++++ WebDevSecOps/Services/IEmpleadoService.cs | 18 + WebDevSecOps/Services/ITipoEmpleadoService.cs | 10 + WebDevSecOps/Services/TipoEmpleadoService.cs | 109 +++ WebDevSecOps/Views/Empleado/Create.cshtml | 121 +++ WebDevSecOps/Views/Empleado/Delete.cshtml | 79 ++ WebDevSecOps/Views/Empleado/Details.cshtml | 52 ++ WebDevSecOps/Views/Empleado/Index.cshtml | 129 +++ WebDevSecOps/Views/Empleado/Update.cshtml | 124 +++ .../Services/EmpleadoApiClientTests.cs | 305 +++++++ .../Services/TipoEmpleadoApiClientTests.cs | 142 +++ .../SAST/EmpleadoSecurityTests.cs | 161 ++++ .../Common/ContractTestData.cs | 93 ++ .../Pages/Empleados/CreateTests.cs | 113 +++ .../Pages/Empleados/DeleteTests.cs | 112 +++ .../Pages/Empleados/IndexTests.cs | 215 +++++ .../Pages/Empleados/UpdateTests.cs | 101 +++ 31 files changed, 4283 insertions(+) create mode 100644 .opencode/plans/pipeline-mejoras.md create mode 100644 .opencode/skills/crud-module/SKILL.md create mode 100644 .opencode/skills/crud-pattern/SKILL.md create mode 100644 AGENTS.md create mode 100644 WebDevSecOps/Controllers/EmpleadoController.cs create mode 100644 WebDevSecOps/Models/EmpCatTipoEmpleado.cs create mode 100644 WebDevSecOps/Models/Empleado.cs create mode 100644 WebDevSecOps/Models/EmpleadoCreateViewModel.cs create mode 100644 WebDevSecOps/Models/EmpleadoDeleteViewModel.cs create mode 100644 WebDevSecOps/Models/EmpleadoDetailsViewModel.cs create mode 100644 WebDevSecOps/Models/EmpleadoUpdateViewModel.cs create mode 100644 WebDevSecOps/Models/OperationResult.cs create mode 100644 WebDevSecOps/Services/EmpleadoService.cs create mode 100644 WebDevSecOps/Services/IEmpleadoService.cs create mode 100644 WebDevSecOps/Services/ITipoEmpleadoService.cs create mode 100644 WebDevSecOps/Services/TipoEmpleadoService.cs create mode 100644 WebDevSecOps/Views/Empleado/Create.cshtml create mode 100644 WebDevSecOps/Views/Empleado/Delete.cshtml create mode 100644 WebDevSecOps/Views/Empleado/Details.cshtml create mode 100644 WebDevSecOps/Views/Empleado/Index.cshtml create mode 100644 WebDevSecOps/Views/Empleado/Update.cshtml create mode 100644 tests/WebDevSecOps.IntegrationTests/Services/EmpleadoApiClientTests.cs create mode 100644 tests/WebDevSecOps.IntegrationTests/Services/TipoEmpleadoApiClientTests.cs create mode 100644 tests/WebDevSecOps.SecurityTests/SAST/EmpleadoSecurityTests.cs create mode 100644 tests/WebDevSecOps.UnitTests/Pages/Empleados/CreateTests.cs create mode 100644 tests/WebDevSecOps.UnitTests/Pages/Empleados/DeleteTests.cs create mode 100644 tests/WebDevSecOps.UnitTests/Pages/Empleados/IndexTests.cs create mode 100644 tests/WebDevSecOps.UnitTests/Pages/Empleados/UpdateTests.cs diff --git a/.opencode/plans/pipeline-mejoras.md b/.opencode/plans/pipeline-mejoras.md new file mode 100644 index 0000000..66a8fc7 --- /dev/null +++ b/.opencode/plans/pipeline-mejoras.md @@ -0,0 +1,101 @@ +# Plan de Mejoras — Pipeline CI + +## Cambios a realizar en `.github/workflows/ci.yml` + +### 1. Actualizar `actions/setup-dotnet` (4 ocurrencias) + +Encontrar `uses: actions/setup-dotnet@v4` y reemplazar por `uses: actions/setup-dotnet@v5` en: +- Línea 36 (job `build`) +- Línea 114 (job `codeql`) +- Línea 165 (job `sast`) +- Línea 200 (job `sca`) + +### 2. Actualizar `actions/dependency-review-action` + +Línea 148: `uses: actions/dependency-review-action@v4` → `uses: actions/dependency-review-action@v5` + +### 3. Actualizar `zaproxy/action-baseline` + +Línea 305: `uses: zaproxy/action-baseline@v0.14.0` → `uses: zaproxy/action-baseline@v0.15.0` + +### 4. Agregar NuGet caching en 4 jobs + +Insertar este bloque después del step `Setup .NET 10.0` en los jobs `build`, `codeql`, `sast`, `sca`: + +```yaml + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} +``` + +#### Ubicación exacta para cada job: + +**Job `build`** — después de `Setup .NET 10.0`, antes de `Install SonarScanner`: +``` + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Cache NuGet packages <-- INSERTAR AQUÍ + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + + - name: Install SonarScanner +``` + +**Job `codeql`** — después de `Setup .NET 10.0`, antes de `Initialize CodeQL`: +``` + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Cache NuGet packages <-- INSERTAR AQUÍ + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + + - name: Initialize CodeQL +``` + +**Job `sast`** — después de `Setup .NET 10.0`, antes de `Restore`: +``` + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Cache NuGet packages <-- INSERTAR AQUÍ + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + + - name: Restore +``` + +**Job `sca`** — después de `Setup .NET 10.0`, antes de `Restore`: +``` + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Cache NuGet packages <-- INSERTAR AQUÍ + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.props') }} + + - name: Restore +``` + +## Verificación + +Después de aplicar los cambios, hacer push y revisar que el pipeline pase correctamente. diff --git a/.opencode/skills/crud-module/SKILL.md b/.opencode/skills/crud-module/SKILL.md new file mode 100644 index 0000000..8fa815a --- /dev/null +++ b/.opencode/skills/crud-module/SKILL.md @@ -0,0 +1,840 @@ +--- +name: "crud-module" +description: "Patterns, conventions and file-by-file templates for creating new CRUD modules (like Empleado) following the established Usuario architecture. Use when building a new CRUD resource: controller, service, viewmodels, views, DI registration, and menu integration." +license: "MIT" +--- + +# CRUD Module — Construction Guide + +This skill documents the exact file structure, patterns, and conventions used to build the **Usuario** CRUD module. Use it as a blueprint when creating a new CRUD resource (e.g., `Empleado`). + +## Project Structure for a CRUD Module + +Every CRUD module requires **14 new files** + **2 modifications** to existing files: + +``` +WebDevSecOps/ +├── Controllers/ +│ └── {Resource}Controller.cs ← NEW (1) +├── Models/ +│ ├── {Resource}.cs ← NEW (2) Entity model +│ ├── {Resource}CreateViewModel.cs ← NEW (3) +│ ├── {Resource}UpdateViewModel.cs ← NEW (4) +│ ├── {Resource}DetailsViewModel.cs ← NEW (5) +│ └── {Resource}DeleteViewModel.cs ← NEW (6) +├── Services/ +│ ├── I{Resource}Service.cs ← NEW (7) Interface +│ └── {Resource}Service.cs ← NEW (8) Implementation +├── Views/ +│ └── {Resource}/ +│ ├── Index.cshtml ← NEW (9) +│ ├── Create.cshtml ← NEW (10) +│ ├── Details.cshtml ← NEW (11) +│ ├── Update.cshtml ← NEW (12) +│ └── Delete.cshtml ← NEW (13) +├── Pages/Shared/ +│ └── _Layout.cshtml ← MODIFY (14) Add menu item +└── Program.cs ← MODIFY (15) Register service +``` + +**Reusable shared files** (already exist, do NOT recreate): +- `Models/PaginatedResponse` — Generic pagination DTO +- `Models/OperationResult.cs` — Result pattern for mutations (see below) +- `Models/ApiErrorResponse.cs` — RFC 7807 error deserialization +- `Services/SafeResponseLogger.cs` — Sanitized logging helper +- `Views/_ViewImports.cshtml` — Namespace imports + tag helpers +- `Views/_ViewStart.cshtml` — Layout reference + +## Naming Conventions + +| Element | Convention | Example | +|---------|-----------|---------| +| Controller | `{Resource}Controller` | `EmpleadoController` | +| Service interface | `I{Resource}Service` | `IEmpleadoService` | +| Service impl | `{Resource}Service` | `EmpleadoService` | +| Entity model | `{Resource}` | `Empleado` | +| ViewModel | `{Resource}{Action}ViewModel` | `EmpleadoCreateViewModel` | +| View folder | `Views/{Resource}/` | `Views/Empleado/` | +| Route segment | `/{Resource}/{action}` | `/Empleado/Index` | +| API endpoint | `api/v1/{Resource}` | `api/v1/Empleado` | +| String fields | Prefix `Str` | `StrNombre`, `StrTelefono` | +| Date fields | Prefix `Dte` | `DteFechaRegistro` | +| Cancellation token | `ct` | `CancellationToken ct` | + +## Controller Pattern (`{Resource}Controller.cs`) + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WebDevSecOps.Models; +using WebDevSecOps.Services; + +namespace WebDevSecOps.Controllers; + +[Authorize] +public class EmpleadoController : Controller +{ + private readonly IEmpleadoService _service; + private readonly ILogger _logger; + + public EmpleadoController(IEmpleadoService service, ILogger logger) + { + _service = service; + _logger = logger; + } +``` + +### Actions Checklist + +| # | Action | Verb | Parameters | Returns | Caching | +|---|--------|------|-----------|---------|---------| +| 1 | `Index` | GET | `int pageNumber = 1, int pageSize = 10` | `View(PaginatedResponse<{Resource}>)` | `[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client, NoStore = false)]` | +| 2 | `Details` | GET | `int id, CancellationToken ct, string? returnUrl = null` | `View({Resource}DetailsViewModel)` | `[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Client, NoStore = false)]` | +| 3 | `Create` | GET | `string? returnUrl = null` | `View()` | None | +| 4 | `Create` | POST | `{Resource}CreateViewModel model, CancellationToken ct, string? returnUrl = null` | `RedirectToAction("Index")` or `View(model)` | None | +| 5 | `Update` | GET | `int id, CancellationToken ct, string? returnUrl = null` | `View({Resource}UpdateViewModel)` | None | +| 6 | `Update` | POST | `{Resource}UpdateViewModel model, CancellationToken ct, string? returnUrl = null` | `RedirectToAction("Index")` or `View(model)` | None | +| 7 | `Delete` | GET | `int id, CancellationToken ct, string? returnUrl = null` | `View({Resource}DeleteViewModel)` | None | +| 8 | `Delete` | POST | `{Resource}DeleteViewModel model, CancellationToken ct, string? returnUrl = null` | `RedirectToAction("Index")` or `View(model)` | None | + +### Required Attributes on POST actions +```csharp +[HttpPost] +[ValidateAntiForgeryToken] +``` + +### Error Handling Pattern (same for Create, Update, Delete POST) + +```csharp +// After calling service method: +if (result.Success) +{ + _logger.LogInformation("{Resource} {Id} created/updated/deleted successfully", id); + TempData["Success"] = "{Resource} procesado exitosamente."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return RedirectToAction("Index"); +} + +// Field errors +if (result.FieldErrors is not null) +{ + foreach (var kvp in result.FieldErrors) + { + foreach (var msg in kvp.Value) + { + var key = kvp.Key switch + { + "strNombre" => nameof({Resource}UpdateViewModel.StrNombre), + // ... map other fields + _ => kvp.Key + }; + ModelState.AddModelError(key, msg); + } + } +} + +// General error +if (result.ErrorMessage is not null) + ModelState.AddModelError(string.Empty, result.ErrorMessage); + +ViewData["ReturnUrl"] = returnUrl; +return View(model); +``` + +### ReturnUrl Handling + +- Always accept `string? returnUrl = null` on every action. +- Store in `ViewData["ReturnUrl"]` on GET. +- Pass as hidden field `` in forms. +- On success, validate with `Url.IsLocalUrl(returnUrl)` before redirecting. +- Pass `asp-route-returnUrl="@(Context.Request.Path + Context.Request.QueryString)"` in action links. + +## Entity Model (`Models/{Resource}.cs`) + +```csharp +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class Empleado +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("strNombre")] + public string StrNombre { get; set; } = string.Empty; + + [JsonPropertyName("strCorreoElectronico")] + public string StrCorreoElectronico { get; set; } = string.Empty; + + [JsonPropertyName("dteFechaRegistro")] + public DateTime DteFechaRegistro { get; set; } + + [JsonPropertyName("rowVersion")] + public byte[]? RowVersion { get; set; } +} +``` + +**Rules:** +- `string` defaults to `string.Empty` (never null). +- `RowVersion` is `byte[]?` on the entity, `byte[]` (non-nullable) on ViewModels. +- Always use `[JsonPropertyName("snake_case")]` matching the API contract. + +## ViewModel Patterns + +### CreateViewModel + +```csharp +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class EmpleadoCreateViewModel +{ + [Required(ErrorMessage = "El nombre es obligatorio.")] + [StringLength(50, ErrorMessage = "El nombre no puede exceder los 50 caracteres.")] + [RegularExpression(@"^[a-zA-Z0-9_ ]+$", ErrorMessage = "El nombre solo permite letras, numeros y espacios.")] + [JsonPropertyName("strNombre")] + [Display(Name = "Nombre")] + public string StrNombre { get; set; } = string.Empty; + + [Required(ErrorMessage = "El correo electronico es obligatorio.")] + [StringLength(50, ErrorMessage = "El correo no puede exceder los 50 caracteres.")] + [EmailAddress(ErrorMessage = "El formato del correo electronico no es valido.")] + [JsonPropertyName("strCorreoElectronico")] + [Display(Name = "Correo Electronico")] + public string StrCorreoElectronico { get; set; } = string.Empty; +} +``` + +### UpdateViewModel — add hidden Id + RowVersion, nullable password + +```csharp +[Required][JsonRequired][JsonPropertyName("id")] +public int Id { get; set; } + +// ... same fields as Create but with RowVersion added: + +[Required][JsonPropertyName("rowVersion")] +public byte[] RowVersion { get; set; } = []; + +// Optional password (nullable, not [Required]): +[MinLength(8, ErrorMessage = "...")] +[DataType(DataType.Password)] +[JsonPropertyName("strPWD")] +[Display(Name = "Contrasena")] +public string? StrPWD { get; set; } +``` + +### DetailsViewModel — include DteFechaRegistro, read-only + +```csharp +[Display(Name = "Nombre Completo")] +[JsonPropertyName("strNombre")] +public string StrNombre { get; set; } = string.Empty; + +[Display(Name = "Fecha de Registro")] +[JsonPropertyName("dteFechaRegistro")] +public DateTime DteFechaRegistro { get; set; } + +[Required][JsonPropertyName("rowVersion")] +public byte[] RowVersion { get; set; } = []; +``` + +### DeleteViewModel — display-only fields + concurrency + +```csharp +[Required][JsonRequired][JsonPropertyName("id")] +public int Id { get; set; } + +[Display(Name = "Nombre")] +public string StrNombre { get; set; } = string.Empty; + +[Required][JsonPropertyName("rowVersion")] +public byte[] RowVersion { get; set; } = []; +``` + +## OperationResult (`Models/OperationResult.cs`) + +Reuse this pattern instead of creating a per-resource result class: + +```csharp +namespace WebDevSecOps.Models; + +public class OperationResult +{ + public bool Success { get; } + public string? ErrorMessage { get; } + public Dictionary? FieldErrors { get; } + + private OperationResult(bool success, string? errorMessage, Dictionary? fieldErrors) + { + Success = success; + ErrorMessage = errorMessage; + FieldErrors = fieldErrors; + } + + public static OperationResult Ok() => new(true, null, null); + public static OperationResult Fail(string message) => new(false, message, null); + public static OperationResult Fail(Dictionary fieldErrors, string? message = null) + => new(false, message, fieldErrors); +} +``` + +If `CreateUsuarioResult` exists in the project, it can be renamed to `OperationResult` for reuse or kept as-is; either way, use this pattern in the new service. + +## Service Layer + +### Interface + +```csharp +using WebDevSecOps.Models; + +namespace WebDevSecOps.Services; + +public interface IEmpleadoService +{ + Task?> GetEmpleadosAsync(int pageNumber = 1, int pageSize = 10, CancellationToken ct = default); + Task CreateEmpleadoAsync(EmpleadoCreateViewModel model, CancellationToken ct = default); + Task GetEmpleadoByIdAsync(int id, CancellationToken ct = default); + Task UpdateEmpleadoAsync(int id, EmpleadoUpdateViewModel model, CancellationToken ct = default); + Task DeleteEmpleadoAsync(int id, byte[] rowVersion, CancellationToken ct = default); +} +``` + +### Implementation Template + +```csharp +using System.Net.Http.Json; +using System.Text.Json; +using WebDevSecOps.Models; + +namespace WebDevSecOps.Services; + +public class EmpleadoService : IEmpleadoService +{ + private readonly HttpClient _httpClient; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITokenStore _tokenStore; + private readonly ILogger _logger; + + public EmpleadoService( + HttpClient httpClient, + IHttpContextAccessor httpContextAccessor, + ITokenStore tokenStore, + ILogger logger) + { + _httpClient = httpClient; + _httpContextAccessor = httpContextAccessor; + _tokenStore = tokenStore; + _logger = logger; + } + + private string? GetToken() + { + var user = _httpContextAccessor.HttpContext?.User; + return user is not null ? _tokenStore.GetTokenFromPrincipal(user) : null; + } +``` + +#### Method — List (GET) + +```csharp +public async Task?> GetEmpleadosAsync( + int pageNumber = 1, int pageSize = 10, CancellationToken ct = default) +{ + var token = GetToken(); + if (token is null) { _logger.LogWarning("No access token found"); return null; } + + try + { + using var request = new HttpRequestMessage( + HttpMethod.Get, $"api/v1/Empleado?PageNumber={pageNumber}&PageSize={pageSize}"); + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(cancellationToken: ct); + } + catch (OperationCanceledException ex) + { + _logger.LogWarning(ex, "Request was cancelled"); + throw new OperationCanceledException("The request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error"); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error"); + return null; + } +} +``` + +#### Method — GetById (GET) + +```csharp +public async Task GetEmpleadoByIdAsync(int id, CancellationToken ct = default) +{ + var token = GetToken(); + if (token is null) { _logger.LogWarning("No access token found"); return null; } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/Empleado/{id}"); + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.IsSuccessStatusCode) + return await response.Content.ReadFromJsonAsync(cancellationToken: ct); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogWarning("Empleado with id {Id} not found", id); + return null; + } + + _logger.LogWarning("Get by id failed with status {StatusCode}", response.StatusCode); + return null; + } + catch (OperationCanceledException ex) { /* rethrow */ } + catch (HttpRequestException ex) { /* return null */ } + catch (Exception ex) { /* return null */ } +} +``` + +#### Method — Create (POST) + +```csharp +public async Task CreateEmpleadoAsync( + EmpleadoCreateViewModel model, CancellationToken ct = default) +{ + var token = GetToken(); + if (token is null) return OperationResult.Fail("Error de autenticacion. Inicie sesion nuevamente."); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "api/v1/Empleado") + { + Content = JsonContent.Create(model) + }; + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Empleado created successfully"); + return OperationResult.Ok(); + } + + await SafeResponseLogger.LogResponseFailure(_logger, response, "CreateEmpleado", ct: ct); + + var rawBody = await response.Content.ReadAsStringAsync(ct); + var errorResponse = JsonSerializer.Deserialize(rawBody); + + if (errorResponse?.Errors is not null && errorResponse.Errors.Count > 0) + return OperationResult.Fail(errorResponse.Errors, errorResponse.Title); + + var message = errorResponse?.Detail ?? errorResponse?.Title ?? "Error al crear."; + return OperationResult.Fail(message); + } + catch (OperationCanceledException ex) { /* rethrow */ } + catch (HttpRequestException ex) { /* return Fail("Error de conexion...") */ } + catch (Exception ex) { /* return Fail("Error inesperado...") */ } +} +``` + +#### Method — Update (PUT) + +```csharp +public async Task UpdateEmpleadoAsync( + int id, EmpleadoUpdateViewModel model, CancellationToken ct = default) +{ + // Same pattern as Create but: + // - HttpMethod.Put + // - URL: $"api/v1/Empleado/{id}" + // - Handle 409 Conflict: return OperationResult.Fail("El registro fue modificado...") +} +``` + +#### Method — Delete (DELETE) + +```csharp +public async Task DeleteEmpleadoAsync( + int id, byte[] rowVersion, CancellationToken ct = default) +{ + // Same pattern but: + // - HttpMethod.Delete + // - URL: $"api/v1/Empleado/{id}" + // - Content: JsonContent.Create(new { id, rowVersion }) + // - Handle 409 Conflict +} +``` + +### Exception Handling Taxonomy + +| Exception | Log Level | Behavior | +|-----------|-----------|----------| +| `OperationCanceledException` | Warning | Re-throw as `OperationCanceledException` | +| `HttpRequestException` | Error | Return null (GET) or `OperationResult.Fail("Error de conexion...")` | +| `Exception` (generic) | Error | Return null (GET) or `OperationResult.Fail("Error inesperado...")` | + +## Views + +### Index.cshtml — Paginated Table + +```razor +@model PaginatedResponse +@{ + ViewData["Title"] = "Empleados"; +} + +
+
+

Empleados

+ + + Agregar Empleado + +
+ + @if (Model?.Items is null || Model.Items.Count == 0) + { +
No se encontraron empleados.
+ } + else + { +
+ + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + } + +
NombreCorreo ElectronicoAcciones
@item.StrNombre@item.StrCorreoElectronico + Detalles + Actualizar + Eliminar +
+
+ + + + +

+ Mostrando pagina @Model.PageNumber de @Model.TotalPages (@Model.TotalCount registros) +

+ } +
+``` + +### Create.cshtml — Form with Validation + +```razor +@model EmpleadoCreateViewModel +@{ + ViewData["Title"] = "Agregar Empleado"; +} + +
+
+
+

Agregar Empleado

+ + @{ + var generalErrors = ViewData.ModelState[string.Empty]?.Errors; + } + @if (generalErrors is { Count: > 0 }) + { + + } + +
+ @Html.AntiForgeryToken() + + + @foreach (var prop in new[] { "StrNombre", "StrCorreoElectronico" }) + { +
+ + + +
+ } + +
+ + Cancelar +
+
+
+
+
+ +@section Scripts { + +} +``` + +### Client-Side JS Validation Pattern + +Always include in `@section Scripts` after `_ValidationScriptsPartial`: + +```javascript + +``` + +### Details.cshtml — Card Display + +```razor +@model EmpleadoDetailsViewModel +@{ + ViewData["Title"] = "Detalles del Empleado"; +} + +
+
+
+

Detalles del Empleado

+ + @* General errors block (same as Create) *@ + +
+
+
+
@Html.DisplayNameFor(m => m.StrNombre)
+
@Model.StrNombre
+ +
@Html.DisplayNameFor(m => m.StrCorreoElectronico)
+
@Model.StrCorreoElectronico
+ +
@Html.DisplayNameFor(m => m.DteFechaRegistro)
+
@Model.DteFechaRegistro.ToString("dd/MM/yyyy HH:mm")
+
+
+
+ + +
+
+
+``` + +### Update.cshtml — Form with Hidden Fields + +Same as Create but with: +- `` +- `` +- Password field label: `"Contrasena (dejar en blanco para mantener)"` +- Submit text: "Guardar" + +### Delete.cshtml — Confirmation + +```razor +@model EmpleadoDeleteViewModel +@{ + ViewData["Title"] = "Eliminar Empleado"; +} + +
+
+
+

Eliminar Empleado

+ + + + @* General errors block *@ + +
+ @Html.AntiForgeryToken() + + + + +
+
@Html.DisplayNameFor(m => m.StrNombre)
+
@Model.StrNombre
+ +
@Html.DisplayNameFor(m => m.StrCorreoElectronico)
+
@Model.StrCorreoElectronico
+
+ +
+ + Cancelar +
+
+
+
+
+``` + +## Menu Integration (`_Layout.cshtml`) + +Add inside the `@if (User.Identity?.IsAuthenticated == true)` block, **after** the Usuarios `
  • `: + +```html +
  • +``` + +## DI Registration (`Program.cs`) + +Add after the existing `IUsuarioService` registration: + +```csharp +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["ApiSettings:BaseUrl"] + ?? throw new InvalidOperationException("ApiSettings:BaseUrl is not configured.")); + client.Timeout = TimeSpan.FromSeconds(30); +}) +.AddStandardResilienceHandler(); +``` + +## Quick-Start Checklist + +When creating a new CRUD module, follow these steps in order: + +- [ ] Create `Models/{Resource}.cs` — entity with `[JsonPropertyName]` +- [ ] Create `Models/{Resource}CreateViewModel.cs` — validation attributes +- [ ] Create `Models/{Resource}UpdateViewModel.cs` — add Id, RowVersion, nullable password +- [ ] Create `Models/{Resource}DetailsViewModel.cs` — include DteFechaRegistro +- [ ] Create `Models/{Resource}DeleteViewModel.cs` — display fields + RowVersion +- [ ] Create `Services/I{Resource}Service.cs` — 5 methods +- [ ] Create `Services/{Resource}Service.cs` — HttpClient implementation +- [ ] Create `Controllers/{Resource}Controller.cs` — 8 actions +- [ ] Create `Views/{Resource}/Index.cshtml` — table + pagination +- [ ] Create `Views/{Resource}/Create.cshtml` — form + JS validation +- [ ] Create `Views/{Resource}/Details.cshtml` — card display +- [ ] Create `Views/{Resource}/Update.cshtml` — form + hidden fields +- [ ] Create `Views/{Resource}/Delete.cshtml` — confirmation +- [ ] Add `
  • ` to `_Layout.cshtml` menu (after Usuarios) +- [ ] Register `I{Resource}Service` in `Program.cs` diff --git a/.opencode/skills/crud-pattern/SKILL.md b/.opencode/skills/crud-pattern/SKILL.md new file mode 100644 index 0000000..ff2ea63 --- /dev/null +++ b/.opencode/skills/crud-pattern/SKILL.md @@ -0,0 +1,527 @@ +# Skill: crud-pattern + +# CRUD Module Implementation Pattern + +Guía paso a paso para implementar un nuevo módulo CRUD en este proyecto, siguiendo el patrón establecido por `Usuario` y `Empleado`. + +## Orden de creación + +``` + 1. Models/ → Modelo de dominio + ViewModels por acción + OperationResult (si no existe) + 2. Services/ → Interfaz + Implementación (HttpClient) + 3. Program.cs → Registro DI con AddHttpClient + AddStandardResilienceHandler + 4. Controllers/ → Controlador con 8 acciones + 5. Views/ → Vistas Razor (Index, Create, Details, Update, Delete) + 6. _Layout.cshtml → Ítem de menú + 7. Unit tests → IndexTests, CreateTests, UpdateTests, DeleteTests + 8. Integration tests → ApiClientTests + 9. Security tests → Auth + validación + XSS +``` + +--- + +## 1. Modelos (`Models/`) + +### Modelo de dominio +Clase plana que refleja la respuesta de la API. + +```csharp +namespace WebDevSecOps.Models; + +public class MiEntidad +{ + public int Id { get; set; } + public string StrNombre { get; set; } = string.Empty; + public byte[] RowVersion { get; set; } = []; +} +``` + +### ViewModels (uno por acción) +- `{Entidad}CreateViewModel.cs` — solo campos editables en creación +- `{Entidad}UpdateViewModel.cs` — mismos campos + `Id` oculto +- `{Entidad}DetailsViewModel.cs` — campos de solo lectura + `StrValorCatalogo` para catálogos +- `{Entidad}DeleteViewModel.cs` — confirmación + `RowVersion` (byte[]) para concurrencia + +```csharp +public class MiEntidadCreateViewModel +{ + [Required(ErrorMessage = "El campo {0} es obligatorio.")] + [StringLength(100)] + [Display(Name = "Nombre")] + public string StrNombre { get; set; } = string.Empty; +} +``` + +```csharp +public class MiEntidadDeleteViewModel +{ + [HiddenInput] + public int Id { get; set; } + + [HiddenInput] + public byte[] RowVersion { get; set; } = []; +} +``` + +### OperationResult +```csharp +namespace WebDevSecOps.Models; + +public class OperationResult +{ + public bool Success { get; protected set; } + public string Message { get; protected set; } = string.Empty; + + public static OperationResult Ok(string message = "Operación exitosa!!") => new() { Success = true, Message = message }; + public static OperationResult Fail(string message) => new() { Success = false, Message = message }; +} + +public class OperationResult : OperationResult +{ + public T? Data { get; private set; } + + public static OperationResult Ok(T data, string message = "Operación exitosa!!") => new() { Success = true, Message = message, Data = data }; + public static new OperationResult Fail(string message) => new() { Success = false, Message = message }; +} +``` + +--- + +## 2. Servicios (`Services/`) + +### Interfaz +```csharp +public interface IMiEntidadService +{ + Task?> GetMiEntidadesAsync(int pageNumber, int pageSize, CancellationToken ct); + Task GetMiEntidadByIdAsync(int id, CancellationToken ct); + Task CreateMiEntidadAsync(MiEntidadCreateViewModel model, CancellationToken ct); + Task UpdateMiEntidadAsync(int id, MiEntidadUpdateViewModel model, CancellationToken ct); + Task DeleteMiEntidadAsync(int id, byte[] rowVersion, CancellationToken ct); + Task?> SearchMiEntidadesAsync(string? texto, int? idCatalogo, int pageNumber, int pageSize, CancellationToken ct); +} +``` + +### Implementación +```csharp +public class MiEntidadService : IMiEntidadService +{ + private readonly HttpClient _httpClient; + private readonly IHttpContextAccessor _httpContextAccessor; + + public MiEntidadService(HttpClient httpClient, IHttpContextAccessor httpContextAccessor) + { + _httpClient = httpClient; + _httpContextAccessor = httpContextAccessor; + } + + private void AttachBearerToken(HttpRequestMessage request) + { + var token = _httpContextAccessor.HttpContext?.User.FindFirst("access_token")?.Value; + if (token is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + public async Task?> GetMiEntidadesAsync(int pageNumber, int pageSize, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/MiEntidad?PageNumber={pageNumber}&PageSize={pageSize}"); + AttachBearerToken(request); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(cancellationToken: ct); + } + + // ... resto de métodos siguiendo el mismo patrón +} +``` + +### Servicio de catálogo (read-only) +Si el CRUD necesita un dropdown desde un catálogo: + +```csharp +public interface IMiCatalogoService +{ + Task?> GetAllAsync(int pageNumber, int pageSize, CancellationToken ct); + Task GetByIdAsync(int id, CancellationToken ct); +} +``` + +--- + +## 3. Registro en `Program.cs` + +```csharp +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://localhost:7227/"); +}).AddStandardResilienceHandler(); + +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://localhost:7227/"); +}).AddStandardResilienceHandler(); +``` + +Colocar después de los registros existentes de UsuarioService. + +--- + +## 4. Controlador (`Controllers/{Entidad}Controller.cs`) + +### 8 acciones + +| Verbo | Ruta | Propósito | +|-------|------|-----------| +| GET | `Index?texto=&idTipoEmpleado=&pageNumber=&pageSize=` | Lista paginada con filtros | +| GET | `Create` | Formulario de creación | +| POST | `Create` | Procesar creación | +| GET | `Details/{id}` | Vista de solo lectura | +| GET | `Update/{id}` | Formulario de edición | +| POST | `Update` | Procesar edición | +| GET | `Delete/{id}` | Confirmación de eliminación | +| POST | `Delete` | Procesar eliminación | + +### Filtrado en Index +```csharp +[HttpGet] +[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client, NoStore = false)] +public async Task Index(string? texto = null, int? idTipoEmpleado = null, int pageNumber = 1, int pageSize = 10, CancellationToken ct = default) +{ + await CargarCatalogosAsync(ct); + + PaginatedResponse? result; + + if (!string.IsNullOrWhiteSpace(texto) || idTipoEmpleado.HasValue) + result = await _service.SearchMiEntidadesAsync(texto, idTipoEmpleado, pageNumber, pageSize, ct); + else + result = await _service.GetMiEntidadesAsync(pageNumber, pageSize, ct); + + if (result is null) + { + _logger.LogWarning("Failed to load entidades"); + TempData["Error"] = "No se pudieron cargar los registros."; + } + + return View(result); +} +``` + +### Dropdown helper +```csharp +private async Task CargarCatalogosAsync(CancellationToken ct) +{ + var catalogos = await _catalogoService.GetAllAsync(1, 100, ct); + if (catalogos?.Items is not null) + { + ViewBag.CatalogoList = new SelectList(catalogos.Items, "Id", "StrValor"); + ViewBag.CatalogoDict = catalogos.Items.ToDictionary(c => c.Id, c => c.StrValor); + } +} +``` + +### POST actions — patrón general +```csharp +[HttpPost] +[ValidateAntiForgeryToken] +public async Task Create(MiEntidadCreateViewModel model, CancellationToken ct, string? returnUrl = null) +{ + if (!ModelState.IsValid) + { + await CargarCatalogosAsync(ct); + return View(model); + } + + var result = await _service.CreateMiEntidadAsync(model, ct); + + if (!result.Success) + { + ModelState.AddModelError("", result.Message); + await CargarCatalogosAsync(ct); + return View(model); + } + + TempData["Success"] = result.Message; + + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + + return RedirectToAction(nameof(Index)); +} +``` + +--- + +## 5. Vistas (`Views/{Entidad}/`) + +### Index.cshtml — Lista paginada con filtros +```html +@model PaginatedResponse + +
    +
    + +
    +
    + +
    +
    + + Limpiar +
    +
    + Nuevo +
    +
    + + + + + + + + + + + @if (Model?.Items is not null) + { + @foreach (var item in Model.Items) + { + + + + + + } + } + +
    NombreCatálogoAcciones
    @item.StrNombre@(ViewBag.CatalogoDict?[item.IdTipoEmpleado] ?? "") + Detalles + Editar + Eliminar +
    + +@if (Model is not null) +{ + +} +``` + +### Create.cshtml / Update.cshtml +```html +@model MiEntidadCreateViewModel + + + +
    + @Html.AntiForgeryToken() + +
    + + + +
    + +
    + + + +
    + + + Cancelar +
    +``` + +### Delete.cshtml +```html +@model MiEntidadDeleteViewModel + +

    ¿Está seguro de eliminar @Model.StrNombre?

    + +
    + @Html.AntiForgeryToken() + + + + + Cancelar +
    +``` + +--- + +## 6. Menú en `_Layout.cshtml` + +Agregar después del ítem "Usuarios": + +```html +
  • +``` + +--- + +## 7. Pruebas + +### Unit tests — datos de prueba (`ContractTestData.cs`) +```csharp +public static readonly PaginatedResponse ValidMiEntidadPaginatedResponse = new() +{ + Items = [ValidMiEntidad], + PageNumber = 1, + PageSize = 10, + TotalCount = 1, + TotalPages = 1 +}; + +public static readonly MiEntidad ValidMiEntidad = new() +{ + Id = 1, + StrNombre = "Ejemplo", + RowVersion = [0x01, 0x02, 0x03, 0x04] +}; + +public static readonly PaginatedResponse ValidCatalogoPaginatedResponse = new() +{ + Items = [ValidCatalogo], + TotalCount = 1 +}; + +public static readonly MiCatalogo ValidCatalogo = new() +{ + Id = 1, + StrValor = "Opción 1" +}; +``` + +### Integration tests +Usar WireMock para simular la API. Ejemplo: + +```csharp +public class MiEntidadApiClientTests : IClassFixture +{ + private readonly WireMockFixture _wireMock; + private readonly MiEntidadService _service; + + public MiEntidadApiClientTests(WireMockFixture wireMock) + { + _wireMock = wireMock; + var httpContextAccessor = Mock.Of(); + var httpClient = wireMock.CreateClient(); + _service = new MiEntidadService(httpClient, httpContextAccessor); + } + + [Fact] + public async Task GetMiEntidadesAsync_ReturnsData() + { + _wireMock.Server.Given( + RequestBuilder.Create().WithPath("/api/v1/MiEntidad").UsingGet() + ).RespondWith( + ResponseBuilder.Create().WithStatusCode(200).WithBodyAsJson(ContractTestData.ValidMiEntidadPaginatedResponse) + ); + + var result = await _service.GetMiEntidadesAsync(1, 10, CancellationToken.None); + Assert.NotNull(result); + Assert.Single(result.Items); + } +} +``` + +### Security tests +```csharp +public class MiEntidadSecurityTests +{ + private static WebApplicationFactory CreateFactory() + { + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(new FakeAuthService()); + services.PostConfigure( + CookieAuthenticationDefaults.AuthenticationScheme, + options => options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest); + }); + }); + } + + [Fact] + public async Task Index_RequiresAuthentication() + { + var (factory, client) = CreateUnauthenticatedClient(); + using (factory) using (client) + { + var (status, _) = await SecurityTestHelpers.GetAsync(client, "/MiEntidad/Index"); + Assert.Equal((int)HttpStatusCode.Redirect, status); + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task Create_RejectsEmptyNombre(string? nombre) + { + using var factory = CreateFactory(); + using var client = factory.CreateClient(...); + await AuthenticateAsync(client); + var token = await SecurityTestHelpers.GetAntiForgeryTokenAsync(client, "/MiEntidad/Create"); + var form = SecurityTestHelpers.ToFormPayload(new Dictionary + { + { "__RequestVerificationToken", token }, + { "StrNombre", nombre ?? "" }, + }); + var (status, body) = await SecurityTestHelpers.PostAsync(client, "/MiEntidad/Create", form); + Assert.Equal((int)HttpStatusCode.OK, status); + Assert.Contains("obligatorio", body); + } + + [Theory] + [MemberData(nameof(SecurityTestData.XssPayloads.All), MemberType = typeof(SecurityTestData.XssPayloads))] + public async Task Create_RejectsXssInNombre(string xssPayload) + { + // mismo patrón que arriba, enviar xssPayload en StrNombre + } +} +``` + +### Comandos +```powershell +dotnet build +dotnet test tests\WebDevSecOps.UnitTests --filter "FullyQualifiedName~MiEntidad" +dotnet test tests\WebDevSecOps.IntegrationTests --filter "FullyQualifiedName~MiEntidad" +dotnet test tests\WebDevSecOps.SecurityTests --filter "FullyQualifiedName~MiEntidad" +``` + +--- + +## Convenciones clave + +| Concepto | Detalle | +|----------|---------| +| Naming | `Str` prefijo para strings, `Async` sufijo, `ct` para CancellationToken | +| TempData | `TempData["Success"]`, `TempData["Error"]`, `TempData["Warning"]` | +| OperationResult | Usar `OperationResult` genérico, no crear tipos resultado específicos | +| Concurrencia | `RowVersion` (byte[]) en DeleteViewModel, pasado al DELETE de la API | +| ReturnUrl | Parámetro opcional en POST, validar con `Url.IsLocalUrl()` | +| Catálogos | `ViewBag.{Nombre}List` (SelectList) + `ViewBag.{Nombre}Dict` (Dictionary), pageSize=100 | +| Paginación | Default pageNumber=1, pageSize=10 | +| Filtros | GET form, si hay filtros → `SearchAsync`, si no → `GetAllAsync` | +| Logger | `_logger.LogWarning(...)` cuando la API retorna null | diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aec1c43 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# WebDevSecOps — Memoria del proyecto + +## Convenciones generales +- **Idioma**: Código fuente y mensajes UX en español (controladores, vistas, validaciones) +- **Patrón CRUD**: Controlador → Servicio (HttpClient) → API externa, siguiendo el mismo patrón que `UsuarioController`/`IUsuarioService` +- **Mensajes al usuario**: Toastr mediante `TempData["Success"]`, `TempData["Error"]`, `TempData["Warning"]` +- **Resultado de operaciones**: `OperationResult` (genérico) en `Models/OperationResult.cs` +- **Concurrencia optimista**: `RowVersion` (byte[]) en DeleteViewModel, pasado al servicio para el DELETE de la API +- **ReturnUrl**: Patrón opcional en acciones POST para redirect condicional + +## Módulo Empleado (implementado) +### Archivos clave +| Archivo | Propósito | +|---------|-----------| +| `Controllers/EmpleadoController.cs` | 8 acciones (Index, Create GET/POST, Details, Update GET/POST, Delete GET/POST) | +| `Services/IEmpleadoService.cs`, `EmpleadoService.cs` | 6 métodos HttpClient contra `/api/v1/Empleado` | +| `Services/ITipoEmpleadoService.cs`, `TipoEmpleadoService.cs` | Catálogo read-only (2 métodos) | +| `Models/Empleado.cs` | Modelo de dominio | +| `Models/EmpCatTipoEmpleado.cs` | Modelo de catálogo (read-only) | +| `Models/Empleado{Create,Update,Details,Delete}ViewModel.cs` | ViewModels por acción | +| `Views/Empleado/{Index,Create,Details,Update,Delete}.cshtml` | Vistas Razor | + +### Endpoints API consumidos +- `GET /api/v1/Empleado?PageNumber=&PageSize=` +- `POST /api/v1/Empleado` +- `GET /api/v1/Empleado/{id}` +- `PUT /api/v1/Empleado/{id}` +- `DELETE /api/v1/Empleado/{id}` +- `GET /api/v1/Empleado/buscar?texto=&idTipoEmpleado=` +- `GET /api/v1/TipoEmpleado?PageNumber=&PageSize=` +- `GET /api/v1/TipoEmpleado/{id}` + +### Filtros en Index +- **Texto**: búsqueda por nombre/apellido vía `SearchEmpleadosAsync` +- **TipoEmpleado**: dropdown poblado desde `ViewBag.TipoEmpleadoList` (SelectList) +- **Paginación**: `pagina` (default 1) y `pageSize` (default 10) +- El controlador llama a `SearchEmpleadosAsync` si `texto` o `idTipoEmpleado` tienen valor, sino a `GetEmpleadosAsync` + +### Dropdown TipoEmpleado +- Poblado en `CargarTipoEmpleadosAsync()` helper en el controlador +- Almacenado en `ViewBag.TipoEmpleadoList` como `SelectList` (value=`Id`, text=`StrValor`) +- También se guarda un `Dictionary` en `ViewBag.TipoEmpleadoDict` para resolución en Index sin N+1 +- `pageSize=100` para obtener todos los registros del catálogo + +### Pruebas +| Proyecto | Archivos | +|----------|----------| +| `WebDevSecOps.UnitTests` | `Pages/Empleados/{IndexTests,CreateTests,UpdateTests,DeleteTests}.cs` | +| `WebDevSecOps.IntegrationTests` | `Services/{EmpleadoApiClientTests,TipoEmpleadoApiClientTests}.cs` | +| `WebDevSecOps.SecurityTests` | `SAST/EmpleadoSecurityTests.cs` | + +### Comandos +```powershell +dotnet build # build completo (0 errors, 0 warnings) +dotnet test tests\WebDevSecOps.UnitTests +dotnet test tests\WebDevSecOps.IntegrationTests +dotnet test tests\WebDevSecOps.SecurityTests +``` + +### Patrón para nuevos CRUDs +1. Crear modelo de dominio en `Models/` +2. Crear ViewModels en `Models/` por acción +3. Crear interfaz e implementación de servicio en `Services/` +4. Registrar en `Program.cs` con `AddHttpClient()...AddStandardResilienceHandler()` +5. Crear controlador en `Controllers/` heredando `Controller` con `[Authorize]` +6. Crear vistas en `Views/{Nombre}/` +7. Agregar ítem de menú en `Pages/Shared/_Layout.cshtml` (después de "Usuarios") +8. Agregar datos de prueba en `tests/WebDevSecOps.UnitTests/Common/ContractTestData.cs` +9. Crear tests unitarios, de integración y seguridad diff --git a/WebDevSecOps/Controllers/EmpleadoController.cs b/WebDevSecOps/Controllers/EmpleadoController.cs new file mode 100644 index 0000000..91000ae --- /dev/null +++ b/WebDevSecOps/Controllers/EmpleadoController.cs @@ -0,0 +1,308 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using WebDevSecOps.Models; +using WebDevSecOps.Services; + +namespace WebDevSecOps.Controllers; + +[Authorize] +public class EmpleadoController : Controller +{ + private readonly IEmpleadoService _service; + private readonly ITipoEmpleadoService _tipoEmpleadoService; + private readonly ILogger _logger; + + public EmpleadoController(IEmpleadoService service, ITipoEmpleadoService tipoEmpleadoService, ILogger logger) + { + _service = service; + _tipoEmpleadoService = tipoEmpleadoService; + _logger = logger; + } + + [HttpGet] + [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client, NoStore = false)] + public async Task Index(string? texto = null, int? idTipoEmpleado = null, int pageNumber = 1, int pageSize = 10, CancellationToken ct = default) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + await CargarTipoEmpleadosAsync(ct); + + var result = (!string.IsNullOrEmpty(texto) || idTipoEmpleado.HasValue) + ? await _service.SearchEmpleadosAsync(texto, idTipoEmpleado, pageNumber, pageSize, ct) + : await _service.GetEmpleadosAsync(pageNumber, pageSize, ct); + + if (result is null) + { + _logger.LogWarning("Failed to load empleados — service returned null"); + TempData["Error"] = "No se pudieron cargar los empleados."; + } + + return View(result); + } + + [HttpGet] + [ResponseCache(Duration = 300, Location = ResponseCacheLocation.Client, NoStore = false)] + public async Task Details(int id, CancellationToken ct, string? returnUrl = null) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var empleado = await _service.GetEmpleadoByIdAsync(id, ct); + + if (empleado is null) + { + _logger.LogWarning("Empleado with id {Id} not found for details", id); + TempData["Error"] = "El empleado no existe o no se pudo recuperar la informacion."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return Redirect("/Empleado/Index"); + } + + var strValorTipoEmpleado = await ObtenerValorTipoEmpleado(empleado.IdEmpCatTipoEmpleado, ct); + + ViewData["ReturnUrl"] = returnUrl; + + var model = new EmpleadoDetailsViewModel + { + Id = empleado.Id, + StrNombre = empleado.StrNombre, + StrAPaterno = empleado.StrAPaterno, + StrAMaterno = empleado.StrAMaterno, + StrCURP = empleado.StrCURP, + StrValorTipoEmpleado = strValorTipoEmpleado, + RowVersion = empleado.RowVersion ?? [] + }; + + return View(model); + } + + [HttpGet] + public async Task Create(string? returnUrl = null, CancellationToken ct = default) + { + await CargarTipoEmpleadosAsync(ct); + ViewData["ReturnUrl"] = returnUrl; + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(EmpleadoCreateViewModel model, CancellationToken ct, string? returnUrl = null) + { + if (!ModelState.IsValid) + { + await CargarTipoEmpleadosAsync(ct); + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + var result = await _service.CreateEmpleadoAsync(model, ct); + + if (result.Success) + { + _logger.LogInformation("Empleado created successfully"); + TempData["Success"] = "Empleado creado exitosamente."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return RedirectToAction("Index"); + } + + if (result.FieldErrors is not null) + { + foreach (var kvp in result.FieldErrors) + { + foreach (var msg in kvp.Value) + ModelState.AddModelError(kvp.Key, msg); + } + } + + if (result.ErrorMessage is not null) + ModelState.AddModelError(string.Empty, result.ErrorMessage); + + await CargarTipoEmpleadosAsync(ct); + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + [HttpGet] + public async Task Update(int id, CancellationToken ct, string? returnUrl = null) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var empleado = await _service.GetEmpleadoByIdAsync(id, ct); + + if (empleado is null) + { + _logger.LogWarning("Empleado with id {Id} not found for update", id); + TempData["Error"] = "Empleado no encontrado."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return Redirect("/Empleado/Index"); + } + + await CargarTipoEmpleadosAsync(ct); + ViewData["ReturnUrl"] = returnUrl; + + var model = new EmpleadoUpdateViewModel + { + Id = empleado.Id, + StrNombre = empleado.StrNombre, + StrAPaterno = empleado.StrAPaterno, + StrAMaterno = empleado.StrAMaterno, + StrCURP = empleado.StrCURP, + IdEmpCatTipoEmpleado = empleado.IdEmpCatTipoEmpleado, + RowVersion = empleado.RowVersion ?? [] + }; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Update(EmpleadoUpdateViewModel model, CancellationToken ct, string? returnUrl = null) + { + if (!ModelState.IsValid) + { + await CargarTipoEmpleadosAsync(ct); + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + var result = await _service.UpdateEmpleadoAsync(model.Id, model, ct); + + if (result.Success) + { + _logger.LogInformation("Empleado {Id} updated successfully", model.Id); + TempData["Success"] = "Empleado actualizado exitosamente."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return RedirectToAction("Index"); + } + + if (result.FieldErrors is not null) + { + foreach (var kvp in result.FieldErrors) + { + foreach (var msg in kvp.Value) + { + var key = kvp.Key switch + { + "strNombre" => nameof(EmpleadoUpdateViewModel.StrNombre), + "strAPaterno" => nameof(EmpleadoUpdateViewModel.StrAPaterno), + "strAMaterno" => nameof(EmpleadoUpdateViewModel.StrAMaterno), + "strCURP" => nameof(EmpleadoUpdateViewModel.StrCURP), + "idEmpCatTipoEmpleado" => nameof(EmpleadoUpdateViewModel.IdEmpCatTipoEmpleado), + "rowVersion" => nameof(EmpleadoUpdateViewModel.RowVersion), + _ => kvp.Key + }; + ModelState.AddModelError(key, msg); + } + } + } + + if (result.ErrorMessage is not null) + ModelState.AddModelError(string.Empty, result.ErrorMessage); + + await CargarTipoEmpleadosAsync(ct); + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + [HttpGet] + public async Task Delete(int id, CancellationToken ct, string? returnUrl = null) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var empleado = await _service.GetEmpleadoByIdAsync(id, ct); + + if (empleado is null) + { + _logger.LogWarning("Empleado with id {Id} not found for delete", id); + TempData["Error"] = "Empleado no encontrado."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return Redirect("/Empleado/Index"); + } + + var strValorTipoEmpleado = await ObtenerValorTipoEmpleado(empleado.IdEmpCatTipoEmpleado, ct); + + ViewData["ReturnUrl"] = returnUrl; + + var model = new EmpleadoDeleteViewModel + { + Id = empleado.Id, + StrNombre = empleado.StrNombre, + StrAPaterno = empleado.StrAPaterno, + StrAMaterno = empleado.StrAMaterno, + StrCURP = empleado.StrCURP, + StrValorTipoEmpleado = strValorTipoEmpleado, + RowVersion = empleado.RowVersion ?? [] + }; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(EmpleadoDeleteViewModel model, CancellationToken ct, string? returnUrl = null) + { + if (!ModelState.IsValid) + { + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + var result = await _service.DeleteEmpleadoAsync(model.Id, model.RowVersion, ct); + + if (result.Success) + { + _logger.LogInformation("Empleado {Id} deleted successfully", model.Id); + TempData["Success"] = "Empleado eliminado exitosamente."; + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + return RedirectToAction("Index"); + } + + if (result.FieldErrors is not null) + { + foreach (var kvp in result.FieldErrors) + { + foreach (var msg in kvp.Value) + { + var key = kvp.Key switch + { + "id" => nameof(EmpleadoDeleteViewModel.Id), + "rowVersion" => nameof(EmpleadoDeleteViewModel.RowVersion), + _ => kvp.Key + }; + ModelState.AddModelError(key, msg); + } + } + } + + if (result.ErrorMessage is not null) + ModelState.AddModelError(string.Empty, result.ErrorMessage); + + ViewData["ReturnUrl"] = returnUrl; + return View(model); + } + + private async Task CargarTipoEmpleadosAsync(CancellationToken ct = default) + { + var tipos = await _tipoEmpleadoService.GetAllAsync(ct: ct); + ViewBag.TipoEmpleadoList = new SelectList(tipos?.Items ?? [], "Id", "StrValor"); + ViewBag.TipoEmpleadoDict = tipos?.Items?.ToDictionary(t => t.Id, t => t.StrValor) ?? []; + } + + private async Task ObtenerValorTipoEmpleado(int? idTipoEmpleado, CancellationToken ct = default) + { + if (!idTipoEmpleado.HasValue) + return null; + + var tipo = await _tipoEmpleadoService.GetByIdAsync(idTipoEmpleado.Value, ct); + return tipo?.StrValor; + } +} diff --git a/WebDevSecOps/Models/EmpCatTipoEmpleado.cs b/WebDevSecOps/Models/EmpCatTipoEmpleado.cs new file mode 100644 index 0000000..703f224 --- /dev/null +++ b/WebDevSecOps/Models/EmpCatTipoEmpleado.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class EmpCatTipoEmpleado +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("strValor")] + public string StrValor { get; set; } = string.Empty; + + [JsonPropertyName("strDescripcion")] + public string StrDescripcion { get; set; } = string.Empty; +} diff --git a/WebDevSecOps/Models/Empleado.cs b/WebDevSecOps/Models/Empleado.cs new file mode 100644 index 0000000..6247171 --- /dev/null +++ b/WebDevSecOps/Models/Empleado.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class Empleado +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("strNombre")] + public string StrNombre { get; set; } = string.Empty; + + [JsonPropertyName("strAPaterno")] + public string? StrAPaterno { get; set; } + + [JsonPropertyName("strAMaterno")] + public string? StrAMaterno { get; set; } + + [JsonPropertyName("strCURP")] + public string? StrCURP { get; set; } + + [JsonPropertyName("idEmpCatTipoEmpleado")] + public int? IdEmpCatTipoEmpleado { get; set; } + + [JsonPropertyName("rowVersion")] + public byte[]? RowVersion { get; set; } +} diff --git a/WebDevSecOps/Models/EmpleadoCreateViewModel.cs b/WebDevSecOps/Models/EmpleadoCreateViewModel.cs new file mode 100644 index 0000000..652fad1 --- /dev/null +++ b/WebDevSecOps/Models/EmpleadoCreateViewModel.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class EmpleadoCreateViewModel +{ + [Required(ErrorMessage = "El nombre es obligatorio.")] + [StringLength(50, ErrorMessage = "El nombre no puede exceder los 50 caracteres.")] + [RegularExpression(@"^[a-zA-Z0-9_ ]+$", ErrorMessage = "El nombre solo permite letras, numeros y espacios.")] + [JsonPropertyName("strNombre")] + [Display(Name = "Nombre")] + public string StrNombre { get; set; } = string.Empty; + + [StringLength(50, ErrorMessage = "A. Paterno no puede exceder los 50 caracteres.")] + [JsonPropertyName("strAPaterno")] + [Display(Name = "A. Paterno")] + public string? StrAPaterno { get; set; } + + [StringLength(50, ErrorMessage = "A. Materno no puede exceder los 50 caracteres.")] + [JsonPropertyName("strAMaterno")] + [Display(Name = "A. Materno")] + public string? StrAMaterno { get; set; } + + [StringLength(18, ErrorMessage = "CURP debe tener 18 caracteres.")] + [JsonPropertyName("strCURP")] + [Display(Name = "CURP")] + public string? StrCURP { get; set; } + + [JsonPropertyName("idEmpCatTipoEmpleado")] + [Display(Name = "Tipo Empleado")] + public int? IdEmpCatTipoEmpleado { get; set; } +} diff --git a/WebDevSecOps/Models/EmpleadoDeleteViewModel.cs b/WebDevSecOps/Models/EmpleadoDeleteViewModel.cs new file mode 100644 index 0000000..5c6cb3c --- /dev/null +++ b/WebDevSecOps/Models/EmpleadoDeleteViewModel.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class EmpleadoDeleteViewModel +{ + [Required] + [JsonRequired] + [JsonPropertyName("id")] + public int Id { get; set; } + + [Display(Name = "Nombre")] + public string StrNombre { get; set; } = string.Empty; + + [Display(Name = "A. Paterno")] + public string? StrAPaterno { get; set; } + + [Display(Name = "A. Materno")] + public string? StrAMaterno { get; set; } + + [Display(Name = "CURP")] + public string? StrCURP { get; set; } + + [Display(Name = "Tipo Empleado")] + public string? StrValorTipoEmpleado { get; set; } + + [Required] + [JsonPropertyName("rowVersion")] + public byte[] RowVersion { get; set; } = []; +} diff --git a/WebDevSecOps/Models/EmpleadoDetailsViewModel.cs b/WebDevSecOps/Models/EmpleadoDetailsViewModel.cs new file mode 100644 index 0000000..92f61f9 --- /dev/null +++ b/WebDevSecOps/Models/EmpleadoDetailsViewModel.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class EmpleadoDetailsViewModel +{ + [Required] + [JsonPropertyName("id")] + public int Id { get; set; } + + [Display(Name = "Nombre")] + [JsonPropertyName("strNombre")] + public string StrNombre { get; set; } = string.Empty; + + [Display(Name = "A. Paterno")] + [JsonPropertyName("strAPaterno")] + public string? StrAPaterno { get; set; } + + [Display(Name = "A. Materno")] + [JsonPropertyName("strAMaterno")] + public string? StrAMaterno { get; set; } + + [Display(Name = "CURP")] + [JsonPropertyName("strCURP")] + public string? StrCURP { get; set; } + + [Display(Name = "Tipo Empleado")] + public string? StrValorTipoEmpleado { get; set; } + + [Required] + [JsonPropertyName("rowVersion")] + public byte[] RowVersion { get; set; } = []; +} diff --git a/WebDevSecOps/Models/EmpleadoUpdateViewModel.cs b/WebDevSecOps/Models/EmpleadoUpdateViewModel.cs new file mode 100644 index 0000000..d52ce7e --- /dev/null +++ b/WebDevSecOps/Models/EmpleadoUpdateViewModel.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class EmpleadoUpdateViewModel +{ + [Required] + [JsonRequired] + [JsonPropertyName("id")] + public int Id { get; set; } + + [Required(ErrorMessage = "El nombre es obligatorio.")] + [StringLength(50, ErrorMessage = "El nombre no puede exceder los 50 caracteres.")] + [RegularExpression(@"^[a-zA-Z0-9_ ]+$", ErrorMessage = "El nombre solo permite letras, numeros y espacios.")] + [JsonPropertyName("strNombre")] + [Display(Name = "Nombre")] + public string StrNombre { get; set; } = string.Empty; + + [StringLength(50, ErrorMessage = "A. Paterno no puede exceder los 50 caracteres.")] + [JsonPropertyName("strAPaterno")] + [Display(Name = "A. Paterno")] + public string? StrAPaterno { get; set; } + + [StringLength(50, ErrorMessage = "A. Materno no puede exceder los 50 caracteres.")] + [JsonPropertyName("strAMaterno")] + [Display(Name = "A. Materno")] + public string? StrAMaterno { get; set; } + + [StringLength(18, ErrorMessage = "CURP debe tener 18 caracteres.")] + [JsonPropertyName("strCURP")] + [Display(Name = "CURP")] + public string? StrCURP { get; set; } + + [JsonPropertyName("idEmpCatTipoEmpleado")] + [Display(Name = "Tipo Empleado")] + public int? IdEmpCatTipoEmpleado { get; set; } + + [Required] + [JsonPropertyName("rowVersion")] + public byte[] RowVersion { get; set; } = []; +} diff --git a/WebDevSecOps/Models/OperationResult.cs b/WebDevSecOps/Models/OperationResult.cs new file mode 100644 index 0000000..1736469 --- /dev/null +++ b/WebDevSecOps/Models/OperationResult.cs @@ -0,0 +1,22 @@ +namespace WebDevSecOps.Models; + +public class OperationResult +{ + public bool Success { get; } + public string? ErrorMessage { get; } + public Dictionary? FieldErrors { get; } + + private OperationResult(bool success, string? errorMessage, Dictionary? fieldErrors) + { + Success = success; + ErrorMessage = errorMessage; + FieldErrors = fieldErrors; + } + + public static OperationResult Ok() => new(true, null, null); + + public static OperationResult Fail(string message) => new(false, message, null); + + public static OperationResult Fail(Dictionary fieldErrors, string? message = null) + => new(false, message, fieldErrors); +} diff --git a/WebDevSecOps/Pages/Shared/_Layout.cshtml b/WebDevSecOps/Pages/Shared/_Layout.cshtml index cfdf188..3a21f9d 100644 --- a/WebDevSecOps/Pages/Shared/_Layout.cshtml +++ b/WebDevSecOps/Pages/Shared/_Layout.cshtml @@ -29,6 +29,9 @@ + }