diff --git a/README.md b/README.md index b886461..679bf82 100644 --- a/README.md +++ b/README.md @@ -1,32 +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) 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. +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 -- **Seguridad por capas** — CSP, HSTS, anti-forgery, rate limiting en login, OWASP headers -- **API mockeada** — WireMock para desarrollo y pruebas sin backend real -- **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 | @@ -38,7 +44,7 @@ de dependencias (SCA), análisis dinámico (DAST), escaneo de secretos y contene --- -## Requisitos previos +## Prerequisites - .NET SDK 10.0 - Docker Desktop @@ -46,52 +52,68 @@ de dependencias (SCA), análisis dinámico (DAST), escaneo de secretos y contene --- -## 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 + +The application consumes an external REST API. Configure the base URL in `appsettings.json`: + +```json +"ApiSettings": { + "BaseUrl": "https://webapidevopsproject-h5fn4.ondigitalocean.app/" +} +``` -### Con Docker Compose (recomendado) +### With Docker Compose ```bash -docker compose up webapp mock-api +docker compose up webapp ``` -Acceder a la aplicación en `http://localhost`. +Access the application at `http://localhost`. -### Sin Docker +### Without Docker ```bash -# Terminal 1 - Iniciar mock API -docker run -p 7227:7227 -v /ruta/a/mappings:/home/wiremock/mappings \ +cd WebDevSecOps +dotnet run --launch-profile https +``` + +### Development with mock API (WireMock) + +```bash +# 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 +# Terminal 2 - Start web app (pointing to localhost) cd WebDevSecOps +# 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 @@ -100,53 +122,131 @@ 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/ # 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/ # Main application +│ ├── Controllers/ # 5 MVC controllers +│ │ ├── UsuarioController.cs +│ │ ├── EmpleadoController.cs +│ │ ├── ClienteController.cs +│ │ ├── ProductoController.cs +│ │ └── VentaController.cs +│ ├── Models/ # Models, ViewModels and 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 interface/implementation pairs + TokenStore +│ │ ├── AuthService # Login/logout with BFF pattern +│ │ ├── UsuarioService # CRUD usuarios +│ │ ├── EmpleadoService # CRUD empleados + search +│ │ ├── ClienteService # CRUD clientes + autocomplete +│ │ ├── ProductoService # CRUD productos + autocomplete + stock +│ │ ├── VentaService # CRUD ventas + VentaDetalle +│ │ ├── 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/ # Static files (CSS, JS, libs) +├── tests/ +│ ├── WebDevSecOps.UnitTests/ +│ │ ├── Pages/ # Tests per module (Empleados, Login, Productos, +│ │ │ # Usuarios, Ventas) +│ │ └── Common/ # TestData, MockHttpMessageHandler, TestConstants +│ ├── WebDevSecOps.IntegrationTests/ +│ │ └── Services/ # API Client tests per service +│ ├── WebDevSecOps.SecurityTests/ +│ │ ├── SAST/ # Static security tests +│ │ ├── DAST/ # Dynamic tests +│ │ ├── SCA/ # Dependency analysis +│ │ └── SecretScanning/ # Secret scanning +│ ├── WebDevSecOps.E2E/ +│ └── MemoriaPruebas.md # Testing lessons learned +├── docker/ # Container configuration +├── docker-compose.yml +└── AGENTS.md # Project memory (conventions, commands) ``` --- -## Licencia +## CRUD Modules + +| 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 | + +### Consumed API Endpoints + +| Module | Endpoint | Method | +|--------|----------|--------| +| **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 | + +--- + +## License -Proyecto educativo con fines de demostración DevSecOps. +Educational project for DevSecOps demonstration purposes. 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 {