Editar Usuario
- {loggedUserId !== userId &&
}
+ {!isSelf &&
}
@@ -129,105 +133,120 @@ export function UserEditPage() {
{user.username}
-
+
(
+
+
+
+
+ Activo
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/web/src/features/users/types.ts b/src/web/src/features/users/types.ts
index b2a1732..80c44b2 100644
--- a/src/web/src/features/users/types.ts
+++ b/src/web/src/features/users/types.ts
@@ -46,3 +46,21 @@ export interface UpdateUserPayload {
rol: string
activo: boolean
}
+
+// UDT-009 — Permisos overrides per-user types
+
+export interface UsuarioPermisos {
+ rolPermisos: string[]
+ overrides: {
+ grant: string[]
+ deny: string[]
+ }
+ effective: string[]
+}
+
+export interface UpdatePermisosOverridesPayload {
+ grant: string[]
+ deny: string[]
+}
+
+export type PermisoOverrideState = 'heredado' | 'concedido' | 'denegado'
diff --git a/src/web/src/tests/features/users/PermisosEditor.test.tsx b/src/web/src/tests/features/users/PermisosEditor.test.tsx
new file mode 100644
index 0000000..067e4e6
--- /dev/null
+++ b/src/web/src/tests/features/users/PermisosEditor.test.tsx
@@ -0,0 +1,197 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { http, HttpResponse } from 'msw'
+import { setupServer } from 'msw/node'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import { PermisosEditor } from '../../../features/users/components/PermisosEditor'
+
+const API_URL = 'http://localhost:5000'
+
+// Catalog of ALL known permissions (from /api/v1/permisos)
+const catalogoPermisos = [
+ { id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: null, modulo: 'ventas' },
+ { id: 2, codigo: 'ventas:contado:cobrar', nombre: 'Cobrar venta contado', descripcion: null, modulo: 'ventas' },
+ { id: 3, codigo: 'textos:editar', nombre: 'Editar textos', descripcion: null, modulo: 'textos' },
+]
+
+// User permisos — from /api/v1/users/42/permisos
+const mockUsuarioPermisos = {
+ rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'],
+ overrides: {
+ grant: ['textos:editar'],
+ deny: ['ventas:contado:cobrar'],
+ },
+ effective: ['ventas:contado:crear', 'textos:editar'],
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
+
+function renderEditor(userId = 42) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+function setupHandlers() {
+ server.use(
+ http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
+ http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
+ )
+}
+
+describe('PermisosEditor', () => {
+ it('calls GET /api/v1/users/:id/permisos on mount', async () => {
+ let getPermisosCallCount = 0
+ server.use(
+ http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
+ http.get(`${API_URL}/api/v1/users/42/permisos`, () => {
+ getPermisosCallCount++
+ return HttpResponse.json(mockUsuarioPermisos)
+ }),
+ )
+
+ renderEditor(42)
+
+ await waitFor(() => expect(getPermisosCallCount).toBe(1))
+ })
+
+ it('renders permissions grouped by module', async () => {
+ setupHandlers()
+ renderEditor()
+
+ await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument())
+
+ expect(screen.getByText('textos')).toBeInTheDocument()
+ expect(screen.getByText('Crear venta contado')).toBeInTheDocument()
+ expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument()
+ expect(screen.getByText('Editar textos')).toBeInTheDocument()
+ })
+
+ it('shows Heredado state for permissions in role but not in grant or deny', async () => {
+ setupHandlers()
+ renderEditor()
+
+ // ventas:contado:crear is in rolPermisos, not in grant, not in deny
+ // expect a button/element indicating "Heredado" is active for that permission
+ await waitFor(() => expect(screen.getByText('Crear venta contado')).toBeInTheDocument())
+
+ // Should have a "Heredado" indicator active for ventas:contado:crear
+ // We look for the specific row container and check the selected state
+ const crearRow = screen.getByTestId('permiso-row-ventas:contado:crear')
+ expect(within(crearRow).getByRole('button', { name: /heredado/i })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ })
+
+ it('shows Concedido state for permissions in grant', async () => {
+ setupHandlers()
+ renderEditor()
+
+ await waitFor(() => expect(screen.getByText('Editar textos')).toBeInTheDocument())
+
+ const editarRow = screen.getByTestId('permiso-row-textos:editar')
+ expect(within(editarRow).getByRole('button', { name: /concedido/i })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ })
+
+ it('shows Denegado state for permissions in deny', async () => {
+ setupHandlers()
+ renderEditor()
+
+ await waitFor(() => expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument())
+
+ const cobrarRow = screen.getByTestId('permiso-row-ventas:contado:cobrar')
+ expect(within(cobrarRow).getByRole('button', { name: /denegado/i })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ })
+
+ it('Guardar button calls PUT with correct { grant, deny } body', async () => {
+ let capturedBody: unknown = null
+ server.use(
+ http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
+ http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
+ http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => {
+ capturedBody = await request.json()
+ return HttpResponse.json(mockUsuarioPermisos)
+ }),
+ )
+
+ const u = userEvent.setup()
+ renderEditor()
+
+ await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
+
+ await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
+
+ await waitFor(() => expect(capturedBody).not.toBeNull())
+ // Initial state: grant=['textos:editar'], deny=['ventas:contado:cobrar']
+ expect(capturedBody).toMatchObject({
+ grant: expect.arrayContaining(['textos:editar']),
+ deny: expect.arrayContaining(['ventas:contado:cobrar']),
+ })
+ })
+
+ it('shows alert on 400 invalid-permiso-codes error', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
+ http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
+ http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
+ HttpResponse.json(
+ { title: 'invalid-permiso-codes', status: 400, invalidCodes: ['fake:codigo'] },
+ { status: 400 },
+ ),
+ ),
+ )
+
+ const u = userEvent.setup()
+ renderEditor()
+
+ await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
+ await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
+
+ await waitFor(() =>
+ expect(screen.getByRole('alert')).toBeInTheDocument(),
+ )
+ })
+
+ it('shows alert on 400 grant-deny-overlap error', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
+ http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
+ http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
+ HttpResponse.json(
+ { title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] },
+ { status: 400 },
+ ),
+ ),
+ )
+
+ const u = userEvent.setup()
+ renderEditor()
+
+ await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
+ await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
+
+ await waitFor(() =>
+ expect(screen.getByRole('alert')).toBeInTheDocument(),
+ )
+ })
+})
diff --git a/src/web/src/tests/features/users/UserEditPage.test.tsx b/src/web/src/tests/features/users/UserEditPage.test.tsx
index 5b8882c..c9284c4 100644
--- a/src/web/src/tests/features/users/UserEditPage.test.tsx
+++ b/src/web/src/tests/features/users/UserEditPage.test.tsx
@@ -162,4 +162,40 @@ describe('UserEditPage', () => {
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: /resetear contraseña/i })).not.toBeInTheDocument()
})
+
+ // FP-01: tabs Perfil and Permisos visible when editing another user
+ it('shows tabs "Perfil" and "Permisos" when editing another user', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
+ http.get(`${API_URL}/api/v1/users/5/permisos`, () =>
+ HttpResponse.json({
+ usuarioId: 5, rol: 'cajero', rolPermisos: [], grant: [], deny: [], effective: [],
+ }),
+ ),
+ http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json([])),
+ )
+
+ renderEditPage(5)
+
+ await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
+
+ expect(screen.getByRole('tab', { name: /perfil/i })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: /permisos/i })).toBeInTheDocument()
+ })
+
+ // FP-10: self-edit — tab Permisos is disabled
+ it('disables tab "Permisos" when editing own profile (self-edit)', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/users/1`, () =>
+ HttpResponse.json({ ...mockUserDetail, id: 1, username: 'admin' }),
+ ),
+ )
+
+ renderEditPage(1)
+
+ await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
+
+ const permisosTab = screen.getByRole('tab', { name: /permisos/i })
+ expect(permisosTab).toBeDisabled()
+ })
})
diff --git a/src/web/src/tests/features/users/getUserPermisos.test.ts b/src/web/src/tests/features/users/getUserPermisos.test.ts
new file mode 100644
index 0000000..2533c6e
--- /dev/null
+++ b/src/web/src/tests/features/users/getUserPermisos.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
+import { http, HttpResponse } from 'msw'
+import { setupServer } from 'msw/node'
+import { getUserPermisos } from '../../../features/users/api/getUserPermisos'
+
+const API_URL = 'http://localhost:5000'
+
+const mockUsuarioPermisos = {
+ rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'],
+ overrides: {
+ grant: ['textos:editar'],
+ deny: ['ventas:contado:cobrar'],
+ },
+ effective: ['ventas:contado:crear', 'textos:editar'],
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
+
+describe('getUserPermisos api client', () => {
+ it('calls GET /api/v1/users/:id/permisos and returns UsuarioPermisos shape', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/users/42/permisos`, () =>
+ HttpResponse.json(mockUsuarioPermisos),
+ ),
+ )
+
+ const result = await getUserPermisos(42)
+
+ expect(result.rolPermisos).toEqual(['ventas:contado:crear', 'ventas:contado:cobrar'])
+ expect(result.overrides.grant).toEqual(['textos:editar'])
+ expect(result.overrides.deny).toEqual(['ventas:contado:cobrar'])
+ expect(result.effective).toEqual(['ventas:contado:crear', 'textos:editar'])
+ })
+
+ it('rejects with error on 404', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/users/9999/permisos`, () =>
+ HttpResponse.json({ title: 'Not Found', status: 404 }, { status: 404 }),
+ ),
+ )
+
+ await expect(getUserPermisos(9999)).rejects.toThrow()
+ })
+})
diff --git a/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts b/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts
new file mode 100644
index 0000000..b46707f
--- /dev/null
+++ b/src/web/src/tests/features/users/updateUserPermisosOverrides.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
+import { http, HttpResponse } from 'msw'
+import { setupServer } from 'msw/node'
+import { updateUserPermisosOverrides } from '../../../features/users/api/updateUserPermisosOverrides'
+
+const API_URL = 'http://localhost:5000'
+
+const mockResponse = {
+ usuarioId: 42,
+ rol: 'cajero',
+ rolPermisos: ['ventas:contado:crear'],
+ grant: ['textos:editar'],
+ deny: [],
+ effective: ['ventas:contado:crear', 'textos:editar'],
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
+
+describe('updateUserPermisosOverrides api client', () => {
+ it('calls PUT /api/v1/users/:id/permisos/overrides with correct body and returns UsuarioPermisos', async () => {
+ let capturedBody: unknown = null
+ server.use(
+ http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => {
+ capturedBody = await request.json()
+ return HttpResponse.json(mockResponse)
+ }),
+ )
+
+ const result = await updateUserPermisosOverrides(42, {
+ grant: ['textos:editar'],
+ deny: [],
+ })
+
+ expect(result.grant).toEqual(['textos:editar'])
+ expect(result.effective).toContain('textos:editar')
+ expect(capturedBody).toMatchObject({ grant: ['textos:editar'], deny: [] })
+ })
+
+ it('rejects on 400 invalid-permiso-codes', async () => {
+ server.use(
+ http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
+ HttpResponse.json(
+ { title: 'invalid-permiso-codes', status: 400, invalidCodes: ['modulo:fake:accion'] },
+ { status: 400 },
+ ),
+ ),
+ )
+
+ await expect(
+ updateUserPermisosOverrides(42, { grant: ['modulo:fake:accion'], deny: [] }),
+ ).rejects.toThrow()
+ })
+
+ it('rejects on 400 grant-deny-overlap', async () => {
+ server.use(
+ http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
+ HttpResponse.json(
+ { title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] },
+ { status: 400 },
+ ),
+ ),
+ )
+
+ await expect(
+ updateUserPermisosOverrides(42, { grant: ['textos:editar'], deny: ['textos:editar'] }),
+ ).rejects.toThrow()
+ })
+})
diff --git a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
index 408bcb7..53606e2 100644
--- a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
+++ b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
@@ -1,3 +1,4 @@
+using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -10,27 +11,38 @@ using SIGCM2.Domain.Entities;
namespace SIGCM2.Api.Tests.Authorization;
///
-/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006).
-/// Tests isolated from DB: IRolPermisoRepository is mocked via NSubstitute.
+/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006) + SUITE-B-AUTHZ-HANDLER (UDT-009).
+/// Tests isolated from DB: IRolPermisoRepository and IUsuarioRepository mocked via NSubstitute.
///
public sealed class PermissionAuthorizationHandlerTests
{
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For
();
+ private readonly IUsuarioRepository _usuarioRepo = Substitute.For();
private readonly PermissionAuthorizationHandler _handler;
public PermissionAuthorizationHandlerTests()
{
+ // Default: usuario repo returns null (no overrides) unless overridden in individual tests
+ _usuarioRepo.GetByIdAsync(Arg.Any(), Arg.Any())
+ .Returns((Usuario?)null);
+
_handler = new PermissionAuthorizationHandler(
_rolPermisoRepo,
+ _usuarioRepo,
NullLogger.Instance);
}
// ── Helpers ──────────────────────────────────────────────────────────────
- private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue)
+ /// Creates an authenticated user with rol claim and sub=42 (needed by UDT-009 handler).
+ private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue, int userId = 42)
{
var identity = new ClaimsIdentity(
- new[] { new Claim("rol", rolValue) },
+ new[]
+ {
+ new Claim("rol", rolValue),
+ new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
+ },
authenticationType: "TestAuth");
return new ClaimsPrincipal(identity);
}
@@ -38,7 +50,19 @@ public sealed class PermissionAuthorizationHandlerTests
private static ClaimsPrincipal AuthenticatedUserWithoutRolClaim()
{
var identity = new ClaimsIdentity(
- new[] { new Claim(ClaimTypes.Name, "someuser") },
+ new[]
+ {
+ new Claim(ClaimTypes.Name, "someuser"),
+ new Claim(JwtRegisteredClaimNames.Sub, "42"),
+ },
+ authenticationType: "TestAuth");
+ return new ClaimsPrincipal(identity);
+ }
+
+ private static ClaimsPrincipal AuthenticatedUserWithoutSubClaim()
+ {
+ var identity = new ClaimsIdentity(
+ new[] { new Claim("rol", "cajero") },
authenticationType: "TestAuth");
return new ClaimsPrincipal(identity);
}
@@ -243,4 +267,149 @@ public sealed class PermissionAuthorizationHandlerTests
Assert.False(context.HasSucceeded);
Assert.Equal("administracion:usuarios:gestionar", httpContext.Items["RequiredPermission"]);
}
+
+ // ── UDT-009: SUITE-B-AUTHZ-HANDLER (A-01 a A-07) ────────────────────────
+
+ // A-01: Cajero sin override, endpoint requiere permiso ajeno → HasSucceeded == false
+ [Fact]
+ public async Task A01_Cajero_NoOverride_LacksPermission_Fails()
+ {
+ var user = AuthenticatedUserWithRol("cajero", userId: 42);
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+ _usuarioRepo.GetByIdAsync(42, Arg.Any())
+ .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}"""));
+
+ var context = MakeContext(user, requirement);
+ await _handler.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ // A-02: Cajero con grant del permiso requerido → HasSucceeded == true
+ [Fact]
+ public async Task A02_Cajero_WithGrant_RequiredPermiso_Succeeds()
+ {
+ var user = AuthenticatedUserWithRol("cajero", userId: 42);
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+ _usuarioRepo.GetByIdAsync(42, Arg.Any())
+ .Returns(MakeUsuario(42, "cajero", """{"grant":["administracion:usuarios:gestionar"],"deny":[]}"""));
+
+ var context = MakeContext(user, requirement);
+ await _handler.HandleAsync(context);
+
+ Assert.True(context.HasSucceeded);
+ }
+
+ // A-03: Admin (tiene el permiso) + deny del permiso requerido → HasSucceeded == false
+ [Fact]
+ public async Task A03_Admin_WithDeny_RequiredPermiso_Fails()
+ {
+ var user = AuthenticatedUserWithRol("admin", userId: 1);
+ var requirement = new RequirePermissionAttribute("administracion:permisos:ver");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any())
+ .Returns(new List { MakePermiso(21, "administracion:permisos:ver") }
+ .AsReadOnly() as IReadOnlyList);
+ _usuarioRepo.GetByIdAsync(1, Arg.Any())
+ .Returns(MakeUsuario(1, "admin", """{"grant":[],"deny":["administracion:permisos:ver"]}"""));
+
+ var context = MakeContext(user, requirement);
+ await _handler.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ }
+
+ // A-04: Token sin claim 'permisos' (post-UDT-009) → handler resuelve desde DB
+ [Fact]
+ public async Task A04_TokenWithoutPermisosClaim_HandlerResolvesFromDB()
+ {
+ // Token has sub=42 but no 'permisos' claim (post-UDT-009 JWT)
+ var user = AuthenticatedUserWithRol("cajero", userId: 42);
+ var requirement = new RequirePermissionAttribute("ventas:contado:crear");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+ _usuarioRepo.GetByIdAsync(42, Arg.Any())
+ .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}"""));
+
+ var context = MakeContext(user, requirement);
+ await _handler.HandleAsync(context);
+
+ // Handler correctly resolves from DB (no 'permisos' claim needed)
+ Assert.True(context.HasSucceeded);
+ await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any());
+ }
+
+ // A-05: IUsuarioRepository.GetByIdAsync called with sub from token
+ [Fact]
+ public async Task A05_GetByIdAsync_CalledWithSubFromToken()
+ {
+ var user = AuthenticatedUserWithRol("cajero", userId: 42);
+ var requirement = new RequirePermissionAttribute("ventas:contado:crear");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+ _usuarioRepo.GetByIdAsync(42, Arg.Any())
+ .Returns(MakeUsuario(42, "cajero", """{"grant":[],"deny":[]}"""));
+
+ var context = MakeContext(user, requirement);
+ await _handler.HandleAsync(context);
+
+ await _usuarioRepo.Received(1).GetByIdAsync(42, Arg.Any());
+ }
+
+ // A-06: sub claim absent → Fail, repo NOT called
+ [Fact]
+ public async Task A06_SubClaimAbsent_Fails_RepoNotCalled()
+ {
+ var user = AuthenticatedUserWithoutSubClaim();
+ var requirement = new RequirePermissionAttribute("ventas:contado:crear");
+
+ _rolPermisoRepo.GetByRolCodigoAsync(Arg.Any(), Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+ await _handler.HandleAsync(context);
+
+ Assert.False(context.HasSucceeded);
+ await _usuarioRepo.DidNotReceive().GetByIdAsync(Arg.Any(), Arg.Any());
+ }
+
+ // A-07: Usuario not found in DB (null) → Fail, no exception
+ [Fact]
+ public async Task A07_UsuarioNotFoundInDB_FailsSafely_NoException()
+ {
+ var user = AuthenticatedUserWithRol("cajero", userId: 9999);
+ var requirement = new RequirePermissionAttribute("ventas:contado:crear");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+ _usuarioRepo.GetByIdAsync(9999, Arg.Any())
+ .Returns((Usuario?)null);
+
+ var context = MakeContext(user, requirement);
+
+ // Should not throw — null usuario → no overrides → resolve with Empty (rol permisos only)
+ await _handler.HandleAsync(context);
+
+ // With no overrides, cajero with ventas:contado:crear should succeed
+ Assert.True(context.HasSucceeded);
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private static Usuario MakeUsuario(int id, string rol, string permisosJson)
+ => new(id, "user" + id, "$2a$12$hash", "Test", "User", null, rol, permisosJson, true);
}
diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs
index 9d89b3a..abaa702 100644
--- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs
+++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs
@@ -225,7 +225,8 @@ public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
new { Username = newUsername });
Assert.True(row.Activo, "Activo should be true");
- Assert.Equal("[]", row.PermisosJson);
+ // V009 (UDT-009): ForCreation now defaults to canonical shape {"grant":[],"deny":[]}
+ Assert.Equal("""{"grant":[],"deny":[]}""", row.PermisosJson);
}
finally
{
diff --git a/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
new file mode 100644
index 0000000..7571acb
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Usuarios/UsuarioPermisosEndpointTests.cs
@@ -0,0 +1,435 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Dapper;
+using Microsoft.Data.SqlClient;
+using SIGCM2.TestSupport;
+
+namespace SIGCM2.Api.Tests.Usuarios;
+
+///
+/// Integration tests for GET /api/v1/users/{id}/permisos and PUT /api/v1/users/{id}/permisos/overrides.
+/// SUITE-B-GET-PERMISOS (GP-01..GP-06) + SUITE-B-PUT-OVERRIDES (PO-01..PO-11) — UDT-009.
+///
+[Collection("ApiIntegration")]
+public sealed class UsuarioPermisosEndpointTests : IAsyncLifetime
+{
+ private const string TestConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ private const string AdminUsername = "admin";
+ private const string AdminPassword = "@Diego550@";
+
+ private readonly HttpClient _client;
+ private string? _adminToken;
+
+ public UsuarioPermisosEndpointTests(TestWebAppFactory factory)
+ {
+ _client = factory.CreateClient();
+ }
+
+ public async Task InitializeAsync()
+ {
+ _adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private async Task GetBearerTokenAsync(string username, string password)
+ {
+ var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password });
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadFromJsonAsync();
+ return json.GetProperty("accessToken").GetString()!;
+ }
+
+ private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? token = null)
+ {
+ var request = new HttpRequestMessage(method, url);
+ var tok = token ?? _adminToken;
+ if (tok is not null)
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tok);
+ if (body is not null)
+ request.Content = JsonContent.Create(body);
+ return request;
+ }
+
+ private async Task GetAdminIdAsync()
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ return await conn.QuerySingleAsync("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
+ }
+
+ private async Task SetPermisosJsonAsync(int userId, string json)
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ await conn.ExecuteAsync(
+ "UPDATE dbo.Usuario SET PermisosJson = @Json WHERE Id = @Id",
+ new { Json = json, Id = userId });
+ }
+
+ private async Task GetPermisosJsonAsync(int userId)
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ return await conn.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Id = @Id",
+ new { Id = userId });
+ }
+
+ // ── SUITE-B-GET-PERMISOS ─────────────────────────────────────────────────
+
+ // GP-01: Admin → 200 con shape correcto {rolPermisos, overrides, effective}
+ [Fact]
+ public async Task GetPermisos_Admin_Returns200_WithCorrectShape()
+ {
+ var adminId = await GetAdminIdAsync();
+ await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
+
+ var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+
+ Assert.True(json.TryGetProperty("rolPermisos", out var rolPermisos));
+ Assert.Equal(JsonValueKind.Array, rolPermisos.ValueKind);
+
+ Assert.True(json.TryGetProperty("overrides", out var overrides));
+ Assert.True(overrides.TryGetProperty("grant", out _));
+ Assert.True(overrides.TryGetProperty("deny", out _));
+
+ Assert.True(json.TryGetProperty("effective", out var effective));
+ Assert.Equal(JsonValueKind.Array, effective.ValueKind);
+ }
+
+ // GP-02: Usuario con overrides no vacíos → shape refleja overrides.grant, effective incluye el grant
+ [Fact]
+ public async Task GetPermisos_UserWithGrant_EffectiveContainsGrantedPermiso()
+ {
+ var adminId = await GetAdminIdAsync();
+ // Admin ya tiene 21 permisos del rol — grant con uno que tiene para probar idempotencia
+ await SetPermisosJsonAsync(adminId, """{"grant":["textos:editar"],"deny":[]}""");
+
+ var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+ var overrides = json.GetProperty("overrides");
+ var grantArr = overrides.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
+
+ Assert.Contains("textos:editar", grantArr);
+
+ var effectiveArr = json.GetProperty("effective").EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Contains("textos:editar", effectiveArr);
+ }
+
+ // GP-03: Usuario con overrides vacíos → effective == rolPermisos, overrides vacíos
+ [Fact]
+ public async Task GetPermisos_UserWithEmptyOverrides_EffectiveEqualsRolPermisos()
+ {
+ var adminId = await GetAdminIdAsync();
+ await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
+
+ var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+ var rolPermisos = json.GetProperty("rolPermisos").EnumerateArray().Select(e => e.GetString()).OrderBy(x => x).ToArray();
+ var effective = json.GetProperty("effective").EnumerateArray().Select(e => e.GetString()).OrderBy(x => x).ToArray();
+
+ Assert.Equal(rolPermisos, effective);
+
+ var grantArr = json.GetProperty("overrides").GetProperty("grant").EnumerateArray().ToArray();
+ var denyArr = json.GetProperty("overrides").GetProperty("deny").EnumerateArray().ToArray();
+ Assert.Empty(grantArr);
+ Assert.Empty(denyArr);
+ }
+
+ // GP-04: Usuario inexistente → 404
+ [Fact]
+ public async Task GetPermisos_NonExistentUser_Returns404()
+ {
+ var request = BuildRequest(HttpMethod.Get, "/api/v1/users/99999/permisos");
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ // GP-05: Sin permiso administracion:usuarios:gestionar → 403
+ [Fact]
+ public async Task GetPermisos_WithoutRequiredPermission_Returns403()
+ {
+ // Create a cajero user without the required permission
+ var cajeroToken = await CreateCajeroAndGetTokenAsync("cajero_gp05");
+ try
+ {
+ var adminId = await GetAdminIdAsync();
+ var request = BuildRequest(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos", token: cajeroToken);
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+ finally
+ {
+ await DeleteUsuarioAsync("cajero_gp05");
+ }
+ }
+
+ // GP-06: Sin auth → 401
+ [Fact]
+ public async Task GetPermisos_WithoutAuth_Returns401()
+ {
+ var adminId = await GetAdminIdAsync();
+ var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}/permisos");
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ // ── SUITE-B-PUT-OVERRIDES ────────────────────────────────────────────────
+
+ // PO-01: Grant válido → 200, DB persistido, FechaModificacion actualizado
+ [Fact]
+ public async Task PutOverrides_ValidGrant_Returns200_AndPersistsInDB()
+ {
+ var adminId = await GetAdminIdAsync();
+ await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
+
+ var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = new[] { "textos:editar" }, deny = Array.Empty() });
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var stored = await GetPermisosJsonAsync(adminId);
+ var parsed = JsonDocument.Parse(stored).RootElement;
+ var grant = parsed.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Contains("textos:editar", grant);
+ }
+
+ // PO-02: Deny válido → 200
+ [Fact]
+ public async Task PutOverrides_ValidDeny_Returns200()
+ {
+ var adminId = await GetAdminIdAsync();
+ await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
+
+ var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = Array.Empty(), deny = new[] { "ventas:contado:cobrar" } });
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var stored = await GetPermisosJsonAsync(adminId);
+ var parsed = JsonDocument.Parse(stored).RootElement;
+ var deny = parsed.GetProperty("deny").EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Contains("ventas:contado:cobrar", deny);
+ }
+
+ // PO-03: Código fuera del catálogo → 400, error code "invalid-permiso-codes"
+ [Fact]
+ public async Task PutOverrides_InvalidPermisoCode_Returns400_InvalidCodes()
+ {
+ var adminId = await GetAdminIdAsync();
+
+ var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = new[] { "modulo:fake:accion" }, deny = Array.Empty() });
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+ var title = json.GetProperty("title").GetString();
+ Assert.Equal("invalid-permiso-codes", title);
+
+ // Should contain the list of invalid codes
+ Assert.True(json.TryGetProperty("invalidCodes", out var invalidCodes));
+ var codes = invalidCodes.EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Contains("modulo:fake:accion", codes);
+ }
+
+ // PO-04: Mismo código en grant Y deny → 400, "grant-deny-overlap"
+ [Fact]
+ public async Task PutOverrides_GrantDenyOverlap_Returns400_Overlap()
+ {
+ var adminId = await GetAdminIdAsync();
+
+ var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = new[] { "textos:editar" }, deny = new[] { "textos:editar" } });
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+ var title = json.GetProperty("title").GetString();
+ Assert.Equal("grant-deny-overlap", title);
+
+ Assert.True(json.TryGetProperty("overlap", out var overlap));
+ var codes = overlap.EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Contains("textos:editar", codes);
+ }
+
+ // PO-05: Usuario inexistente → 404
+ [Fact]
+ public async Task PutOverrides_NonExistentUser_Returns404()
+ {
+ var request = BuildRequest(HttpMethod.Put, "/api/v1/users/99999/permisos/overrides",
+ body: new { grant = Array.Empty(), deny = Array.Empty() });
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ // PO-06: Sin permiso → 403
+ [Fact]
+ public async Task PutOverrides_WithoutRequiredPermission_Returns403()
+ {
+ var cajeroToken = await CreateCajeroAndGetTokenAsync("cajero_po06");
+ try
+ {
+ var adminId = await GetAdminIdAsync();
+ var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = Array.Empty(), deny = Array.Empty() },
+ token: cajeroToken);
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+ finally
+ {
+ await DeleteUsuarioAsync("cajero_po06");
+ }
+ }
+
+ // PO-07: Sin auth → 401
+ [Fact]
+ public async Task PutOverrides_WithoutAuth_Returns401()
+ {
+ var adminId = await GetAdminIdAsync();
+ var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides")
+ {
+ Content = JsonContent.Create(new { grant = Array.Empty(), deny = Array.Empty() })
+ };
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ // PO-08: Body JSON malformado → 400
+ [Fact]
+ public async Task PutOverrides_MalformedBody_Returns400()
+ {
+ var adminId = await GetAdminIdAsync();
+ var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides")
+ {
+ Headers = { Authorization = new AuthenticationHeaderValue("Bearer", _adminToken) },
+ Content = new StringContent("{grant: not-json", System.Text.Encoding.UTF8, "application/json")
+ };
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ // PO-09: PUT idempotente — dos veces el mismo body → estado igual
+ [Fact]
+ public async Task PutOverrides_Idempotent_SameBodyTwice_StateUnchanged()
+ {
+ var adminId = await GetAdminIdAsync();
+ var body = new { grant = new[] { "textos:editar" }, deny = Array.Empty() };
+
+ var r1 = await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body));
+ var r2 = await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides", body));
+
+ Assert.Equal(HttpStatusCode.OK, r1.StatusCode);
+ Assert.Equal(HttpStatusCode.OK, r2.StatusCode);
+
+ var stored = await GetPermisosJsonAsync(adminId);
+ var parsed = JsonDocument.Parse(stored).RootElement;
+ var grant = parsed.GetProperty("grant").EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Single(grant);
+ Assert.Equal("textos:editar", grant[0]);
+ }
+
+ // PO-10: PUT con grants vacíos (reset overrides) → effective == rolPermisos
+ [Fact]
+ public async Task PutOverrides_EmptyPayload_ResetsOverrides()
+ {
+ var adminId = await GetAdminIdAsync();
+
+ // First set some overrides
+ await _client.SendAsync(BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = new[] { "textos:editar" }, deny = Array.Empty() }));
+
+ // Then reset
+ var resetRequest = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = Array.Empty(), deny = Array.Empty() });
+ var response = await _client.SendAsync(resetRequest);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var stored = await GetPermisosJsonAsync(adminId);
+ Assert.Equal("""{"grant":[],"deny":[]}""", stored);
+ }
+
+ // PO-11: Response de PUT tiene shape {rolPermisos, overrides, effective}
+ [Fact]
+ public async Task PutOverrides_ResponseHasCorrectShape()
+ {
+ var adminId = await GetAdminIdAsync();
+ await SetPermisosJsonAsync(adminId, """{"grant":[],"deny":[]}""");
+
+ var request = BuildRequest(HttpMethod.Put, $"/api/v1/users/{adminId}/permisos/overrides",
+ body: new { grant = Array.Empty(), deny = Array.Empty() });
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+ Assert.True(json.TryGetProperty("rolPermisos", out _));
+ Assert.True(json.TryGetProperty("overrides", out var overrides));
+ Assert.True(overrides.TryGetProperty("grant", out _));
+ Assert.True(overrides.TryGetProperty("deny", out _));
+ Assert.True(json.TryGetProperty("effective", out _));
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private async Task CreateCajeroAndGetTokenAsync(string username)
+ {
+ // Seed a cajero user without administracion:usuarios:gestionar
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ await conn.ExecuteAsync("""
+ IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES (@Username, '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
+ 'Cajero', 'Test', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
+ """, new { Username = username });
+
+ return await GetBearerTokenAsync(username, "@Diego550@");
+ }
+
+ private async Task DeleteUsuarioAsync(string username)
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ // Must delete RefreshTokens first due to FK constraint
+ await conn.ExecuteAsync("""
+ DELETE rt FROM dbo.RefreshToken rt
+ INNER JOIN dbo.Usuario u ON rt.UsuarioId = u.Id
+ WHERE u.Username = @Username
+ """, new { Username = username });
+ await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
index f96306c..e009f00 100644
--- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
@@ -150,6 +150,135 @@ public class LoginCommandHandlerTests
Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos);
}
+ // ── UDT-009: PermisoResolver integration in LoginCommandHandler ─────────────
+
+ // L-01: Admin sin overrides → permisos = exactamente los del rol
+ [Fact]
+ public async Task Handle_AdminNoOverrides_PermisosEqualRolPermisos()
+ {
+ // Arrange
+ var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin",
+ """{"grant":[],"deny":[]}""", true);
+ _repository.GetByUsernameAsync("admin").Returns(usuario);
+ _hasher.Verify("pass", "$2a$12$hash").Returns(true);
+ _jwtService.GenerateAccessToken(usuario).Returns("jwt");
+
+ var adminPermisos = Enumerable.Range(1, 21)
+ .Select(i => MakePermiso(i, $"perm:mod{i}:accion{i}"))
+ .ToList();
+ _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any())
+ .Returns(adminPermisos.AsReadOnly() as IReadOnlyList);
+
+ // Act
+ var result = await _handler.Handle(new LoginCommand("admin", "pass"));
+
+ // Assert
+ Assert.Equal(21, result.Usuario.Permisos.Length);
+ }
+
+ // L-02: Cajero + grant nuevo permiso → result contiene permiso del grant
+ [Fact]
+ public async Task Handle_CajeroWithGrant_PermisosContainGrantedPermiso()
+ {
+ // Arrange
+ var usuario = new Usuario(2, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
+ """{"grant":["textos:editar"],"deny":[]}""", true);
+ _repository.GetByUsernameAsync("cajero1").Returns(usuario);
+ _hasher.Verify("pass", "$2a$12$hash").Returns(true);
+ _jwtService.GenerateAccessToken(usuario).Returns("jwt");
+
+ var cajeroPermisos = new List
+ {
+ MakePermiso(10, "ventas:contado:crear"),
+ MakePermiso(11, "ventas:contado:modificar"),
+ MakePermiso(12, "ventas:contado:cobrar"),
+ MakePermiso(13, "ventas:contado:facturar"),
+ };
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList);
+
+ // Act
+ var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));
+
+ // Assert
+ Assert.Equal(5, result.Usuario.Permisos.Length);
+ Assert.Contains("textos:editar", result.Usuario.Permisos);
+ Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
+ }
+
+ // L-03: Cajero + deny uno del rol → result NO contiene el permiso denegado
+ [Fact]
+ public async Task Handle_CajeroWithDeny_PermisosExcludeDeniedPermiso()
+ {
+ // Arrange
+ var usuario = new Usuario(3, "cajero2", "$2a$12$hash", "C", "B", null, "cajero",
+ """{"grant":[],"deny":["ventas:contado:cobrar"]}""", true);
+ _repository.GetByUsernameAsync("cajero2").Returns(usuario);
+ _hasher.Verify("pass", "$2a$12$hash").Returns(true);
+ _jwtService.GenerateAccessToken(usuario).Returns("jwt");
+
+ var cajeroPermisos = new List
+ {
+ MakePermiso(10, "ventas:contado:crear"),
+ MakePermiso(11, "ventas:contado:modificar"),
+ MakePermiso(12, "ventas:contado:cobrar"),
+ MakePermiso(13, "ventas:contado:facturar"),
+ };
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList);
+
+ // Act
+ var result = await _handler.Handle(new LoginCommand("cajero2", "pass"));
+
+ // Assert — ventas:contado:cobrar was denied
+ Assert.Equal(3, result.Usuario.Permisos.Length);
+ Assert.DoesNotContain("ventas:contado:cobrar", result.Usuario.Permisos);
+ Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
+ }
+
+ // L-04: DTO always returns Permisos as string[] — not grant/deny shape
+ [Fact]
+ public async Task Handle_AlwaysReturnsPermisosAsStringArray_NotGrantDenyShape()
+ {
+ var usuario = new Usuario(1, "admin", "$2a$12$hash", "A", "B", null, "admin",
+ """{"grant":["extra:perm"],"deny":[]}""", true);
+ _repository.GetByUsernameAsync("admin").Returns(usuario);
+ _hasher.Verify("pass", "$2a$12$hash").Returns(true);
+ _jwtService.GenerateAccessToken(usuario).Returns("jwt");
+ _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any())
+ .Returns(new List().AsReadOnly() as IReadOnlyList);
+
+ var result = await _handler.Handle(new LoginCommand("admin", "pass"));
+
+ // Must be string[] — no grant/deny wrapping
+ Assert.IsType(result.Usuario.Permisos);
+ }
+
+ // L-05: Legacy PermisosJson "[]" → treated as Empty → permisos = only rol
+ [Fact]
+ public async Task Handle_LegacyPermisosJson_EmptyArray_TreatedAsEmpty()
+ {
+ var usuario = new Usuario(1, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
+ "[]", true);
+ _repository.GetByUsernameAsync("cajero1").Returns(usuario);
+ _hasher.Verify("pass", "$2a$12$hash").Returns(true);
+ _jwtService.GenerateAccessToken(usuario).Returns("jwt");
+
+ var cajeroPermisos = new List
+ {
+ MakePermiso(10, "ventas:contado:crear"),
+ MakePermiso(11, "ventas:contado:cobrar"),
+ };
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList);
+
+ var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));
+
+ Assert.Equal(2, result.Usuario.Permisos.Length);
+ Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
+ Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos);
+ }
+
// Helper: construir Permiso via ForRead para tests
private static Permiso MakePermiso(int id, string codigo) =>
Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow);
diff --git a/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs b/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
new file mode 100644
index 0000000..bb78d3f
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
@@ -0,0 +1,134 @@
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Tests.Common;
+
+///
+/// SUITE-B-RESOLVER — R-01 a R-09 (UDT-009)
+/// Unit tests for PermisoResolver.Resolve static helper.
+/// Pure unit: no DB, no mocks.
+///
+public sealed class PermisoResolverTests
+{
+ // R-01: Override vacío → effective = solo rol sin cambios
+ [Fact]
+ public void Resolve_EmptyOverride_ReturnsRolPermisosUnchanged()
+ {
+ var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-02: Grant nuevo permiso → se agrega al set
+ [Fact]
+ public void Resolve_GrantNewPermiso_AddsToEffective()
+ {
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Contains("C", result);
+ Assert.Equal(3, result.Count);
+ }
+
+ // R-03: Deny permiso del rol → se quita del set
+ [Fact]
+ public void Resolve_DenyRolPermiso_RemovesFromEffective()
+ {
+ var overrides = new PermisosOverride(Grant: [], Deny: ["A"]);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.DoesNotContain("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(1, result.Count);
+ }
+
+ // R-04: Grant duplicado (ya en rol) → idempotente, no duplicados
+ [Fact]
+ public void Resolve_GrantDuplicated_Idempotent()
+ {
+ var overrides = new PermisosOverride(Grant: ["B"], Deny: []);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count); // no duplicates
+ }
+
+ // R-05: Deny código inexistente en rol → no-op
+ [Fact]
+ public void Resolve_DenyNonExistentCode_NoOp()
+ {
+ var overrides = new PermisosOverride(Grant: [], Deny: ["X"]);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-06: Grant + Deny combinados
+ [Fact]
+ public void Resolve_GrantAndDeny_Combined()
+ {
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: ["A"]);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.DoesNotContain("A", result);
+ Assert.Contains("B", result);
+ Assert.Contains("C", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-07: PermisosOverride.Empty literal → mismo que rol
+ [Fact]
+ public void Resolve_EmptyLiteral_ReturnsRolPermisosOnly()
+ {
+ var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-08: Rol vacío + grant → effective = solo el grant
+ [Fact]
+ public void Resolve_EmptyRol_WithGrant_ReturnsGrant()
+ {
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
+
+ var result = PermisoResolver.Resolve([], overrides);
+
+ Assert.Single(result);
+ Assert.Contains("C", result);
+ }
+
+ // R-09: Rol vacío + sin overrides → effective vacío
+ [Fact]
+ public void Resolve_EmptyRol_EmptyOverrides_ReturnsEmpty()
+ {
+ var result = PermisoResolver.Resolve([], PermisosOverride.Empty);
+
+ Assert.Empty(result);
+ }
+
+ // Extra: Deny gana sobre grant explícito (defense en runtime — validator lo bloquea antes)
+ [Fact]
+ public void Resolve_DenyWinsOver_ExplicitGrant()
+ {
+ // Mismo código en grant y deny → deny gana (algoritmo: grant primero, deny al final)
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: ["C"]);
+
+ var result = PermisoResolver.Resolve(["A"], overrides);
+
+ Assert.DoesNotContain("C", result);
+ Assert.Contains("A", result);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs b/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs
new file mode 100644
index 0000000..fa95d82
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs
@@ -0,0 +1,116 @@
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Tests.Common;
+
+///
+/// SUITE-B-PERMISOS-OVERRIDE-PARSING — P-01 a P-08 (UDT-009)
+/// Unit tests for PermisosOverride.FromJson parsing logic.
+///
+public sealed class PermisosOverrideParsingTests
+{
+ // P-01: JSON válido con grant y deny → record correcto
+ [Fact]
+ public void FromJson_ValidGrantAndDeny_ReturnsParsedRecord()
+ {
+ const string json = """{"grant":["textos:editar"],"deny":["ventas:contado:cobrar"]}""";
+
+ var result = PermisosOverride.FromJson(json);
+
+ Assert.Single(result.Grant);
+ Assert.Equal("textos:editar", result.Grant[0]);
+ Assert.Single(result.Deny);
+ Assert.Equal("ventas:contado:cobrar", result.Deny[0]);
+ }
+
+ // P-02: JSON vacío canónico → equivalente a Empty
+ [Fact]
+ public void FromJson_EmptyCanonical_ReturnsEmpty()
+ {
+ const string json = """{"grant":[],"deny":[]}""";
+
+ var result = PermisosOverride.FromJson(json);
+
+ Assert.Empty(result.Grant);
+ Assert.Empty(result.Deny);
+ }
+
+ // P-03: Legacy "[]" → Empty (backward compat)
+ [Fact]
+ public void FromJson_LegacyEmptyArray_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson("[]");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-04: Legacy '["*"]' → Empty (backward compat)
+ [Fact]
+ public void FromJson_LegacyWildcard_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson("""["*"]""");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-05: null → Empty
+ [Fact]
+ public void FromJson_Null_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson(null);
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-06a: string vacío → Empty
+ [Fact]
+ public void FromJson_EmptyString_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson(string.Empty);
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-06b: whitespace → Empty
+ [Fact]
+ public void FromJson_Whitespace_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson(" ");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-07: JSON malformado → Empty (tolerante en runtime)
+ [Fact]
+ public void FromJson_MalformedJson_ReturnsEmpty()
+ {
+ // Nota: FromJson es tolerante — catch(JsonException) → Empty.
+ // Ver tasks note 2: "P-07/P-08 verifican que JSON malformado → Empty (no FormatException)"
+ var result = PermisosOverride.FromJson("{grant:[");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-08: JSON de tipo incorrecto (número) → Empty (tolerante)
+ [Fact]
+ public void FromJson_WrongJsonType_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson("42");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // Extra: ToJson produce JSON re-parseable con shape correcto
+ [Fact]
+ public void ToJson_ProducesCanonicalJson()
+ {
+ var overrides = new PermisosOverride(
+ Grant: new[] { "textos:editar" },
+ Deny: new[] { "ventas:contado:cobrar" });
+
+ var json = overrides.ToJson();
+ var reparsed = PermisosOverride.FromJson(json);
+
+ Assert.Equal(overrides.Grant, reparsed.Grant);
+ Assert.Equal(overrides.Deny, reparsed.Deny);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
index cea3266..c080c8f 100644
--- a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
@@ -55,6 +55,59 @@ public class JwtServiceTests : IDisposable
Assert.Contains("sigcm2.web", parsed.Audiences); // aud
Assert.Contains(parsed.Claims, c => c.Type == "name" && c.Value == "admin");
Assert.Contains(parsed.Claims, c => c.Type == "rol" && c.Value == "admin");
+
+ // J-01 (UDT-009): token must NOT contain 'permisos' claim post-UDT-009
+ Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
+ }
+
+ // J-01: token post-UDT-009 does NOT have 'permisos' claim
+ [Fact]
+ public void GenerateAccessToken_DoesNotContainPermisosClaim()
+ {
+ var usuario = MakeUsuario();
+ var token = _jwtService.GenerateAccessToken(usuario);
+
+ var handler = new JwtSecurityTokenHandler();
+ var parsed = handler.ReadJwtToken(token);
+
+ Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
+ }
+
+ // J-02: claims present are sub, jti, name, rol (+ iat/exp/nbf) — no extras
+ [Fact]
+ public void GenerateAccessToken_HasExactlyExpectedClaims_NoPermisos()
+ {
+ var usuario = MakeUsuario();
+ var token = _jwtService.GenerateAccessToken(usuario);
+
+ var handler = new JwtSecurityTokenHandler();
+ var parsed = handler.ReadJwtToken(token);
+
+ // Must have sub, name, rol, jti
+ Assert.Contains(parsed.Claims, c => c.Type == "sub");
+ Assert.Contains(parsed.Claims, c => c.Type == "name");
+ Assert.Contains(parsed.Claims, c => c.Type == "rol");
+ Assert.Contains(parsed.Claims, c => c.Type == "jti");
+
+ // Must NOT have permisos
+ Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
+ }
+
+ // J-03: MakeUsuario with '["*"]' PermisosJson → token still has no 'permisos' claim
+ [Fact]
+ public void GenerateAccessToken_WithLegacyPermisosJson_NoPermisosClaim()
+ {
+ // MakeUsuario already uses '[\"*\"]' — this explicitly tests J-03
+ var usuario = MakeUsuario();
+ Assert.Equal("[\"*\"]", usuario.PermisosJson); // verify the helper
+
+ var token = _jwtService.GenerateAccessToken(usuario);
+
+ var handler = new JwtSecurityTokenHandler();
+ var parsed = handler.ReadJwtToken(token);
+
+ // Post-UDT-009: JwtService ignores PermisosJson entirely — no claim emitted
+ Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
}
// Scenario: token is verifiable with the public key
diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs
new file mode 100644
index 0000000..6ffe69a
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs
@@ -0,0 +1,131 @@
+using Dapper;
+using Microsoft.Data.SqlClient;
+using Respawn;
+using SIGCM2.Infrastructure.Persistence;
+
+namespace SIGCM2.Application.Tests.Integration;
+
+///
+/// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009).
+/// Uses SIGCM2_Test database directly.
+///
+[Collection("Database")]
+public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ private SqlConnection _connection = null!;
+ private Respawner _respawner = null!;
+ private UsuarioRepository _repository = null!;
+
+ public async Task InitializeAsync()
+ {
+ _connection = new SqlConnection(ConnectionString);
+ await _connection.OpenAsync();
+
+ _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
+ {
+ DbAdapter = DbAdapter.SqlServer,
+ TablesToIgnore =
+ [
+ new Respawn.Graph.Table("dbo", "Rol"),
+ new Respawn.Graph.Table("dbo", "Permiso"),
+ new Respawn.Graph.Table("dbo", "RolPermiso"),
+ ]
+ });
+
+ await _respawner.ResetAsync(_connection);
+ await SeedRolCanonicalAsync();
+
+ var factory = new SqlConnectionFactory(ConnectionString);
+ _repository = new UsuarioRepository(factory);
+
+ // Seed a test user
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('testuser', '$2a$12$hash', 'Test', 'User', 'cajero', '{"grant":[],"deny":[]}', 1, 0)
+ """);
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_connection is not null)
+ {
+ await _respawner.ResetAsync(_connection);
+ await _connection.CloseAsync();
+ await _connection.DisposeAsync();
+ }
+ }
+
+ // UPJ-01: UpdatePermisosJsonAsync persists PermisosJson and FechaModificacion
+ [Fact]
+ public async Task UpdatePermisosJsonAsync_PersistsJsonAndFechaModificacion()
+ {
+ // Arrange
+ var userId = await _connection.QuerySingleAsync(
+ "SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
+ var newJson = """{"grant":["textos:editar"],"deny":[]}""";
+ var fechaMod = DateTime.UtcNow;
+
+ // Act
+ await _repository.UpdatePermisosJsonAsync(userId, newJson, fechaMod);
+
+ // Assert
+ var row = await _connection.QuerySingleAsync<(string PermisosJson, DateTime? FechaModificacion)>(
+ "SELECT PermisosJson, FechaModificacion FROM dbo.Usuario WHERE Id = @Id",
+ new { Id = userId });
+
+ Assert.Equal(newJson, row.PermisosJson);
+ Assert.NotNull(row.FechaModificacion);
+ // Allow 2-second tolerance for DB round-trip
+ Assert.True(
+ Math.Abs((row.FechaModificacion!.Value - fechaMod).TotalSeconds) < 2,
+ $"FechaModificacion {row.FechaModificacion} is too far from {fechaMod}");
+ }
+
+ // UPJ-02: UpdatePermisosJsonAsync with non-existent id → no throw (UPDATE affects 0 rows)
+ [Fact]
+ public async Task UpdatePermisosJsonAsync_NonExistentId_NoThrow()
+ {
+ // Should not throw — UPDATE with 0 rows affected is a no-op
+ await _repository.UpdatePermisosJsonAsync(99999, """{"grant":[],"deny":[]}""", DateTime.UtcNow);
+ }
+
+ // UPJ-03: GetByIdAsync after update reflects new PermisosJson
+ [Fact]
+ public async Task UpdatePermisosJsonAsync_GetByIdReflectsChange()
+ {
+ // Arrange
+ var userId = await _connection.QuerySingleAsync(
+ "SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'");
+ var newJson = """{"grant":["pauta:azanu:ver"],"deny":["ventas:contado:cobrar"]}""";
+
+ // Act
+ await _repository.UpdatePermisosJsonAsync(userId, newJson, DateTime.UtcNow);
+
+ // Assert — read back through the repo
+ var usuario = await _repository.GetByIdAsync(userId);
+
+ Assert.NotNull(usuario);
+ Assert.Equal(newJson, usuario!.PermisosJson);
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private async Task SeedRolCanonicalAsync()
+ {
+ await _connection.ExecuteAsync("""
+ SET QUOTED_IDENTIFIER ON;
+ MERGE dbo.Rol AS t
+ USING (VALUES
+ ('admin', N'Administrador', N'Supervisor total'),
+ ('cajero', N'Cajero', N'Mostrador contado')
+ ) AS s (Codigo, Nombre, Descripcion)
+ ON t.Codigo = s.Codigo
+ WHEN NOT MATCHED BY TARGET THEN
+ INSERT (Codigo, Nombre, Descripcion, Activo)
+ VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
+ """);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
new file mode 100644
index 0000000..8566027
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
@@ -0,0 +1,256 @@
+using Dapper;
+using Microsoft.Data.SqlClient;
+using Respawn;
+
+namespace SIGCM2.Application.Tests.Integration;
+
+///
+/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
+/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
+/// Uses SIGCM2_Test database directly.
+///
+[Collection("Database")]
+public sealed class V009MigrationTests : IAsyncLifetime
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ private SqlConnection _connection = null!;
+ private Respawner _respawner = null!;
+
+ public async Task InitializeAsync()
+ {
+ _connection = new SqlConnection(ConnectionString);
+ await _connection.OpenAsync();
+
+ _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
+ {
+ DbAdapter = DbAdapter.SqlServer,
+ TablesToIgnore =
+ [
+ new Respawn.Graph.Table("dbo", "Rol"),
+ new Respawn.Graph.Table("dbo", "Permiso"),
+ new Respawn.Graph.Table("dbo", "RolPermiso"),
+ ]
+ });
+
+ await _respawner.ResetAsync(_connection);
+ await SeedRolAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_connection is not null)
+ {
+ await _connection.CloseAsync();
+ await _connection.DisposeAsync();
+ }
+ }
+
+ // M-01: migration file exists on filesystem
+ [Fact]
+ public void MigrationFile_Exists()
+ {
+ // Walk up from test assembly looking for the repo root
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ string? repoRoot = null;
+
+ while (dir is not null)
+ {
+ if (dir.GetFiles("SIGCM2.slnx").Length > 0)
+ {
+ repoRoot = dir.FullName;
+ break;
+ }
+ dir = dir.Parent;
+ }
+
+ // Known fallback
+ if (repoRoot is null && Directory.Exists(@"E:\SIG-CM2.0"))
+ repoRoot = @"E:\SIG-CM2.0";
+
+ Assert.NotNull(repoRoot);
+ var migrationPath = Path.Combine(repoRoot!, "database", "migrations", "V009__activate_permisos_overrides.sql");
+ Assert.True(File.Exists(migrationPath), $"Migration file not found at: {migrationPath}");
+ }
+
+ // M-02: re-run idempotent (EnsureV009SchemaAsync twice → no error)
+ [Fact]
+ public async Task EnsureV009SchemaAsync_IsIdempotent()
+ {
+ await EnsureV009SchemaAsync();
+ await EnsureV009SchemaAsync(); // second call must not throw
+ }
+
+ // M-03: after migration, DEFAULT constraint is the new shape
+ [Fact]
+ public async Task EnsureV009SchemaAsync_DefaultConstraint_IsNewShape()
+ {
+ await EnsureV009SchemaAsync();
+
+ const string sql = """
+ SELECT object_definition(default_object_id) AS DefaultDef
+ FROM sys.columns
+ WHERE object_id = OBJECT_ID('dbo.Usuario')
+ AND name = 'PermisosJson'
+ """;
+
+ var definition = await _connection.QuerySingleOrDefaultAsync(sql);
+
+ Assert.NotNull(definition);
+ Assert.Contains(@"{""grant"":[]", definition);
+ Assert.Contains(@"""deny"":[]}", definition);
+ }
+
+ // M-04: rows with '[]' are migrated to new shape
+ [Fact]
+ public async Task EnsureV009SchemaAsync_MigratesLegacyEmptyArray()
+ {
+ await EnsureV009SchemaAsync();
+
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0)
+ """);
+
+ // Run migration again to migrate the newly inserted row
+ await EnsureV009SchemaAsync();
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // M-05: rows with '["*"]' are migrated
+ [Fact]
+ public async Task EnsureV009SchemaAsync_MigratesLegacyWildcard()
+ {
+ await EnsureV009SchemaAsync();
+
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0)
+ """);
+
+ await EnsureV009SchemaAsync();
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // M-06: the migration UPDATE statement includes NULL / empty-string conditions
+ // The column is NOT NULL (V001 constraint), so we verify the UPDATE logic covers
+ // all the WHERE conditions syntactically and that rows with '' are migrated.
+ [Fact]
+ public async Task EnsureV009SchemaAsync_MigratesEmptyStringRows()
+ {
+ // First apply V009 schema so the constraint is updated
+ await EnsureV009SchemaAsync();
+
+ // Temporarily drop and re-add without the DEFAULT so we can insert ''
+ await _connection.ExecuteAsync("""
+ IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ """);
+
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0)
+ """);
+
+ // Re-apply V009 (which restores constraint and migrates '' rows)
+ await EnsureV009SchemaAsync();
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // M-07: admin seed in SqlTestFixture uses new shape
+ [Fact]
+ public async Task SqlTestFixture_SeedAdmin_UsesNewPermisosJsonShape()
+ {
+ await EnsureV009SchemaAsync();
+
+ // Seed admin as TestFixture does post-V009
+ await _connection.ExecuteAsync("""
+ IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES (
+ 'admin',
+ '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
+ 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
+ )
+ """);
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private async Task SeedRolAsync()
+ {
+ await _connection.ExecuteAsync("""
+ SET QUOTED_IDENTIFIER ON;
+ MERGE dbo.Rol AS t
+ USING (VALUES ('admin', N'Administrador', N'Supervisor total'))
+ AS s (Codigo, Nombre, Descripcion)
+ ON t.Codigo = s.Codigo
+ WHEN NOT MATCHED BY TARGET THEN
+ INSERT (Codigo, Nombre, Descripcion, Activo) VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
+ """);
+ }
+
+ ///
+ /// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
+ ///
+ private async Task EnsureV009SchemaAsync()
+ {
+ const string dropConstraint = """
+ IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ END
+ """;
+
+ const string addConstraint = """
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario
+ ADD CONSTRAINT DF_Usuario_Permisos
+ DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
+ END
+ """;
+
+ const string migrateRows = """
+ UPDATE dbo.Usuario
+ SET PermisosJson = '{"grant":[],"deny":[]}'
+ WHERE PermisosJson IN ('[]', '["*"]', '')
+ OR PermisosJson IS NULL
+ OR LTRIM(RTRIM(PermisosJson)) = ''
+ """;
+
+ await _connection.ExecuteAsync(dropConstraint);
+ await _connection.ExecuteAsync(addConstraint);
+ await _connection.ExecuteAsync(migrateRows);
+ }
+}
diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
index 783a672..2bf7c92 100644
--- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs
+++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
@@ -29,6 +29,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB
await EnsureV008SchemaAsync();
+ // V009: update PermisosJson DEFAULT constraint and migrate legacy rows
+ await EnsureV009SchemaAsync();
+
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -215,6 +218,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
private async Task SeedAdminAsync()
{
+ // V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]'
const string sql = """
SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
@@ -222,9 +226,53 @@ public sealed class SqlTestFixture : IAsyncLifetime
VALUES (
'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
- 'Administrador', 'Sistema', 'admin', '["*"]', 1, 0
+ 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
);
""";
await _connection.ExecuteAsync(sql);
}
+
+ ///
+ /// Applies V009 schema changes idempotently to the test database.
+ /// Mirrors V009__activate_permisos_overrides.sql.
+ /// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows.
+ ///
+ private async Task EnsureV009SchemaAsync()
+ {
+ const string dropConstraint = """
+ IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ END
+ """;
+
+ const string addConstraint = """
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario
+ ADD CONSTRAINT DF_Usuario_Permisos
+ DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
+ END
+ """;
+
+ const string migrateRows = """
+ UPDATE dbo.Usuario
+ SET PermisosJson = '{"grant":[],"deny":[]}'
+ WHERE PermisosJson IN ('[]', '["*"]', '')
+ OR PermisosJson IS NULL
+ OR LTRIM(RTRIM(PermisosJson)) = ''
+ """;
+
+ await _connection.ExecuteAsync(dropConstraint);
+ await _connection.ExecuteAsync(addConstraint);
+ await _connection.ExecuteAsync(migrateRows);
+ }
}