Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs
dmolinari e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00

483 lines
21 KiB
C#

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;
/// <summary>
/// Integration tests for POST api/v1/users (UDT-003).
/// These tests run against SIGCM2_Test database via TestWebAppFactory.
/// TestWebAppFactory is shared across the whole "ApiIntegration" collection
/// (see ApiIntegrationCollection) — one factory, one RSA singleton, one DB state.
/// </summary>
[Collection("ApiIntegration")]
public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
{
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
private const string Endpoint = "/api/v1/users";
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly HttpClient _client;
public CreateUsuarioEndpointTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
// IAsyncLifetime: reset DB state before each test class execution
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
// ---------------------------------------------------------------------------
// Helper: Authenticate and return Bearer token for the given credentials
// ---------------------------------------------------------------------------
private async Task<string> 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<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
private static object ValidCreateBody(string username = "testuser") => new
{
username,
password = "Test1234!",
nombre = "Test",
apellido = "Usuario",
email = (string?)null,
rol = "cajero"
};
// ---------------------------------------------------------------------------
// Helper: seed a vendedor user directly via SQL (avoid calling the endpoint)
// ---------------------------------------------------------------------------
private static async Task SeedVendedorAsync(string username, string passwordHash)
{
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)
VALUES (@Username, @Hash, 'Vendedor', 'Test', 'cajero', '[]', 1)
""",
new { Username = username, Hash = passwordHash });
}
// ---------------------------------------------------------------------------
// Helper: clean up a created user after test
// ---------------------------------------------------------------------------
private static async Task DeleteUsuarioAsync(string username)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
// Must delete RefreshTokens first — FK constraint FK_RefreshToken_Usuario
await conn.ExecuteAsync("""
DELETE rt FROM dbo.RefreshToken rt
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
WHERE u.Username = @Username
""", new { Username = username });
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
}
// ---------------------------------------------------------------------------
// Scenario 1: 401 — no Authorization header
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithoutAuthHeader_Returns401()
{
var request = BuildRequest(HttpMethod.Post, Endpoint, ValidCreateBody());
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
// ---------------------------------------------------------------------------
// Scenario 2: 403 — valid token but role is not admin (vendedor)
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithVendedorRole_Returns403()
{
// Use admin to create a vendedor, then login as that vendedor and attempt to create another user
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
// Create vendedor user via the endpoint (as admin)
const string testVendedor = "vendedor_role_test";
using var createRequest = BuildRequest(HttpMethod.Post, Endpoint, new
{
username = testVendedor,
password = "@Test1234@",
nombre = "Vendedor",
apellido = "Test",
email = (string?)null,
rol = "cajero"
}, adminToken);
var createResp = await _client.SendAsync(createRequest);
// If already exists (test re-run), ignore 409
if (createResp.StatusCode != HttpStatusCode.Created && createResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Unexpected status seeding vendedor: {createResp.StatusCode}");
// Login as vendedor
var vendedorToken = await GetBearerTokenAsync(testVendedor, "@Test1234@");
// Attempt to create user with vendedor token
using var request = BuildRequest(HttpMethod.Post, Endpoint, ValidCreateBody("another_user"), vendedorToken);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
// Cleanup
await DeleteUsuarioAsync(testVendedor);
}
// ---------------------------------------------------------------------------
// Scenario 3: 400 — invalid body (username vacío, password corta, rol fuera whitelist)
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithInvalidBody_Returns400()
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var request = BuildRequest(HttpMethod.Post, Endpoint, new
{
username = "", // invalid: empty
password = "abc", // invalid: too short
nombre = "Test",
apellido = "Usuario",
email = (string?)null,
rol = "superadmin" // invalid: not in whitelist
}, adminToken);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("errors", out _), "Response must contain 'errors' field");
}
// ---------------------------------------------------------------------------
// Scenario 4: 201 — admin autenticado crea usuario, body contiene Id/username/rol, NO contiene passwordHash
// + verifica en BD: Activo=1, PermisosJson='[]'
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithAdminToken_Returns201WithCorrectShape()
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string newUsername = "integration_test_user_201";
using var request = BuildRequest(HttpMethod.Post, Endpoint, new
{
username = newUsername,
password = "Secure1234!",
nombre = "Integration",
apellido = "Test",
email = "integration@test.com",
rol = "cajero"
}, adminToken);
try
{
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// Location header must be set
Assert.NotNull(response.Headers.Location);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
// Must have Id, username, rol
Assert.True(json.TryGetProperty("id", out var id), "Response must contain 'id'");
Assert.True(json.TryGetProperty("username", out var username), "Response must contain 'username'");
Assert.True(json.TryGetProperty("rol", out var rol), "Response must contain 'rol'");
Assert.True(id.GetInt32() > 0, "'id' must be positive");
Assert.Equal(newUsername, username.GetString());
Assert.Equal("cajero", rol.GetString());
// Must NOT contain passwordHash
Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'");
Assert.False(json.TryGetProperty("PasswordHash", out _), "Response must NOT leak 'PasswordHash'");
// Verify in DB: Activo=1, PermisosJson='[]'
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
var row = await conn.QuerySingleAsync<(bool Activo, string PermisosJson)>(
"SELECT Activo, PermisosJson FROM dbo.Usuario WHERE Username = @Username",
new { Username = newUsername });
Assert.True(row.Activo, "Activo should be true");
// V009 (UDT-009): ForCreation now defaults to canonical shape {"grant":[],"deny":[]}
Assert.Equal("""{"grant":[],"deny":[]}""", row.PermisosJson);
}
finally
{
await DeleteUsuarioAsync(newUsername);
}
}
// ---------------------------------------------------------------------------
// Scenario 5: 409 — username duplicado
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_DuplicateUsername_Returns409()
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string username = "duplicate_test_user";
try
{
// First creation — should succeed
using var first = BuildRequest(HttpMethod.Post, Endpoint, new
{
username,
password = "Secure1234!",
nombre = "First",
apellido = "User",
email = (string?)null,
rol = "cajero"
}, adminToken);
var firstResp = await _client.SendAsync(first);
Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode);
// Second creation with same username — should 409
using var second = BuildRequest(HttpMethod.Post, Endpoint, new
{
username,
password = "Other5678!",
nombre = "Second",
apellido = "User",
email = (string?)null,
rol = "reportes"
}, adminToken);
var secondResp = await _client.SendAsync(second);
Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode);
var json = await secondResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("error", out var error), "409 response must contain 'error'");
Assert.Equal("username_taken", error.GetString());
}
finally
{
await DeleteUsuarioAsync(username);
}
}
// ---------------------------------------------------------------------------
// Scenario 6: 409 race — simulación de UQ violation via direct INSERT
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_UqViolationFromRace_Returns409WithUsernameTaken()
{
// Simulate the race: seed the user directly in DB (bypassing ExistsByUsername check),
// then attempt to create via endpoint → INSERT fails with SqlException 2627 → 409.
const string username = "race_condition_user";
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
// Directly insert to simulate race (bypass handler's ExistsByUsername check)
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)
VALUES (@Username, '$2a$12$placeholder_hash_for_race_test', 'Race', 'User', 'cajero', '[]', 1)
""", new { Username = username });
try
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
// This hits ExistsByUsername first → user exists → throws UsernameAlreadyExistsException → 409
using var request = BuildRequest(HttpMethod.Post, Endpoint, new
{
username,
password = "Secure1234!",
nombre = "Race",
apellido = "User",
email = (string?)null,
rol = "cajero"
}, adminToken);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("error", out var error));
Assert.Equal("username_taken", error.GetString());
}
finally
{
await DeleteUsuarioAsync(username);
}
}
// ---------------------------------------------------------------------------
// Scenario 7: E2E — admin creates user → new user logs in successfully (200 with tokens)
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_ThenLogin_ReturnsValidTokens()
{
// Use a unique username to avoid collision with other test runs
var newUsername = $"e2e_test_{DateTime.UtcNow.Ticks % 100000}";
const string newPassword = "E2eTest1234!";
// Get admin token — gracefully skip if DB is unavailable (pre-existing infra issue)
var loginCheck = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username = AdminUsername, password = AdminPassword });
if (loginCheck.StatusCode == System.Net.HttpStatusCode.InternalServerError)
return; // DB not available in this environment — skip gracefully
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
try
{
// Step 1: Admin creates user
using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new
{
username = newUsername,
password = newPassword,
nombre = "E2E",
apellido = "Test",
email = (string?)null,
rol = "cajero"
}, adminToken);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
// Step 2: New user logs in
var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = newUsername,
password = newPassword
});
Assert.Equal(HttpStatusCode.OK, loginResp.StatusCode);
var loginJson = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(loginJson.TryGetProperty("accessToken", out var accessToken));
Assert.True(loginJson.TryGetProperty("refreshToken", out var refreshToken));
Assert.False(string.IsNullOrWhiteSpace(accessToken.GetString()), "accessToken must not be empty");
Assert.False(string.IsNullOrWhiteSpace(refreshToken.GetString()), "refreshToken must not be empty");
// Verify usuario in response
Assert.True(loginJson.TryGetProperty("usuario", out var usuario));
Assert.Equal("cajero", usuario.GetProperty("rol").GetString());
}
finally
{
await DeleteUsuarioAsync(newUsername);
}
}
// ---------------------------------------------------------------------------
// UDT-006 Scenario: 403 con ProblemDetails shape — token cajero sin permiso administracion:usuarios:gestionar
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithCajeroRole_Returns403WithProblemDetailsShape()
{
const string username = "udt006_403_shape_test";
try
{
var token = await CreateCajeroTokenAsync(username);
using var request = BuildRequest(HttpMethod.Post, Endpoint, ValidCreateBody("shape_target"), token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
// Content-Type must be application/problem+json
Assert.Contains("problem+json", response.Content.Headers.ContentType?.MediaType ?? "");
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(403, json.GetProperty("status").GetInt32());
Assert.Equal("Acceso denegado", json.GetProperty("title").GetString());
Assert.True(json.TryGetProperty("permisoRequerido", out var perm),
"Response must contain 'permisoRequerido'");
Assert.Equal("administracion:usuarios:gestionar", perm.GetString());
}
finally
{
await DeleteUsuarioAsync(username);
}
}
// Helper: create a cajero user and return its token
private async Task<string> CreateCajeroTokenAsync(string username)
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var mkUser = BuildRequest(HttpMethod.Post, Endpoint, new
{
username,
password = "Secure1234!",
nombre = "Cajero",
apellido = "Test",
email = (string?)null,
rol = "cajero"
}, adminToken);
var mkResp = await _client.SendAsync(mkUser);
if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
return await GetBearerTokenAsync(username, "Secure1234!");
}
// ---------------------------------------------------------------------------
// Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithInactiveRol_Returns400()
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string codigo = "udt004_inactive_rol";
const string testUser = "udt004_inactive_rol_user";
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Inactivo Test', 0);",
new { Codigo = codigo });
try
{
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
username = testUser,
password = "Secure1234!",
nombre = "Test",
apellido = "Inactive",
email = (string?)null,
rol = codigo
}, adminToken);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("errors", out var errors), "Response must contain 'errors'");
// Validation error should be on the Rol field
Assert.Contains(errors.EnumerateObject(), p => p.Name.Equals("Rol", StringComparison.OrdinalIgnoreCase));
}
finally
{
await DeleteUsuarioAsync(testUser);
await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
}
}
}