Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Ums.Application.Test.Approvals.ApprovalRequest;
using Ums.Application.Approvals.ApprovalRequest.DTOs;
using Ums.Application.Approvals.ApprovalRequest.Services;
using Ums.Domain.Authorization;
using RoleAggregate = Ums.Domain.Authorization.Role.Role;
using Ums.Domain.Approvals.ApprovalRequest;
using Ums.Domain.Approvals;
using Ums.Domain.Authorization.Profile;
Expand Down Expand Up @@ -35,6 +36,7 @@ public class ApprovalRequestCommandHandlerTests
private readonly Mock<IUnitOfWorkScope> _unitOfWorkScope = new();
private readonly Mock<ITransactionScope> _transactionScope = new();
private readonly Mock<INotificationService> _notifications = new();
private readonly Mock<IRoleRepository> _roleRepo = new();
private readonly Mock<IUnitOfWork> _approvalUow = new();
private readonly Mock<IUnitOfWork> _profileUow = new();
private readonly Mock<IUserContext> _ctx = new();
Expand All @@ -47,6 +49,8 @@ public ApprovalRequestCommandHandlerTests()
{
_repo.Setup(r => r.UnitOfWork).Returns(_approvalUow.Object);
_profileRepo.Setup(r => r.UnitOfWork).Returns(_profileUow.Object);
_roleRepo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MakeRole());
_approvalUow.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(true);
_profileUow.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(true);
_tenantScopePolicy.Setup(p => p.EnsureManagementOwnerScopeAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
Expand Down Expand Up @@ -111,7 +115,16 @@ private CreateApprovalRequestCommandHandler CreateHandler() =>
new(_repo.Object, _workflowRepo.Object, _creationPolicyResolver.Object, _userAccountRepo.Object, _ctx.Object);

private ApproveRequestCommandHandler CreateApproveHandler() =>
new(_repo.Object, _profileRepo.Object, _userAccountRepo.Object, _tenantRepo.Object, _delegationRepo.Object, _tenantScopePolicy.Object, _unitOfWorkScope.Object, _notifications.Object, _ctx.Object);
new(_repo.Object, _profileRepo.Object, _userAccountRepo.Object, _tenantRepo.Object, _delegationRepo.Object, _tenantScopePolicy.Object, _unitOfWorkScope.Object, _notifications.Object, _roleRepo.Object, _ctx.Object);

// G-160: por defecto el repo de roles resuelve un rol válido, para que las aprobaciones happy-path
// no fallen por la nueva guarda de existencia del rol concedido.
private static RoleAggregate MakeRole() =>
RoleAggregate.Create(
Domain.Kernel.ValueObjects.TenantId.Load(TenantId),
SystemSuiteId.Load(Guid.NewGuid()),
Code.Create("ROLE_TEST"), Name.Create("Rol de Prueba"), Description.Create("rol"),
null, 0, 0, ActorId.Create("sys")).Value;

private RejectRequestCommandHandler CreateRejectHandler() =>
new(_repo.Object, _userAccountRepo.Object, _tenantRepo.Object, _notifications.Object, _ctx.Object);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ public async Task Handle_WithValidCommand_ReturnsSuccess()
Assert.Equal(tenantId, result.Value.TenantId);
}

// G-161: cuando la verificación autoritativa de unicidad (que ignora el filtro global por inquilino)
// detecta el código ya existente —incluso si la colección en memoria del agregado está vacía por el
// filtro cross-tenant— la creación se rechaza con 409 (BranchCodeNotUnique), no un 201 engañoso.
[Fact]
public async Task Handle_WhenBranchCodeExistsCrossTenant_ReturnsFailure()
{
var tenantId = Guid.NewGuid();
_userContextMock.Setup(u => u.UserId).Returns("user-001");
var tenant = CreateTenant();
_tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(tenant);
_tenantRepositoryMock.Setup(r => r.BranchCodeExistsAsync(tenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var command = ValidCommand with { TenantId = tenantId };

var result = await _handler.Handle(command, CancellationToken.None);

Assert.True(result.IsFailure);
Assert.Equal(DomainErrors.Tenant.BranchCodeNotUnique, result.Error);
}

[Fact]
public async Task Handle_WithGeofencingMetadata_ReturnsSuccess()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public sealed class ApproveRequestCommandHandler : ICommandHandler<ApproveReques
private readonly ITenantScopePolicy _tenantScopePolicy;
private readonly IUnitOfWorkScope _unitOfWorkScope;
private readonly INotificationService _notificationService;
private readonly IRoleRepository _roleRepository;
private readonly IUserContext _userContext;

public ApproveRequestCommandHandler(
Expand All @@ -35,6 +36,7 @@ public ApproveRequestCommandHandler(
ITenantScopePolicy tenantScopePolicy,
IUnitOfWorkScope unitOfWorkScope,
INotificationService notificationService,
IRoleRepository roleRepository,
IUserContext userContext)
{
_repository = repository;
Expand All @@ -45,6 +47,7 @@ public ApproveRequestCommandHandler(
_tenantScopePolicy = tenantScopePolicy;
_unitOfWorkScope = unitOfWorkScope;
_notificationService = notificationService;
_roleRepository = roleRepository;
_userContext = userContext;
}

Expand Down Expand Up @@ -143,6 +146,16 @@ private async Task<bool> CanApproveAsDelegatedBranchManagerAsync(
return Result<(Profile Profile, bool IsNew)>.Success((existingProfile, false));
}

// G-160: validar que el rol concedido EXISTE antes de materializar el perfil. Sin esta guarda,
// aprobar con un `grantedRoleId` arbitrario dejaba un Profile con rol FANTASMA que luego rompía
// la construcción del grafo de autorización del usuario objetivo en el login (401 AUTH_000).
var grantedRole = await _roleRepository.GetByIdAsync(grantedRoleId.GetValue(), cancellationToken);
if (grantedRole is null)
{
return Result<(Profile Profile, bool IsNew)>.Failure(
"El rol concedido no existe.");
}

var profileResult = Profile.Create(
targetUser.TenantId,
UserId.Load(targetUser.GetId().GetValue()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public async Task<Result<PagedResult<ApprovalRequestDto>>> Handle(GetAllApproval
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(r => r.WorkflowId.ToString().Contains(search, StringComparison.OrdinalIgnoreCase));

// G-159: el parámetro userId de la query se ignoraba (contrato expuesto, filtro no cableado).
// Se filtra por el usuario objetivo de la solicitud (TargetUserId), la semántica útil:
// «solicitudes de aprobación que conciernen a este usuario».
if (request.UserId.HasValue)
query = query.Where(r => r.TargetUserId == request.UserId.Value);

query = (sortBy, sortOrder) switch
{
("status", "desc") => query.OrderByDescending(r => r.Status),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ public async Task<Result<AddBranchResponse>> Handle(
return Result<AddBranchResponse>.Failure(scopeResult.Error);
}

// G-161: verificación de unicidad AUTORITATIVA (ignora el filtro global por inquilino) ANTES de
// materializar. La guarda en memoria de `Tenant.AddBranch` es correcta cuando el actor opera en
// su propio inquilino, pero cuando un admin interno provisiona sobre OTRO inquilino el filtro
// global vacía `tenant.Branches` y no vería el duplicado → antes devolvía 201 (no-op engañoso)
// en vez de 409. La BD garantiza la integridad (índice único TenantId+Code), pero el contrato
// debe ser consistente: se rechaza aquí con el mismo error de dominio.
if (await _tenantRepository.BranchCodeExistsAsync(request.TenantId, request.Code, cancellationToken))
{
return Result<AddBranchResponse>.Failure(DomainErrors.Tenant.BranchCodeNotUnique);
}

var result = tenant.AddBranch(
Code.Create(request.Code),
Name.Create(request.Name),
Expand Down
8 changes: 8 additions & 0 deletions src/apps/ums.api/Ums.Domain/Identity/Repositories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ public interface ITenantRepository : IAggregateRepository<TenantAggregate>
{
Task<TenantAggregate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<TenantAggregate?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// G-161: ¿existe ya una sucursal con este código bajo el inquilino? Consulta autoritativa que
/// IGNORA el filtro global por inquilino, para que la verificación de unicidad sea correcta aun
/// cuando el actor (admin interno) opera sobre OTRO inquilino distinto a su contexto — donde el
/// filtro global vaciaría la colección `Branches` del agregado y la guarda en memoria de
/// `Tenant.AddBranch` no vería el duplicado. Es de solo lectura y se usa en la vía de escritura.
/// </summary>
Task<bool> BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TenantAggregate>> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// REC-12: Server-side paginated query. SQL implementations use Skip/Take at the DB level.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ public sealed class PostgreSqlTenantRepository(UmsPlatformDbContext dbContext) :
public Task<TenantAggregate?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default)
=> GetByIdAsync(id, cancellationToken);

// G-161: verificación de unicidad autoritativa que IGNORA el filtro global por inquilino. Necesaria
// en la vía de escritura porque el filtro acota la colección `Branches` del agregado al inquilino
// del CONTEXTO; un admin interno que provisiona sobre otro inquilino cargaría una colección vacía y
// la guarda en memoria no vería el duplicado. La lectura sigue aislada (esta consulta no expone
// datos: solo devuelve un booleano y se usa dentro de un handler ya acotado a management-owner).
public Task<bool> BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default)
=> dbContext.TenantBranches
.IgnoreQueryFilters()
.AnyAsync(b => b.TenantId == tenantId && b.Code == code, cancellationToken);

public async Task<TenantAggregate?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var record = await dbContext.Tenants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public sealed class SqlServerTenantRepository(UmsPlatformDbContext dbContext) :
public Task<TenantAggregate?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default)
=> GetByIdAsync(id, cancellationToken);

// G-161: verificación de unicidad autoritativa que IGNORA el filtro global por inquilino (ver
// PostgreSqlTenantRepository). Devuelve solo un booleano y se usa en la vía de escritura acotada.
public Task<bool> BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default)
=> dbContext.TenantBranches
.IgnoreQueryFilters()
.AnyAsync(b => b.TenantId == tenantId && b.Code == code, cancellationToken);

public async Task<TenantAggregate?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var record = await dbContext.Tenants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ public InMemoryTenantRepository(IHttpContextAccessor? httpContextAccessor = null
public Task<TenantAggregate?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default)
=> GetByIdAsync(id, cancellationToken);

// G-161: en memoria no hay filtro global, así que la colección del agregado está siempre completa.
public Task<bool> BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default)
{
_store.TryGetValue(tenantId, out var tenant);
var exists = tenant is not null
&& tenant.Branches.Any(b => b.Code.GetValue() == code);
return Task.FromResult(exists);
}

public Task<IReadOnlyList<TenantAggregate>> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default)
{
var all = _store.Values.ToList();
Expand Down
2 changes: 1 addition & 1 deletion src/apps/ums.web-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# =========================================================
# Phase 1: Dependency Resolution
# =========================================================
FROM node:20-alpine AS base
FROM node:24-alpine AS base
WORKDIR /usr/src/app
COPY package*.json ./
COPY apps/ums.web-app/package*.json ./apps/ums.web-app/
Expand Down
17 changes: 11 additions & 6 deletions src/apps/ums.web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,23 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:cluster": "E2E_BASE_URL=http://localhost:8080 playwright test",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@tanstack/react-query": "5.100.9",
"axios": "1.16.0",
"axios": "1.18.0",
"cookie-es": "3.1.1",
"graphql": "16.12.0",
"lucide-react": "1.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-resizable-panels": "4.11.1",
"react-router-dom": "6.30.4",
"react-router": "8.3.0",
"zod": "4.4.3",
"zustand": "5.0.13"
},
Expand All @@ -38,8 +42,8 @@
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/node": "20.19.39",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"@vitest/coverage-v8": "4.1.7",
"autoprefixer": "10.5.0",
Expand All @@ -53,6 +57,7 @@
"msw": "2.14.6",
"postcss": "8.5.14",
"prettier": "3.4.2",
"rollup": "4.62.2",
"storybook": "10.4.1",
"tailwindcss": "3.4.19",
"typescript": "5.6.2",
Expand Down
2 changes: 1 addition & 1 deletion src/apps/ums.web-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { MainLayout } from './presentation/shared/layouts/MainLayout';
import { AppErrorBoundary } from './presentation/shared/components/ErrorBoundary';
import { RouteLoader } from './presentation/shared/components/RouteLoader';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Internal-admin view of system-wide configuration parameters.
*/
import React, { useState } from 'react';
import { Navigate } from 'react-router-dom';
import { Navigate } from 'react-router';
import { useAppConfigurationDashboard } from '@app/configuration/hooks/use-app-configuration-dashboard';
import {
useCreateAppConfiguration,
Expand Down Expand Up @@ -70,11 +70,16 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme
d.setSelectedId(results[0].appConfigurationId);
}
setIsPickerOpen(false);
} catch (err: any) {
} catch (err: unknown) {
const e = err as {
normalised?: { message?: string };
response?: { data?: { detail?: string } };
message?: string;
};
const errorMsg =
err?.normalised?.message ||
err?.response?.data?.detail ||
err?.message ||
e?.normalised?.message ||
e?.response?.data?.detail ||
e?.message ||
t.failedToLinkParameter;
addNotification({
title: t.error ?? 'Error',
Expand Down Expand Up @@ -103,8 +108,12 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme
if (d.selectedId === pendingDeleteId) {
d.setSelectedId('');
}
} catch (err: any) {
const errorMsg = err?.normalised?.message || err?.response?.data?.detail || t.deleteFailed;
} catch (err: unknown) {
const e = err as {
normalised?: { message?: string };
response?: { data?: { detail?: string } };
};
const errorMsg = e?.normalised?.message || e?.response?.data?.detail || t.deleteFailed;
addNotification({
title: t.error ?? 'Error',
message: errorMsg,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const setLanguageMock = vi.fn();
const setDevUserIdMock = vi.fn();
const addNotificationMock = vi.fn();

vi.mock('react-router-dom', () => ({
vi.mock('react-router', () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ search: '' }),
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@
* - Accessible design with proper labels
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router';
import { useAuthStore, detectBrowserTimezone } from '@app/stores/auth.store';
import { useI18nStore } from '@app/stores/i18n.store';
import type { SupportedLanguage } from '@app/stores/i18n.store';
import { useDevToolsStore } from '@app/stores/devTools.store';
import { useI18n } from '@app/i18n/use-i18n';
import { M3Card } from '@shared/components/M3Card';
import { M3Button } from '@shared/components/M3Button';
import { M3TextField } from '@shared/components/M3TextField';
Expand All @@ -39,7 +38,6 @@ export default function LoginScreen(): React.JSX.Element {
const { setLanguage } = useI18nStore();
const { setDevUserId } = useDevToolsStore();
const addNotification = useNotificationStore(state => state.addNotification);
const t = useI18n();

const [tenantId, setTenantId] = useState(DEV_TENANTS[0].id);
const [username, setUsername] = useState('');
Expand All @@ -66,13 +64,18 @@ export default function LoginScreen(): React.JSX.Element {
}, [isAuthenticated, navigate, redirectTo]);

useEffect(() => {
// El mensaje de lockout se deriva de `lockoutUntil` (estado): al fijarse el bloqueo se
// muestra la cuenta atrás; al vencer se limpia. Es una sincronización de estado externo
// legítima; el setState va guardado por la condición, no en bucle.
/* eslint-disable react-hooks/set-state-in-effect */
if (lockoutUntil && Date.now() < lockoutUntil) {
const remaining = Math.ceil((lockoutUntil - Date.now()) / 1000);
setError(`Demasiados intentos. Espere ${remaining} segundos`);
} else if (lockoutUntil) {
setLockoutUntil(null);
setError('');
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [lockoutUntil]);

const handleLogin = useCallback(
Expand Down Expand Up @@ -204,6 +207,10 @@ export default function LoginScreen(): React.JSX.Element {
return null;
}

// Comparación con la hora actual para deshabilitar el formulario durante el lockout. `Date.now()`
// es impura por definición; aquí es intencional (estado de tiempo real) y no afecta la idempotencia
// del render más allá del bloqueo temporal esperado.
// eslint-disable-next-line react-hooks/purity
const isLocked = lockoutUntil !== null && Date.now() < lockoutUntil;

// ── Registration options shown on the right panel ──────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@
* - Loading states to prevent flash of protected content
*/
import React, { useEffect, useState } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router';
import { useAuthStore } from '@app/stores/auth.store';
import { authService } from '@app/identity/services/auth.service';
import { Spinner } from '@shared/components/Spinner';

interface ProtectedRouteProps {
Expand Down
Loading
Loading