From 13fdfa2c0ad15bbbe5a65ba6b305d604a00d6689 Mon Sep 17 00:00:00 2001 From: edelomeza Date: Thu, 16 Jul 2026 12:51:52 -0600 Subject: [PATCH 1/3] feat: add VentaDetalle CRUD (productos) with delete, guardar, and client-side filtering - Add VentaDetalle model, viewmodels, service and controller actions - Add Productos page with autocomplete, table, delete button, and total - Add EliminarProducto AJAX endpoint with anti-forgery validation - Add Guardar action that sends full Venta object for PUT - Add client-side filtering for idVenVenta (API ignores query param) - Add integration tests for VentaDetalle delete - Add security tests for EliminarProducto endpoint - Clean up unused JS variables --- WebDevSecOps/Controllers/VentaController.cs | 140 +++++++- .../Models/ProProductoAutocompleteDto.cs | 12 + .../Models/VentaAgregarProductoViewModel.cs | 22 ++ WebDevSecOps/Models/VentaDetalle.cs | 30 ++ .../Models/VentaProductosViewModel.cs | 28 ++ WebDevSecOps/Services/IProductoService.cs | 2 + WebDevSecOps/Services/IVentaService.cs | 10 + WebDevSecOps/Services/ProductoService.cs | 38 +++ WebDevSecOps/Services/VentaService.cs | 262 +++++++++++++++ WebDevSecOps/Views/Venta/Index.cshtml | 6 + WebDevSecOps/Views/Venta/Productos.cshtml | 306 ++++++++++++++++++ WebDevSecOps/appsettings.json | 4 +- .../Services/VentaApiClientTests.cs | 46 +++ .../SAST/VentaSecurityTests.cs | 17 + .../Common/ContractTestData.cs | 63 ++++ .../Pages/Ventas/CreateTests.cs | 27 +- .../Pages/Ventas/IndexTests.cs | 3 +- 17 files changed, 999 insertions(+), 17 deletions(-) create mode 100644 WebDevSecOps/Models/ProProductoAutocompleteDto.cs create mode 100644 WebDevSecOps/Models/VentaAgregarProductoViewModel.cs create mode 100644 WebDevSecOps/Models/VentaDetalle.cs create mode 100644 WebDevSecOps/Models/VentaProductosViewModel.cs create mode 100644 WebDevSecOps/Views/Venta/Productos.cshtml diff --git a/WebDevSecOps/Controllers/VentaController.cs b/WebDevSecOps/Controllers/VentaController.cs index 338f779..cef441f 100644 --- a/WebDevSecOps/Controllers/VentaController.cs +++ b/WebDevSecOps/Controllers/VentaController.cs @@ -12,14 +12,16 @@ public class VentaController : Controller private readonly IEstadoVentaService _estadoVentaService; private readonly IClienteService _clienteService; private readonly IUsuarioService _usuarioService; + private readonly IProductoService _productoService; private readonly ILogger _logger; - public VentaController(IVentaService service, IEstadoVentaService estadoVentaService, IClienteService clienteService, IUsuarioService usuarioService, ILogger logger) + public VentaController(IVentaService service, IEstadoVentaService estadoVentaService, IClienteService clienteService, IUsuarioService usuarioService, IProductoService productoService, ILogger logger) { _service = service; _estadoVentaService = estadoVentaService; _clienteService = clienteService; _usuarioService = usuarioService; + _productoService = productoService; _logger = logger; } @@ -123,6 +125,142 @@ public async Task UsuariosAutocomplete(string texto, int maxResultad return Json(result ?? []); } + [HttpGet] + public async Task Productos(int id, CancellationToken ct) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var venta = await _service.GetVentaByIdAsync(id, ct); + if (venta is null) + { + _logger.LogWarning("Venta with id {Id} not found", id); + TempData["Error"] = "La venta no existe o no se pudo recuperar la informacion."; + return RedirectToAction("Index"); + } + + await CargarEstadoVentasAsync(ct); + + var detalles = await _service.GetVentaDetallesAsync(id, ct) ?? []; + + var model = new VentaProductosViewModel + { + Id = venta.Id, + DteFechaHoraCompra = venta.DteFechaHoraCompra, + StrClaveVenta = venta.StrClaveVenta, + StrNombreCliente = venta.StrNombreCliente, + StrNombreUsuario = venta.StrNombreUsuario, + IdVenCatEstado = venta.IdVenCatEstado, + RowVersion = venta.RowVersion, + Detalles = detalles + }; + + return View(model); + } + + [HttpGet] + public async Task ProductosAutocomplete(string texto, int maxResultados = 10, CancellationToken ct = default) + { + if (!ModelState.IsValid) + return Json(new List()); + + if (string.IsNullOrWhiteSpace(texto) || texto.Length < 2) + return Json(new List()); + + var result = await _productoService.AutocompleteProductosAsync(texto, maxResultados, ct); + return Json(result ?? []); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task AgregarProducto(VentaAgregarProductoViewModel model, CancellationToken ct) + { + if (!ModelState.IsValid) + { + TempData["Error"] = "Verifique los datos del producto."; + return RedirectToAction("Productos", new { id = model.IdVenVenta }); + } + + var producto = await _productoService.GetProductoByIdAsync(model.IdProProducto, ct); + if (producto is null) + { + _logger.LogWarning("Producto with id {IdProProducto} not found", model.IdProProducto); + TempData["Error"] = "El producto seleccionado no existe."; + return RedirectToAction("Productos", new { id = model.IdVenVenta }); + } + + if (producto.IntNumeroExistencia <= 0) + { + _logger.LogWarning("Producto {IdProProducto} has no stock", model.IdProProducto); + TempData["Error"] = "El producto no tiene existencia disponible."; + return RedirectToAction("Productos", new { id = model.IdVenVenta }); + } + + var result = await _service.CreateVentaDetalleAsync(model, ct); + + if (result.Success) + { + _logger.LogInformation("VentaDetalle created for Venta {Id}", model.IdVenVenta); + TempData["Success"] = "Producto agregado exitosamente."; + } + else + { + TempData["Error"] = result.ErrorMessage ?? "Error al agregar el producto."; + } + + return RedirectToAction("Productos", new { id = model.IdVenVenta }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Guardar(int id, int idVenCatEstado, byte[] rowVersion, CancellationToken ct) + { + if (!ModelState.IsValid) + { + TempData["Error"] = "Datos invalidos para guardar la venta."; + return RedirectToAction("Productos", new { id }); + } + + var venta = await _service.GetVentaByIdAsync(id, ct); + if (venta is null) + { + TempData["Error"] = "Venta no encontrada."; + return RedirectToAction("Index"); + } + + venta.IdVenCatEstado = idVenCatEstado; + + var result = await _service.UpdateVentaAsync(id, venta, ct); + + if (result.Success) + { + _logger.LogInformation("Venta {Id} guardada exitosamente", id); + TempData["Success"] = "Venta guardada exitosamente."; + } + else + { + TempData["Error"] = result.ErrorMessage ?? "Error al guardar la venta."; + return RedirectToAction("Productos", new { id }); + } + + return RedirectToAction("Index"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EliminarProducto(int id, byte[] rowVersion, CancellationToken ct) + { + if (!ModelState.IsValid) + return Json(new { success = false, message = "Datos invalidos." }); + + var result = await _service.DeleteVentaDetalleAsync(id, rowVersion, ct); + + if (result.Success) + return Json(new { success = true }); + + return Json(new { success = false, message = result.ErrorMessage ?? "Error al eliminar el producto." }); + } + private async Task CargarEstadoVentasAsync(CancellationToken ct = default) { var estados = await _estadoVentaService.GetAllAsync(ct: ct); diff --git a/WebDevSecOps/Models/ProProductoAutocompleteDto.cs b/WebDevSecOps/Models/ProProductoAutocompleteDto.cs new file mode 100644 index 0000000..6e2a31c --- /dev/null +++ b/WebDevSecOps/Models/ProProductoAutocompleteDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class ProProductoAutocompleteDto +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("strTextoAutocomplete")] + public string StrTextoAutocomplete { get; set; } = string.Empty; +} diff --git a/WebDevSecOps/Models/VentaAgregarProductoViewModel.cs b/WebDevSecOps/Models/VentaAgregarProductoViewModel.cs new file mode 100644 index 0000000..066ad10 --- /dev/null +++ b/WebDevSecOps/Models/VentaAgregarProductoViewModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class VentaAgregarProductoViewModel +{ + [Required(ErrorMessage = "La venta es obligatoria.")] + [JsonRequired] + [JsonPropertyName("idVenVenta")] + public int IdVenVenta { get; set; } + + [Required(ErrorMessage = "El producto es obligatorio.")] + [JsonRequired] + [JsonPropertyName("idProProducto")] + public int IdProProducto { get; set; } + + [Required(ErrorMessage = "El numero de piezas es obligatorio.")] + [Range(1, int.MaxValue, ErrorMessage = "Las piezas deben ser al menos 1.")] + [JsonPropertyName("intPiezaVenta")] + public int IntPiezasVenta { get; set; } +} diff --git a/WebDevSecOps/Models/VentaDetalle.cs b/WebDevSecOps/Models/VentaDetalle.cs new file mode 100644 index 0000000..130cab6 --- /dev/null +++ b/WebDevSecOps/Models/VentaDetalle.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class VentaDetalle +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("idVenVenta")] + public int IdVenVenta { get; set; } + + [JsonPropertyName("idProProducto")] + public int IdProProducto { get; set; } + + [JsonPropertyName("strNombreProducto")] + public string? StrNombreProducto { get; set; } + + [JsonPropertyName("decPrecio")] + public decimal DecPrecio { get; set; } + + [JsonPropertyName("intPiezaVenta")] + public int IntPiezasVenta { get; set; } + + [JsonPropertyName("decTotalVenta")] + public decimal DecTotalVenta { get; set; } + + [JsonPropertyName("rowVersion")] + public byte[]? RowVersion { get; set; } +} diff --git a/WebDevSecOps/Models/VentaProductosViewModel.cs b/WebDevSecOps/Models/VentaProductosViewModel.cs new file mode 100644 index 0000000..72eb48f --- /dev/null +++ b/WebDevSecOps/Models/VentaProductosViewModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace WebDevSecOps.Models; + +public class VentaProductosViewModel +{ + public int Id { get; set; } + + [Display(Name = "Fecha y Hora")] + public DateTime? DteFechaHoraCompra { get; set; } + + [Display(Name = "Clave Venta")] + public string? StrClaveVenta { get; set; } + + [Display(Name = "Cliente")] + public string? StrNombreCliente { get; set; } + + [Display(Name = "Usuario")] + public string? StrNombreUsuario { get; set; } + + [Display(Name = "Estado")] + public int IdVenCatEstado { get; set; } + + public byte[]? RowVersion { get; set; } + + public List Detalles { get; set; } = []; +} diff --git a/WebDevSecOps/Services/IProductoService.cs b/WebDevSecOps/Services/IProductoService.cs index 6c2dbdb..4780f8e 100644 --- a/WebDevSecOps/Services/IProductoService.cs +++ b/WebDevSecOps/Services/IProductoService.cs @@ -15,4 +15,6 @@ public interface IProductoService Task UpdateProductoAsync(int id, ProductoUpdateViewModel model, CancellationToken ct = default); Task DeleteProductoAsync(int id, byte[] rowVersion, CancellationToken ct = default); + + Task> AutocompleteProductosAsync(string texto, int maxResultados = 10, CancellationToken ct = default); } diff --git a/WebDevSecOps/Services/IVentaService.cs b/WebDevSecOps/Services/IVentaService.cs index f7f78fc..5414c67 100644 --- a/WebDevSecOps/Services/IVentaService.cs +++ b/WebDevSecOps/Services/IVentaService.cs @@ -9,4 +9,14 @@ public interface IVentaService Task?> SearchVentasAsync(string? texto, DateTime? dteFechaInicio, DateTime? dteFechaFin, int pageNumber = 1, int pageSize = 10, CancellationToken ct = default); Task CreateVentaAsync(VentaCreateViewModel model, CancellationToken ct = default); + + Task GetVentaByIdAsync(int id, CancellationToken ct = default); + + Task> GetVentaDetallesAsync(int idVenVenta, CancellationToken ct = default); + + Task CreateVentaDetalleAsync(VentaAgregarProductoViewModel model, CancellationToken ct = default); + + Task DeleteVentaDetalleAsync(int id, byte[] rowVersion, CancellationToken ct = default); + + Task UpdateVentaAsync(int id, Venta model, CancellationToken ct = default); } diff --git a/WebDevSecOps/Services/ProductoService.cs b/WebDevSecOps/Services/ProductoService.cs index bd40e16..ad8e3b0 100644 --- a/WebDevSecOps/Services/ProductoService.cs +++ b/WebDevSecOps/Services/ProductoService.cs @@ -268,6 +268,44 @@ public async Task UpdateProductoAsync(int id, ProductoUpdateVie } } + public async Task> AutocompleteProductosAsync(string texto, int maxResultados = 10, CancellationToken ct = default) + { + var token = GetToken(); + + if (token is null) + { + _logger.LogWarning("No access token found"); + return []; + } + + try + { + var encodedTexto = Uri.EscapeDataString(texto); + using var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/ventadetalle/autocomplete?texto={encodedTexto}&maxResultados={maxResultados}"); + 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, "Autocomplete productos request was cancelled"); + throw new OperationCanceledException("The autocomplete productos request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error autocomplete productos"); + return []; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error autocomplete productos"); + return []; + } + } + public async Task DeleteProductoAsync(int id, byte[] rowVersion, CancellationToken ct = default) { var token = GetToken(); diff --git a/WebDevSecOps/Services/VentaService.cs b/WebDevSecOps/Services/VentaService.cs index 4271342..3448379 100644 --- a/WebDevSecOps/Services/VentaService.cs +++ b/WebDevSecOps/Services/VentaService.cs @@ -172,4 +172,266 @@ public async Task CreateVentaAsync(VentaCreateViewModel model, return OperationResult.Fail("Error inesperado. Intente nuevamente."); } } + + public async Task GetVentaByIdAsync(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/Venta/{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("Venta with id {Id} not found", id); + return null; + } + + _logger.LogWarning("Get venta by id failed with status {StatusCode}", response.StatusCode); + return null; + } + catch (OperationCanceledException ex) + { + _logger.LogWarning(ex, "Get venta by id request was cancelled"); + throw new OperationCanceledException("The get venta by id request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error fetching venta {Id}", id); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error fetching venta {Id}", id); + return null; + } + } + + public async Task> GetVentaDetallesAsync(int idVenVenta, CancellationToken ct = default) + { + var token = GetToken(); + + if (token is null) + { + _logger.LogWarning("No access token found"); + return []; + } + + try + { + _logger.LogWarning("GetVentaDetalle requesting idVenVenta={IdVenVenta}", idVenVenta); + using var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/ventadetalle?idVenVenta={idVenVenta}"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var rawJson = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning("GetVentaDetalle raw JSON: {Body}", rawJson); + + var paginated = await response.Content.ReadFromJsonAsync>(cancellationToken: ct); + return paginated?.Items?.Where(d => d.IdVenVenta == idVenVenta).ToList() ?? []; + } + catch (OperationCanceledException ex) + { + _logger.LogWarning(ex, "Get venta detalles request was cancelled"); + throw new OperationCanceledException("The get venta detalles request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error fetching venta detalles"); + return []; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error fetching venta detalles"); + return []; + } + } + + public async Task CreateVentaDetalleAsync(VentaAgregarProductoViewModel model, CancellationToken ct = default) + { + var token = GetToken(); + + if (token is null) + { + _logger.LogWarning("No access token found"); + return OperationResult.Fail("Error de autenticación. Inicie sesión nuevamente."); + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "api/v1/ventadetalle") + { + 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("VentaDetalle created successfully for Venta {Id}", model.IdVenVenta); + return OperationResult.Ok(); + } + + await SafeResponseLogger.LogResponseFailure(_logger, response, "CreateVentaDetalle", ct: ct); + + var rawBody = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning("CreateVentaDetalle raw body: {Body}", rawBody); + 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 agregar el producto."; + _logger.LogWarning("CreateVentaDetalle failed: {Message}", message); + return OperationResult.Fail(message); + } + catch (OperationCanceledException ex) + { + _logger.LogWarning(ex, "Create venta detalle request was cancelled"); + throw new OperationCanceledException("The create venta detalle request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error while creating venta detalle"); + return OperationResult.Fail("Error de conexion con el servidor. Verifique su conexion e intente nuevamente."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error creating venta detalle"); + return OperationResult.Fail("Error inesperado. Intente nuevamente."); + } + } + + public async Task UpdateVentaAsync(int id, Venta model, CancellationToken ct = default) + { + var token = GetToken(); + + if (token is null) + { + _logger.LogWarning("No access token found"); + return OperationResult.Fail("Error de autenticación. Inicie sesión nuevamente."); + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Put, $"api/v1/Venta/{id}") + { + 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("Venta {Id} updated successfully", id); + return OperationResult.Ok(); + } + + await SafeResponseLogger.LogResponseFailure(_logger, response, "UpdateVenta", id, ct); + + var rawBody = await response.Content.ReadAsStringAsync(ct); + + _logger.LogWarning("UpdateVenta {Id} response body: {Body}", id, rawBody); + + if (response.StatusCode == System.Net.HttpStatusCode.Conflict) + return OperationResult.Fail("El registro fue modificado por otro usuario. Recargue la pagina e intente nuevamente."); + + 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 actualizar la venta."; + return OperationResult.Fail(message); + } + catch (OperationCanceledException ex) + { + _logger.LogWarning(ex, "Update venta request was cancelled"); + throw new OperationCanceledException("The update venta request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error while updating venta {Id}", id); + return OperationResult.Fail("Error de conexion con el servidor. Verifique su conexion e intente nuevamente."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error updating venta {Id}", id); + return OperationResult.Fail("Error inesperado. Intente nuevamente."); + } + } + + public async Task DeleteVentaDetalleAsync(int id, byte[] rowVersion, CancellationToken ct = default) + { + var token = GetToken(); + + if (token is null) + { + _logger.LogWarning("No access token found"); + return OperationResult.Fail("Error de autenticación. Inicie sesión nuevamente."); + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Delete, $"api/v1/ventadetalle/{id}") + { + Content = JsonContent.Create(new { id, rowVersion }) + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, ct); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("VentaDetalle {Id} deleted successfully", id); + return OperationResult.Ok(); + } + + await SafeResponseLogger.LogResponseFailure(_logger, response, "DeleteVentaDetalle", id, ct); + + var rawBody = await response.Content.ReadAsStringAsync(ct); + + if (response.StatusCode == System.Net.HttpStatusCode.Conflict) + return OperationResult.Fail("El registro fue modificado por otro usuario. Recargue la pagina e intente nuevamente."); + + 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 eliminar el producto."; + return OperationResult.Fail(message); + } + catch (OperationCanceledException ex) + { + _logger.LogWarning(ex, "Delete venta detalle request was cancelled"); + throw new OperationCanceledException("The delete venta detalle request was cancelled.", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Connection error while deleting venta detalle {Id}", id); + return OperationResult.Fail("Error de conexion con el servidor. Verifique su conexion e intente nuevamente."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error deleting venta detalle {Id}", id); + return OperationResult.Fail("Error inesperado. Intente nuevamente."); + } + } } diff --git a/WebDevSecOps/Views/Venta/Index.cshtml b/WebDevSecOps/Views/Venta/Index.cshtml index 3a6b6c3..3323e91 100644 --- a/WebDevSecOps/Views/Venta/Index.cshtml +++ b/WebDevSecOps/Views/Venta/Index.cshtml @@ -45,6 +45,7 @@ Clave Venta Nombre Cliente Estado Venta + Acciones @@ -62,6 +63,11 @@ } } @estadoValor + + + Producto + + } diff --git a/WebDevSecOps/Views/Venta/Productos.cshtml b/WebDevSecOps/Views/Venta/Productos.cshtml new file mode 100644 index 0000000..394ea78 --- /dev/null +++ b/WebDevSecOps/Views/Venta/Productos.cshtml @@ -0,0 +1,306 @@ +@model VentaProductosViewModel +@{ + ViewData["Title"] = "Productos de Venta: " + Model.StrClaveVenta; + var estadoDict = ViewBag.EstadoVentaDict as Dictionary ?? []; +} + +
+

Productos de Venta: @Model.StrClaveVenta

+ + @* ===== Seccion 1: Datos de la Venta (solo lectura) ===== *@ +
+
Datos de la Venta
+
+
+
+ +

@Model.DteFechaHoraCompra?.ToString("dd/MM/yyyy HH:mm")

+
+
+ +

@Model.StrClaveVenta

+
+
+ +

@Model.StrNombreCliente

+
+
+ +

@Model.StrNombreUsuario

+
+
+
+
+ + @* ===== Seccion 2: Estado + Agregar Producto ===== *@ +
+
Estado y Productos
+
+
+
+ + +
+
+ +
+ +
+ @Html.AntiForgeryToken() + + +
+ + + + + +
+ +
+ + +
+ +
+ +
+
+
+
+ + @* ===== Seccion 3: Tabla de VentaDetalle ===== *@ +
+
Productos Agregados
+
+ @if (Model.Detalles is null || Model.Detalles.Count == 0) + { +
No se han agregado productos a esta venta.
+ } + else + { +
+ + + + + + + + + + + @foreach (var detalle in Model.Detalles) + { + + + + + + + } + +
ProductoPiezasTotalAcciones
@detalle.StrNombreProducto@detalle.IntPiezasVenta@detalle.DecTotalVenta.ToString("C") + +
+
+ Total $: @Model.Detalles.Sum(d => d.DecTotalVenta).ToString("C") +
+ @Html.AntiForgeryToken() +
+ } +
+
+ + @* ===== Seccion 4: Boton Guardar ===== *@ +
+ @Html.AntiForgeryToken() + + + + +
+ Cancelar + +
+
+
+ +@section Scripts { + + +} diff --git a/WebDevSecOps/appsettings.json b/WebDevSecOps/appsettings.json index a7fb3b8..cb9f14b 100644 --- a/WebDevSecOps/appsettings.json +++ b/WebDevSecOps/appsettings.json @@ -7,8 +7,8 @@ }, "AllowedHosts": "localhost; https://localhost:7227/", "ApiSettings": { - "BaseUrl": "https://localhost:7227/" - } + "BaseUrl": "https://localhost:7227/" + } } diff --git a/tests/WebDevSecOps.IntegrationTests/Services/VentaApiClientTests.cs b/tests/WebDevSecOps.IntegrationTests/Services/VentaApiClientTests.cs index 4d2912f..33ae688 100644 --- a/tests/WebDevSecOps.IntegrationTests/Services/VentaApiClientTests.cs +++ b/tests/WebDevSecOps.IntegrationTests/Services/VentaApiClientTests.cs @@ -12,6 +12,8 @@ namespace WebDevSecOps.IntegrationTests.Services; public class VentaApiClientTests { + private static readonly byte[] _rowVersion = [0x01, 0x02, 0x03, 0x04]; + private (VentaService Service, MockHttpMessageHandler Handler) CreateServiceWithToken( object? responseContent = null, HttpStatusCode? statusCode = null) { @@ -179,4 +181,48 @@ public async Task GetVentas_ShouldReturnNull_WhenMissingToken() Assert.Null(result); } + + [Fact] + public async Task DeleteVentaDetalle_ShouldDeleteCorrectUrl() + { + var (service, handler) = CreateServiceWithToken(null, HttpStatusCode.OK); + + await service.DeleteVentaDetalleAsync(1, _rowVersion); + + Assert.Equal(HttpMethod.Delete, handler.LastRequest?.Method); + Assert.Equal("api/v1/ventadetalle/1", handler.LastRequest?.RequestUri?.PathAndQuery.TrimStart('/')); + } + + [Fact] + public async Task DeleteVentaDetalle_ShouldReturnOk_WhenApiSucceeds() + { + var (service, _) = CreateServiceWithToken(null, HttpStatusCode.OK); + + var result = await service.DeleteVentaDetalleAsync(1, _rowVersion); + + Assert.True(result.Success); + } + + [Fact] + public async Task DeleteVentaDetalle_ShouldReturnFail_WhenMissingToken() + { + var (service, _) = CreateServiceWithoutToken(); + + var result = await service.DeleteVentaDetalleAsync(1, _rowVersion); + + Assert.False(result.Success); + Assert.Equal(ContractTestData.MissingTokenMessage, result.ErrorMessage); + } + + [Fact] + public async Task DeleteVentaDetalle_ShouldSendRequestBody() + { + var (service, handler) = CreateServiceWithToken(null, HttpStatusCode.OK); + + await service.DeleteVentaDetalleAsync(1, _rowVersion); + + Assert.NotNull(handler.LastRequestBody); + var json = JsonDocument.Parse(handler.LastRequestBody); + Assert.Equal(1, json.RootElement.GetProperty("id").GetInt32()); + } } diff --git a/tests/WebDevSecOps.SecurityTests/SAST/VentaSecurityTests.cs b/tests/WebDevSecOps.SecurityTests/SAST/VentaSecurityTests.cs index 8af8e65..27d29da 100644 --- a/tests/WebDevSecOps.SecurityTests/SAST/VentaSecurityTests.cs +++ b/tests/WebDevSecOps.SecurityTests/SAST/VentaSecurityTests.cs @@ -206,4 +206,21 @@ public async Task UsuariosAutocomplete_RequiresAuthentication() Assert.Equal((int)HttpStatusCode.Redirect, status); } } + + [Fact] + public async Task VentaEliminarProducto_RequiresAuthentication() + { + var (factory, client) = CreateUnauthenticatedClient(); + using (factory) + using (client) + { + var form = SecurityTestHelpers.ToFormPayload(new Dictionary + { + { "id", "1" }, + { "rowVersion", "AAAA" }, + }); + var (status, _) = await SecurityTestHelpers.PostAsync(client, "/Venta/EliminarProducto", form); + Assert.Equal((int)HttpStatusCode.Redirect, status); + } + } } diff --git a/tests/WebDevSecOps.UnitTests/Common/ContractTestData.cs b/tests/WebDevSecOps.UnitTests/Common/ContractTestData.cs index bdbfdd4..585f3c1 100644 --- a/tests/WebDevSecOps.UnitTests/Common/ContractTestData.cs +++ b/tests/WebDevSecOps.UnitTests/Common/ContractTestData.cs @@ -524,6 +524,69 @@ public static class ContractTestData } }; + // ======================================================================== + // VentaDetalle domain model + // ======================================================================== + + public static VentaDetalle ValidVentaDetalle => new() + { + Id = 1, + IdVenVenta = 1, + IdProProducto = 1, + StrNombreProducto = "Laptop Gamer", + DecPrecio = 15000.50m, + IntPiezasVenta = 2, + DecTotalVenta = 30001.00m + }; + + public static List ValidVentaDetalleList => new() + { + ValidVentaDetalle, + new VentaDetalle + { + Id = 2, + IdVenVenta = 1, + IdProProducto = 2, + StrNombreProducto = "Mouse Inalambrico", + DecPrecio = 500.00m, + IntPiezasVenta = 3, + DecTotalVenta = 1500.00m + } + }; + + // ======================================================================== + // VentaAgregarProductoViewModel + // ======================================================================== + + public static VentaAgregarProductoViewModel ValidVentaAgregarProductoViewModel => new() + { + IdVenVenta = 1, + IdProProducto = 1, + IntPiezasVenta = 2 + }; + + public static VentaAgregarProductoViewModel InvalidVentaAgregarProductoViewModel => new() + { + IdVenVenta = 0, + IdProProducto = 0, + IntPiezasVenta = 0 + }; + + // ======================================================================== + // ProProductoAutocompleteDto + // ======================================================================== + + public static List ValidProductoAutocompleteList => new() + { + new ProProductoAutocompleteDto { Id = 1, StrTextoAutocomplete = "Laptop Gamer | #: 10 | $: 15000.50" }, + new ProProductoAutocompleteDto { Id = 2, StrTextoAutocomplete = "Mouse Inalambrico | #: 5 | $: 500.00" } + }; + + public static List ProductoAutocompleteListSinExistencia => new() + { + new ProProductoAutocompleteDto { Id = 3, StrTextoAutocomplete = "Teclado Mecanico | #: 0 | $: 800.00" } + }; + public const string TestToken = "eyJhbGciOiJIUzI1NiJ9.dGVzdC1kYXRhLW5vdC1hLXJlYWwtc2VjcmV0"; public const string ConflictMessage = "El registro fue modificado por otro usuario. Recargue la p\u00e1gina e intente nuevamente."; public const string MissingTokenMessage = "Error de autenticaci\u00f3n. Inicie sesi\u00f3n nuevamente."; diff --git a/tests/WebDevSecOps.UnitTests/Pages/Ventas/CreateTests.cs b/tests/WebDevSecOps.UnitTests/Pages/Ventas/CreateTests.cs index 862bb78..2bcd853 100644 --- a/tests/WebDevSecOps.UnitTests/Pages/Ventas/CreateTests.cs +++ b/tests/WebDevSecOps.UnitTests/Pages/Ventas/CreateTests.cs @@ -12,14 +12,15 @@ namespace WebDevSecOps.UnitTests; public class VentaCreateTests { - private static (VentaController Controller, Mock ServiceMock, Mock EstadoMock, Mock ClienteMock, Mock UsuarioMock, Mock> LoggerMock) CreateController() + private static (VentaController Controller, Mock ServiceMock, Mock EstadoMock, Mock ClienteMock, Mock UsuarioMock, Mock ProductoMock, Mock> LoggerMock) CreateController() { var serviceMock = new Mock(); var estadoMock = new Mock(); var clienteMock = new Mock(); var usuarioMock = new Mock(); + var productoMock = new Mock(); var loggerMock = new Mock>(); - var controller = new VentaController(serviceMock.Object, estadoMock.Object, clienteMock.Object, usuarioMock.Object, loggerMock.Object); + var controller = new VentaController(serviceMock.Object, estadoMock.Object, clienteMock.Object, usuarioMock.Object, productoMock.Object, loggerMock.Object); controller.ControllerContext = new ControllerContext { @@ -29,13 +30,13 @@ private static (VentaController Controller, Mock ServiceMock, Moc new DefaultHttpContext(), Mock.Of()); - return (controller, serviceMock, estadoMock, clienteMock, usuarioMock, loggerMock); + return (controller, serviceMock, estadoMock, clienteMock, usuarioMock, productoMock, loggerMock); } [Fact] public async Task Create_Get_ReturnsView() { - var (controller, _, _, _, _, _) = CreateController(); + var (controller, _, _, _, _, _, _) = CreateController(); var result = await controller.Create(); @@ -46,7 +47,7 @@ public async Task Create_Get_ReturnsView() [Fact] public async Task Create_Post_ReturnsRedirectToIndex_WhenSuccess() { - var (controller, serviceMock, _, _, _, _) = CreateController(); + var (controller, serviceMock, _, _, _, _, _) = CreateController(); serviceMock .Setup(x => x.CreateVentaAsync(It.IsAny(), It.IsAny())) @@ -62,7 +63,7 @@ public async Task Create_Post_ReturnsRedirectToIndex_WhenSuccess() [Fact] public async Task Create_Post_ReturnsViewWithModel_WhenModelInvalid() { - var (controller, _, _, _, _, _) = CreateController(); + var (controller, _, _, _, _, _, _) = CreateController(); controller.ModelState.AddModelError("IdCliCliente", "Required"); @@ -75,7 +76,7 @@ public async Task Create_Post_ReturnsViewWithModel_WhenModelInvalid() [Fact] public async Task Create_Post_ReturnsViewWithModel_WhenServiceFails() { - var (controller, serviceMock, _, _, _, _) = CreateController(); + var (controller, serviceMock, _, _, _, _, _) = CreateController(); serviceMock .Setup(x => x.CreateVentaAsync(It.IsAny(), It.IsAny())) @@ -90,7 +91,7 @@ public async Task Create_Post_ReturnsViewWithModel_WhenServiceFails() [Fact] public async Task Create_Post_MapsFieldErrors_WhenServiceReturnsFieldErrors() { - var (controller, serviceMock, _, _, _, _) = CreateController(); + var (controller, serviceMock, _, _, _, _, _) = CreateController(); var fieldErrors = new Dictionary { @@ -112,7 +113,7 @@ public async Task Create_Post_MapsFieldErrors_WhenServiceReturnsFieldErrors() [Fact] public async Task ClientesAutocomplete_ReturnsEmpty_WhenTextoLessThan2() { - var (controller, _, _, _, _, _) = CreateController(); + var (controller, _, _, _, _, _, _) = CreateController(); var result = await controller.ClientesAutocomplete("a"); @@ -124,7 +125,7 @@ public async Task ClientesAutocomplete_ReturnsEmpty_WhenTextoLessThan2() [Fact] public async Task ClientesAutocomplete_ReturnsData_WhenTextoIsValid() { - var (controller, _, _, clienteMock, _, _) = CreateController(); + var (controller, _, _, clienteMock, _, _, _) = CreateController(); clienteMock .Setup(x => x.AutocompleteClientesAsync("Maria", 10, It.IsAny())) @@ -141,7 +142,7 @@ public async Task ClientesAutocomplete_ReturnsData_WhenTextoIsValid() [Fact] public async Task UsuariosAutocomplete_ReturnsEmpty_WhenTextoLessThan2() { - var (controller, _, _, _, _, _) = CreateController(); + var (controller, _, _, _, _, _, _) = CreateController(); var result = await controller.UsuariosAutocomplete("b"); @@ -153,7 +154,7 @@ public async Task UsuariosAutocomplete_ReturnsEmpty_WhenTextoLessThan2() [Fact] public async Task UsuariosAutocomplete_ReturnsData_WhenTextoIsValid() { - var (controller, _, _, _, usuarioMock, _) = CreateController(); + var (controller, _, _, _, usuarioMock, _, _) = CreateController(); usuarioMock .Setup(x => x.AutocompleteUsuariosAsync("admin", 10, It.IsAny())) @@ -170,7 +171,7 @@ public async Task UsuariosAutocomplete_ReturnsData_WhenTextoIsValid() [Fact] public async Task Create_Post_ReturnsViewWithGeneralError_WhenServiceErrorMessage() { - var (controller, serviceMock, _, _, _, _) = CreateController(); + var (controller, serviceMock, _, _, _, _, _) = CreateController(); serviceMock .Setup(x => x.CreateVentaAsync(It.IsAny(), It.IsAny())) diff --git a/tests/WebDevSecOps.UnitTests/Pages/Ventas/IndexTests.cs b/tests/WebDevSecOps.UnitTests/Pages/Ventas/IndexTests.cs index 9e6650d..9c0d78d 100644 --- a/tests/WebDevSecOps.UnitTests/Pages/Ventas/IndexTests.cs +++ b/tests/WebDevSecOps.UnitTests/Pages/Ventas/IndexTests.cs @@ -18,8 +18,9 @@ private static (VentaController Controller, Mock ServiceMock, Moc var estadoMock = new Mock(); var clienteMock = new Mock(); var usuarioMock = new Mock(); + var productoMock = new Mock(); var loggerMock = new Mock>(); - var controller = new VentaController(serviceMock.Object, estadoMock.Object, clienteMock.Object, usuarioMock.Object, loggerMock.Object); + var controller = new VentaController(serviceMock.Object, estadoMock.Object, clienteMock.Object, usuarioMock.Object, productoMock.Object, loggerMock.Object); controller.ControllerContext = new ControllerContext { From 7aa032b873d46d680a0436a16ab8a2038c49a810 Mon Sep 17 00:00:00 2001 From: edelomeza Date: Thu, 16 Jul 2026 13:31:59 -0600 Subject: [PATCH 2/3] docs: update README with all CRUD modules and API endpoints --- README.md | 144 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 122 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b886461..cf12b21 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **Proyecto Web desarrollado tomando en consideración buenas prácticas de DevSecOps** -Aplicación ASP.NET Core (BFF Pattern) para gestión de usuarios con integración continua -de seguridad en cada etapa del ciclo de desarrollo: análisis estático (SAST), análisis -de dependencias (SCA), análisis dinámico (DAST), escaneo de secretos y contenedores. +Aplicación ASP.NET Core (BFF Pattern) con integración continua de seguridad en cada +etapa del ciclo de desarrollo: análisis estático (SAST), análisis de dependencias (SCA), +análisis dinámico (DAST), escaneo de secretos y contenedores. --- @@ -12,8 +12,14 @@ de dependencias (SCA), análisis dinámico (DAST), escaneo de secretos y contene - **Autenticación segura** — Cookie-based con BFF pattern (token almacenado server-side) - **CRUD de usuarios** — Crear, leer, actualizar y eliminar con paginación +- **CRUD de empleados** — Con catálogo TipoEmpleado, búsqueda por texto y filtro por tipo +- **CRUD de clientes** — Con autocomplete para selección en ventas +- **CRUD de productos** — Con autocomplete y validación de existencia (stock) +- **CRUD de ventas con detalle (VentaDetalle)** — Gestión de ventas, asignación de productos con autocomplete, validación de stock, eliminación AJAX, actualización de estado +- **Autocomplete JSON endpoints** — Clientes, Usuarios y Productos con búsqueda asíncrona +- **Catálogos read-only** — TipoEmpleado, EstadoVenta como servicios HttpClient independientes - **Seguridad por capas** — CSP, HSTS, anti-forgery, rate limiting en login, OWASP headers -- **API mockeada** — WireMock para desarrollo y pruebas sin backend real +- **API remota** — Consume API REST externa con Polly resilience (retry, timeout, circuit breaker) - **Pipeline DevSecOps** — CI/CD automatizado con análisis de seguridad en cada push - **Optimistic concurrency** — Control de concurrencia vía RowVersion @@ -63,23 +69,39 @@ dotnet test ## Ejecución -### Con Docker Compose (recomendado) +La aplicación consume una API REST externa. Configurar la URL base en `appsettings.json`: + +```json +"ApiSettings": { + "BaseUrl": "https://webapidevopsproject-h5fn4.ondigitalocean.app/" +} +``` + +### Con Docker Compose ```bash -docker compose up webapp mock-api +docker compose up webapp ``` Acceder a la aplicación en `http://localhost`. ### Sin Docker +```bash +cd WebDevSecOps +dotnet run --launch-profile https +``` + +### Desarrollo con API mock (WireMock) + ```bash # Terminal 1 - Iniciar mock API docker run -p 7227:7227 -v /ruta/a/mappings:/home/wiremock/mappings \ wiremock/wiremock:latest --port 7227 -# Terminal 2 - Iniciar web app +# Terminal 2 - Iniciar web app (apuntando a localhost) cd WebDevSecOps +# Cambiar BaseUrl en appsettings.json a "https://localhost:7227/" dotnet run --launch-profile https ``` @@ -128,25 +150,103 @@ para las instrucciones de reporte responsable. **No abras issues públicos.** ``` WebDevSecOps/ -├── .github/ # CI/CD, dependabot, security policy -├── WebDevSecOps/ # Aplicación principal -│ ├── Controllers/ # Controladores MVC -│ ├── Models/ # Modelos y ViewModels -│ ├── Pages/ # Razor Pages (Login, Home, etc.) -│ ├── Services/ # Servicios de negocio y API Client -│ ├── Views/ # Vistas del CRUD de usuarios -│ └── wwwroot/ # Estáticos (CSS, JS, libs) -├── tests/ # Proyectos de prueba -│ ├── UnitTests/ -│ ├── IntegrationTests/ -│ ├── SecurityTests/ -│ └── E2E/ -├── docker/ # Configuración de contenedores -└── docker-compose.yml +├── .github/ # CI/CD, dependabot, security policy +├── WebDevSecOps/ # Aplicación principal +│ ├── Controllers/ # 5 controladores MVC +│ │ ├── UsuarioController.cs +│ │ ├── EmpleadoController.cs +│ │ ├── ClienteController.cs +│ │ ├── ProductoController.cs +│ │ └── VentaController.cs +│ ├── Models/ # Modelos, ViewModels y DTOs +│ │ ├── Usuario/ # Usuario + 5 VMs + AutocompleteDto +│ │ ├── Empleado/ # Empleado + 4 VMs + EmpCatTipoEmpleado +│ │ ├── Cliente/ # Cliente + 4 VMs + AutocompleteDto +│ │ ├── Producto/ # Producto + 4 VMs + AutocompleteDto +│ │ ├── Venta/ # Venta, VentaDetalle, VenCatEstado + 3 VMs +│ │ ├── ApiErrorResponse, OperationResult, PaginatedResponse +│ │ └── Auth (LoginRequest, LoginResponse) +│ ├── Pages/ # Razor Pages (Login, Home, Layout) +│ ├── Services/ # 8 pares interfaz/implementación + TokenStore +│ │ ├── AuthService # Login/logout con BFF pattern +│ │ ├── UsuarioService # CRUD usuarios +│ │ ├── EmpleadoService # CRUD empleados + búsqueda +│ │ ├── ClienteService # CRUD clientes + autocomplete +│ │ ├── ProductoService # CRUD productos + autocomplete + stock +│ │ ├── VentaService # CRUD ventas + VentaDetalle +│ │ ├── EstadoVentaService# Catálogo read-only +│ │ └── TipoEmpleadoService# Catálogo read-only +│ ├── Views/ # 5 módulos con 4-5 vistas cada uno +│ │ ├── Usuario/ # Index, Create, Details, Update, Delete +│ │ ├── Empleado/ # Index, Create, Details, Update, Delete +│ │ ├── Cliente/ # Index, Create, Details, Update, Delete +│ │ ├── Producto/ # Index, Create, Details, Update, Delete +│ │ ├── Venta/ # Index, Create, Productos +│ │ └── Shared/ # _Layout.cshtml, _ValidationScriptsPartial +│ └── wwwroot/ # Estáticos (CSS, JS, libs) +├── tests/ +│ ├── WebDevSecOps.UnitTests/ +│ │ ├── Pages/ # Tests por módulo (Empleados, Login, Productos, +│ │ │ # Usuarios, Ventas) +│ │ └── Common/ # TestData, MockHttpMessageHandler, TestConstants +│ ├── WebDevSecOps.IntegrationTests/ +│ │ └── Services/ # Tests de API Client por servicio +│ ├── WebDevSecOps.SecurityTests/ +│ │ ├── SAST/ # Pruebas de seguridad estáticas +│ │ ├── DAST/ # Pruebas dinámicas +│ │ ├── SCA/ # Análisis de dependencias +│ │ └── SecretScanning/ # Escaneo de secretos +│ ├── WebDevSecOps.E2E/ +│ └── MemoriaPruebas.md # Lecciones aprendidas en testing +├── docker/ # Configuración de contenedores +├── docker-compose.yml +└── AGENTS.md # Memoria del proyecto (convenciones, comandos) ``` --- +## Módulos CRUD + +| Módulo | Controller | Servicios | Vistas | Tests | +|--------|-----------|-----------|--------|-------| +| **Usuario** | `UsuarioController.cs` | `IUsuarioService` | 5 vistas | Unit, Integration, Security | +| **Empleado** | `EmpleadoController.cs` | `IEmpleadoService`, `ITipoEmpleadoService` | 5 vistas | Unit, Integration, Security | +| **Cliente** | `ClienteController.cs` | `IClienteService` | 5 vistas + Memoria.md | — | +| **Producto** | `ProductoController.cs` | `IProductoService` | 5 vistas | Unit, Integration, Security | +| **Venta** | `VentaController.cs` | `IVentaService`, `IEstadoVentaService` | 3 vistas + Productos + Memoria.md | Unit, Integration, Security | + +### Endpoints API consumidos + +| Módulo | Endpoint | Método | +|--------|----------|--------| +| **Auth** | `/api/v1/Login/login` | POST | +| **Usuario** | `/api/v1/Usuario` | GET, POST | +| | `/api/v1/Usuario/{id}` | GET, PUT, DELETE | +| | `/api/v1/Usuario/buscar?texto=&strValor=&pageNumber=&pageSize=` | GET | +| **Empleado** | `/api/v1/Empleado` | GET, POST | +| | `/api/v1/Empleado/{id}` | GET, PUT, DELETE | +| | `/api/v1/Empleado/buscar?texto=&idTipoEmpleado=` | GET | +| | `/api/v1/TipoEmpleado` | GET | +| | `/api/v1/TipoEmpleado/{id}` | GET | +| **Cliente** | `/api/v1/Cliente` | GET, POST | +| | `/api/v1/Cliente/{id}` | GET, PUT, DELETE | +| | `/api/v1/Cliente/buscar?texto=` | GET | +| | `/api/v1/Cliente/autocomplete?texto=` | GET | +| **Producto** | `/api/v1/Producto` | GET, POST | +| | `/api/v1/Producto/{id}` | GET, PUT, DELETE | +| | `/api/v1/Producto/buscar?texto=` | GET | +| **Venta** | `/api/v1/Venta` | GET, POST | +| | `/api/v1/Venta/{id}` | GET, PUT | +| | `/api/v1/Venta/buscar?strClaveVenta=&strNombreCliente=&dteFechaInicio=&dteFechaFin=` | GET | +| | `/api/v1/ventadetalle?idVenVenta=` | GET | +| | `/api/v1/ventadetalle` | POST | +| | `/api/v1/ventadetalle/{id}` | DELETE | +| | `/api/v1/ventadetalle/autocomplete?texto=` | GET | +| | `/api/v1/EstadoVenta` | GET | +| | `/api/v1/EstadoVenta/{id}` | GET | + +--- + ## Licencia Proyecto educativo con fines de demostración DevSecOps. From 4cd905fbee9e58d2775f2cd86e7df750adf88ebc Mon Sep 17 00:00:00 2001 From: edelomeza Date: Thu, 16 Jul 2026 13:34:33 -0600 Subject: [PATCH 3/3] docs: translate README to English --- README.md | 170 +++++++++++++++++++++++++++--------------------------- 1 file changed, 85 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index cf12b21..679bf82 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,38 @@ # WebDevSecOps -**Proyecto Web desarrollado tomando en consideración buenas prácticas de DevSecOps** +**Web project developed with DevSecOps best practices** -Aplicación ASP.NET Core (BFF Pattern) con integración continua de seguridad en cada -etapa del ciclo de desarrollo: análisis estático (SAST), análisis de dependencias (SCA), -análisis dinámico (DAST), escaneo de secretos y contenedores. +ASP.NET Core (BFF Pattern) application with continuous security integration +at every stage of the development lifecycle: static analysis (SAST), dependency +analysis (SCA), dynamic analysis (DAST), secret and container scanning. --- -## Características - -- **Autenticación segura** — Cookie-based con BFF pattern (token almacenado server-side) -- **CRUD de usuarios** — Crear, leer, actualizar y eliminar con paginación -- **CRUD de empleados** — Con catálogo TipoEmpleado, búsqueda por texto y filtro por tipo -- **CRUD de clientes** — Con autocomplete para selección en ventas -- **CRUD de productos** — Con autocomplete y validación de existencia (stock) -- **CRUD de ventas con detalle (VentaDetalle)** — Gestión de ventas, asignación de productos con autocomplete, validación de stock, eliminación AJAX, actualización de estado -- **Autocomplete JSON endpoints** — Clientes, Usuarios y Productos con búsqueda asíncrona -- **Catálogos read-only** — TipoEmpleado, EstadoVenta como servicios HttpClient independientes -- **Seguridad por capas** — CSP, HSTS, anti-forgery, rate limiting en login, OWASP headers -- **API remota** — Consume API REST externa con Polly resilience (retry, timeout, circuit breaker) -- **Pipeline DevSecOps** — CI/CD automatizado con análisis de seguridad en cada push -- **Optimistic concurrency** — Control de concurrencia vía RowVersion +## Features + +- **Secure Authentication** — Cookie-based with BFF pattern (server-side token storage) +- **CRUD Usuarios** — Create, read, update, delete with pagination +- **CRUD Empleados** — With TipoEmpleado catalog, text search and type filter +- **CRUD Clientes** — With autocomplete for sales selection +- **CRUD Productos** — With autocomplete and stock validation +- **CRUD Ventas with detail (VentaDetalle)** — Sales management, product assignment with autocomplete, stock validation, AJAX delete, status update +- **Autocomplete JSON endpoints** — Clients, Users and Products with async search +- **Read-only catalogs** — TipoEmpleado, EstadoVenta as independent HttpClient services +- **Layered security** — CSP, HSTS, anti-forgery, rate limiting on login, OWASP headers +- **Remote API** — Consumes external REST API with Polly resilience (retry, timeout, circuit breaker) +- **DevSecOps Pipeline** — Automated CI/CD with security analysis on every push +- **Optimistic concurrency** — Concurrency control via RowVersion --- -## Tecnologías +## Technologies -| Capa | Tecnologías | +| Layer | Technologies | |---|---| | **Backend** | ASP.NET Core 10, Razor Pages, MVC Controllers | | **Frontend** | Bootstrap 5.3.x, jQuery 3.x, Toastr | | **API Mock** | WireMock | -| **Contenedores** | Docker, Docker Compose | +| **Containers** | Docker, Docker Compose | | **Testing** | xUnit, Moq, Coverlet | | **CI/CD** | GitHub Actions | | **SAST** | SecurityCodeScan, SonarAnalyzer, CodeQL | @@ -44,7 +44,7 @@ análisis dinámico (DAST), escaneo de secretos y contenedores. --- -## Requisitos previos +## Prerequisites - .NET SDK 10.0 - Docker Desktop @@ -52,24 +52,24 @@ análisis dinámico (DAST), escaneo de secretos y contenedores. --- -## Configuración rápida +## Quick Setup ```bash -# 1. Restaurar dependencias +# 1. Restore dependencies dotnet restore -# 2. Compilar +# 2. Build dotnet build -# 3. Ejecutar pruebas +# 3. Run tests dotnet test ``` --- -## Ejecución +## Running -La aplicación consume una API REST externa. Configurar la URL base en `appsettings.json`: +The application consumes an external REST API. Configure the base URL in `appsettings.json`: ```json "ApiSettings": { @@ -77,43 +77,43 @@ La aplicación consume una API REST externa. Configurar la URL base en `appsetti } ``` -### Con Docker Compose +### With Docker Compose ```bash docker compose up webapp ``` -Acceder a la aplicación en `http://localhost`. +Access the application at `http://localhost`. -### Sin Docker +### Without Docker ```bash cd WebDevSecOps dotnet run --launch-profile https ``` -### Desarrollo con API mock (WireMock) +### Development with mock API (WireMock) ```bash -# Terminal 1 - Iniciar mock API -docker run -p 7227:7227 -v /ruta/a/mappings:/home/wiremock/mappings \ +# Terminal 1 - Start mock API +docker run -p 7227:7227 -v /path/to/mappings:/home/wiremock/mappings \ wiremock/wiremock:latest --port 7227 -# Terminal 2 - Iniciar web app (apuntando a localhost) +# Terminal 2 - Start web app (pointing to localhost) cd WebDevSecOps -# Cambiar BaseUrl en appsettings.json a "https://localhost:7227/" +# Change BaseUrl in appsettings.json to "https://localhost:7227/" dotnet run --launch-profile https ``` --- -## Pruebas +## Tests ```bash -# Todas las pruebas +# All tests dotnet test -# Proyecto específico +# Specific project dotnet test tests/WebDevSecOps.UnitTests dotnet test tests/WebDevSecOps.IntegrationTests dotnet test tests/WebDevSecOps.SecurityTests @@ -122,43 +122,43 @@ dotnet test tests/WebDevSecOps.E2E --- -## Pipeline DevSecOps +## DevSecOps Pipeline -El pipeline automatizado (GitHub Actions) ejecuta en paralelo: +The automated pipeline (GitHub Actions) runs in parallel: -| Job | Herramienta | Propósito | +| Job | Tool | Purpose | |---|---|---| -| Build + SonarCloud | `dotnet build`, SonarScanner | Compilación y calidad de código | -| CodeQL | GitHub CodeQL | Análisis de vulnerabilidades en el código | -| SAST | SecurityCodeScan, Roslyn analyzers | Reglas de seguridad estáticas | -| SCA | `dotnet list package --vulnerable`, Snyk | Vulnerabilidades en dependencias | -| Secret Scanning | Gitleaks | Credenciales y secretos hardcodeados | -| Container Scanning | Trivy | Vulnerabilidades en imagen Docker | -| DAST | OWASP ZAP | Pruebas de penetración automatizadas | -| SBOM | CycloneDX | Generación de SBOM en releases | +| Build + SonarCloud | `dotnet build`, SonarScanner | Build and code quality | +| CodeQL | GitHub CodeQL | Code vulnerability analysis | +| SAST | SecurityCodeScan, Roslyn analyzers | Static security rules | +| SCA | `dotnet list package --vulnerable`, Snyk | Dependency vulnerabilities | +| Secret Scanning | Gitleaks | Hardcoded credentials and secrets | +| Container Scanning | Trivy | Docker image vulnerabilities | +| DAST | OWASP ZAP | Automated penetration testing | +| SBOM | CycloneDX | SBOM generation in releases | --- -## Reportar vulnerabilidades +## Reporting Vulnerabilities -Si encuentras una vulnerabilidad de seguridad, por favor consulta [SECURITY.md](.github/SECURITY.md) -para las instrucciones de reporte responsable. **No abras issues públicos.** +If you find a security vulnerability, please check [SECURITY.md](.github/SECURITY.md) +for responsible disclosure instructions. **Do not open public issues.** --- -## Estructura del proyecto +## Project Structure ``` WebDevSecOps/ ├── .github/ # CI/CD, dependabot, security policy -├── WebDevSecOps/ # Aplicación principal -│ ├── Controllers/ # 5 controladores MVC +├── WebDevSecOps/ # Main application +│ ├── Controllers/ # 5 MVC controllers │ │ ├── UsuarioController.cs │ │ ├── EmpleadoController.cs │ │ ├── ClienteController.cs │ │ ├── ProductoController.cs │ │ └── VentaController.cs -│ ├── Models/ # Modelos, ViewModels y DTOs +│ ├── Models/ # Models, ViewModels and DTOs │ │ ├── Usuario/ # Usuario + 5 VMs + AutocompleteDto │ │ ├── Empleado/ # Empleado + 4 VMs + EmpCatTipoEmpleado │ │ ├── Cliente/ # Cliente + 4 VMs + AutocompleteDto @@ -167,57 +167,57 @@ WebDevSecOps/ │ │ ├── ApiErrorResponse, OperationResult, PaginatedResponse │ │ └── Auth (LoginRequest, LoginResponse) │ ├── Pages/ # Razor Pages (Login, Home, Layout) -│ ├── Services/ # 8 pares interfaz/implementación + TokenStore -│ │ ├── AuthService # Login/logout con BFF pattern +│ ├── Services/ # 8 interface/implementation pairs + TokenStore +│ │ ├── AuthService # Login/logout with BFF pattern │ │ ├── UsuarioService # CRUD usuarios -│ │ ├── EmpleadoService # CRUD empleados + búsqueda +│ │ ├── EmpleadoService # CRUD empleados + search │ │ ├── ClienteService # CRUD clientes + autocomplete │ │ ├── ProductoService # CRUD productos + autocomplete + stock │ │ ├── VentaService # CRUD ventas + VentaDetalle -│ │ ├── EstadoVentaService# Catálogo read-only -│ │ └── TipoEmpleadoService# Catálogo read-only -│ ├── Views/ # 5 módulos con 4-5 vistas cada uno +│ │ ├── EstadoVentaService# Read-only catalog +│ │ └── TipoEmpleadoService# Read-only catalog +│ ├── Views/ # 5 modules with 4-5 views each │ │ ├── Usuario/ # Index, Create, Details, Update, Delete │ │ ├── Empleado/ # Index, Create, Details, Update, Delete │ │ ├── Cliente/ # Index, Create, Details, Update, Delete │ │ ├── Producto/ # Index, Create, Details, Update, Delete │ │ ├── Venta/ # Index, Create, Productos │ │ └── Shared/ # _Layout.cshtml, _ValidationScriptsPartial -│ └── wwwroot/ # Estáticos (CSS, JS, libs) +│ └── wwwroot/ # Static files (CSS, JS, libs) ├── tests/ │ ├── WebDevSecOps.UnitTests/ -│ │ ├── Pages/ # Tests por módulo (Empleados, Login, Productos, +│ │ ├── Pages/ # Tests per module (Empleados, Login, Productos, │ │ │ # Usuarios, Ventas) │ │ └── Common/ # TestData, MockHttpMessageHandler, TestConstants │ ├── WebDevSecOps.IntegrationTests/ -│ │ └── Services/ # Tests de API Client por servicio +│ │ └── Services/ # API Client tests per service │ ├── WebDevSecOps.SecurityTests/ -│ │ ├── SAST/ # Pruebas de seguridad estáticas -│ │ ├── DAST/ # Pruebas dinámicas -│ │ ├── SCA/ # Análisis de dependencias -│ │ └── SecretScanning/ # Escaneo de secretos +│ │ ├── SAST/ # Static security tests +│ │ ├── DAST/ # Dynamic tests +│ │ ├── SCA/ # Dependency analysis +│ │ └── SecretScanning/ # Secret scanning │ ├── WebDevSecOps.E2E/ -│ └── MemoriaPruebas.md # Lecciones aprendidas en testing -├── docker/ # Configuración de contenedores +│ └── MemoriaPruebas.md # Testing lessons learned +├── docker/ # Container configuration ├── docker-compose.yml -└── AGENTS.md # Memoria del proyecto (convenciones, comandos) +└── AGENTS.md # Project memory (conventions, commands) ``` --- -## Módulos CRUD +## CRUD Modules -| Módulo | Controller | Servicios | Vistas | Tests | -|--------|-----------|-----------|--------|-------| -| **Usuario** | `UsuarioController.cs` | `IUsuarioService` | 5 vistas | Unit, Integration, Security | -| **Empleado** | `EmpleadoController.cs` | `IEmpleadoService`, `ITipoEmpleadoService` | 5 vistas | Unit, Integration, Security | -| **Cliente** | `ClienteController.cs` | `IClienteService` | 5 vistas + Memoria.md | — | -| **Producto** | `ProductoController.cs` | `IProductoService` | 5 vistas | Unit, Integration, Security | -| **Venta** | `VentaController.cs` | `IVentaService`, `IEstadoVentaService` | 3 vistas + Productos + Memoria.md | Unit, Integration, Security | +| Module | Controller | Services | Views | Tests | +|--------|-----------|----------|-------|-------| +| **Usuario** | `UsuarioController.cs` | `IUsuarioService` | 5 views | Unit, Integration, Security | +| **Empleado** | `EmpleadoController.cs` | `IEmpleadoService`, `ITipoEmpleadoService` | 5 views | Unit, Integration, Security | +| **Cliente** | `ClienteController.cs` | `IClienteService` | 5 views + Memoria.md | — | +| **Producto** | `ProductoController.cs` | `IProductoService` | 5 views | Unit, Integration, Security | +| **Venta** | `VentaController.cs` | `IVentaService`, `IEstadoVentaService` | 3 views + Productos + Memoria.md | Unit, Integration, Security | -### Endpoints API consumidos +### Consumed API Endpoints -| Módulo | Endpoint | Método | +| Module | Endpoint | Method | |--------|----------|--------| | **Auth** | `/api/v1/Login/login` | POST | | **Usuario** | `/api/v1/Usuario` | GET, POST | @@ -247,6 +247,6 @@ WebDevSecOps/ --- -## Licencia +## License -Proyecto educativo con fines de demostración DevSecOps. +Educational project for DevSecOps demonstration purposes.