diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs new file mode 100644 index 00000000..da24850f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs @@ -0,0 +1,108 @@ +using MediatR; +using Tracker.Application.Governance.ArtifactFieldSchema.Commands.UpsertArtifactFieldSchema; +using Tracker.Application.Governance.GatePolicy.Commands.UpsertGatePolicy; + +namespace Tracker.Application.Governance.GovernancePackage; + +using Package = Domain.Governance.GovernancePackage.GovernancePackage; + +/// What an application did, item by item. Never a bare «ok». +public sealed record GovernancePackageApplyReport +{ + public string PackageName { get; init; } = string.Empty; + public string PackageVersion { get; init; } = string.Empty; + public IReadOnlyList AppliedGatePolicies { get; init; } = new List(); + public IReadOnlyList AppliedArtifactSchemas { get; init; } = new List(); + public IReadOnlyList Failures { get; init; } = new List(); + public bool FullyApplied => Failures.Count == 0; +} + +/// +/// GT-532 — aplica un paquete de gobernanza SOBRE EL TENANT DE LA SESIÓN. +/// +/// El destino no es un parámetro que el llamante elija: viene del contexto de sesión en la +/// capa de presentación. Un endpoint que aceptara «aplica este paquete al tenant X» sería una +/// forma de reescribir la gobernanza de otro cliente, y el paquete tampoco lleva dentro su tenant +/// de origen precisamente para que esto no se pueda hacer por accidente. +/// +/// Es idempotente porque reutiliza los upsert que ya existen, no porque compare +/// estados: aplicar dos veces el mismo paquete deja el mismo resultado. Esto también significa que +/// aplicar un paquete SOBRESCRIBE la configuración de las fases y tipos que nombra, y no toca las +/// demás. Es lo que hace que un paquete sea revisable —lo que dice es exactamente lo que cambia— +/// pero conviene decirlo, porque «aplicar» suena a fusionar y no lo es. +/// +/// No se detiene en el primer fallo. Un paquete parcialmente aplicado es un hecho que +/// hay que ver entero: pararse en el primero dejaría al tenant en un estado intermedio del que el +/// informe no dice nada. Se intenta todo y se informa de cada fallo con su fase o su tipo. +/// +public sealed record ApplyGovernancePackageCommand( + Guid TenantId, + Guid ActorId, + Package Package) : ICommand; + +internal sealed class ApplyGovernancePackageCommandHandler + : ICommandHandler +{ + private readonly IMediator _mediator; + + public ApplyGovernancePackageCommandHandler(IMediator mediator) => _mediator = mediator; + + public async Task> Handle( + ApplyGovernancePackageCommand request, CancellationToken ct) + { + var errores = request.Package.Validate(); + if (errores.Count > 0) + { + // Un paquete inválido se rechaza ENTERO antes de tocar nada. Aplicar «lo que se pueda» + // de un documento que no valida deja al tenant en un estado que nadie declaró. + return Result.Failure(string.Join("; ", errores)); + } + + var policies = new List(); + var schemas = new List(); + var failures = new List(); + + foreach (var p in request.Package.GatePolicies) + { + var result = await _mediator.Send(new UpsertGatePolicyCommand( + request.TenantId, + request.ActorId, + p.Phase, + p.Mode, + p.RequiredEvidence.Select(e => new UpsertGatePolicyEvidence(e.Type, e.Mandatory)).ToList(), + p.ApprovalStrategy, + p.ApprovalStages, + p.Approvers.Select(a => new UpsertGatePolicyApprover(a.RoleOrIdentity, a.Stage, a.Quorum)).ToList(), + p.RequiresCoreVerdict, + p.Active, + p.EvaluationCriteria.Select(c => new UpsertGatePolicyCriterion( + c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, + c.Expected.ToList(), c.Mandatory, c.Severity)).ToList()), ct); + + if (result.IsSuccess) policies.Add(p.Phase); + else failures.Add($"gate-policy:{p.Phase}: {result.Error}"); + } + + foreach (var s in request.Package.ArtifactFieldSchemas) + { + var result = await _mediator.Send(new UpsertArtifactFieldSchemaCommand( + request.TenantId, + request.ActorId, + s.ArtifactType, + s.CustomFields.Select(f => new UpsertCustomField( + f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)).ToList()), ct); + + if (result.IsSuccess) schemas.Add(s.ArtifactType); + else failures.Add($"artifact-schema:{s.ArtifactType}: {result.Error}"); + } + + return Result.Success(new GovernancePackageApplyReport + { + PackageName = request.Package.Name, + PackageVersion = request.Package.Version, + AppliedGatePolicies = policies, + AppliedArtifactSchemas = schemas, + Failures = failures, + }); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs new file mode 100644 index 00000000..282449eb --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs @@ -0,0 +1,99 @@ +using Tracker.Domain.Governance.ArtifactFieldSchema; +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Application.Governance.GovernancePackage; + +using Package = Domain.Governance.GovernancePackage.GovernancePackage; +// Alias explicitos: `GatePolicy` y `ArtifactFieldSchema` son tambien NOMBRES DE ESPACIO aqui. +using GatePolicyAggregate = Domain.Governance.GatePolicy.GatePolicy; +using ArtifactFieldSchemaAggregate = Domain.Governance.ArtifactFieldSchema.ArtifactFieldSchema; + +/// +/// GT-532 — exporta la configuración de gobernanza del tenant de la sesión como un paquete +/// portable. +/// +/// El tenant NO viaja en la petición: sale del contexto de sesión en la capa de +/// presentación. Aceptarlo como parámetro convertiría este endpoint en una forma de leer la +/// configuración de otro cliente, que es exactamente lo que el aislamiento del producto prohíbe. +/// +public sealed record ExportGovernancePackageQuery( + Guid TenantId, + string Name, + string Version, + string Description, + string ExportedFrom) : IQuery; + +internal sealed class ExportGovernancePackageQueryHandler + : IQueryHandler +{ + private readonly IGatePolicyRepository _policies; + private readonly IArtifactFieldSchemaRepository _schemas; + + public ExportGovernancePackageQueryHandler( + IGatePolicyRepository policies, IArtifactFieldSchemaRepository schemas) + { + _policies = policies; + _schemas = schemas; + } + + public async Task Handle(ExportGovernancePackageQuery request, CancellationToken ct) + { + var policies = await _policies.GetByTenantAsync(request.TenantId, ct); + var schemas = await _schemas.GetByTenantAsync(request.TenantId, ct); + + return new Package + { + Name = request.Name, + Version = request.Version, + Description = request.Description, + ExportedFrom = request.ExportedFrom, + ExportedAtUtc = DateTime.UtcNow, + // Ordenado por fase y por tipo, de forma estable: dos exportaciones del mismo estado + // deben producir el mismo documento, o nadie puede diferenciar dos paquetes ni revisar + // un cambio de gobernanza en un pull request. + GatePolicies = policies + .OrderBy(p => p.Phase, StringComparer.Ordinal) + .Select(ToPackaged) + .ToList(), + ArtifactFieldSchemas = schemas + .OrderBy(s => s.ArtifactType, StringComparer.Ordinal) + .Select(ToPackaged) + .ToList(), + }; + } + + private static PackagedGatePolicy ToPackaged(GatePolicyAggregate p) => new() + { + Phase = p.Phase, + Mode = p.Mode, + RequiredEvidence = p.RequiredEvidence + .OrderBy(e => e.Type, StringComparer.Ordinal) + .Select(e => new PackagedEvidence(e.Type, e.Mandatory)) + .ToList(), + ApprovalStrategy = p.ApprovalPolicy.Strategy, + ApprovalStages = p.ApprovalPolicy.Stages, + Approvers = p.ApprovalPolicy.Approvers + .OrderBy(a => a.Stage).ThenBy(a => a.RoleOrIdentity, StringComparer.Ordinal) + .Select(a => new PackagedApprover(a.RoleOrIdentity, a.Stage, a.Quorum)) + .ToList(), + RequiresCoreVerdict = p.RequiresCoreVerdict, + Active = p.Active, + EvaluationCriteria = p.EvaluationCriteria + .OrderBy(c => c.Id, StringComparer.Ordinal) + .Select(c => new PackagedCriterion( + c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, + c.Expected.ToList(), c.Mandatory, c.Severity)) + .ToList(), + }; + + private static PackagedArtifactFieldSchema ToPackaged(ArtifactFieldSchemaAggregate s) => new() + { + ArtifactType = s.ArtifactType, + CustomFields = s.CustomFields + .OrderBy(f => f.Name, StringComparer.Ordinal) + .Select(f => new PackagedCustomField( + f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)) + .ToList(), + }; +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs new file mode 100644 index 00000000..fbf408d9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs @@ -0,0 +1,141 @@ +namespace Tracker.Domain.Governance.GovernancePackage; + +/// +/// GT-532 — un PAQUETE DE GOBERNANZA: el conjunto de decisiones de gobierno de un tenant, con +/// nombre y versión, portable como documento. +/// +/// El problema que resuelve, en las palabras de la ficha: «la gobernanza no se puede +/// empaquetar por cliente». Hoy un tenant la configura pieza a pieza —una `GatePolicy` por fase, +/// un `ArtifactFieldSchema` por tipo de artefacto— y no hay forma de tomar esa configuración +/// entera, versionarla, moverla a otro tenant o revisarla como una unidad. Eso convierte «damos de +/// alta un cliente con nuestro estándar de gobierno» en un trabajo manual que nadie puede auditar. +/// +/// ES UN DOCUMENTO, NO UN AGREGADO PERSISTIDO, y la decisión es deliberada. Un paquete +/// no tiene ciclo de vida propio: se exporta desde un tenant y se aplica sobre otro. Guardarlo en +/// una tabla añadiría una tercera copia de unos datos que ya viven en `GatePolicy` y +/// `ArtifactFieldSchema` —y este repositorio ha pasado la semana arreglando exactamente eso: dos +/// copias de un catálogo que divergen sin que nada lo note. Sin tabla no hay migración, no hay +/// deriva posible y el paquete es portable por construcción. +/// +/// NO LLEVA `TenantId` DENTRO, y esto no es un olvido. Un paquete que arrastrara el +/// tenant de origen al destino sería precisamente la fuga de aislamiento que el producto prohíbe. +/// La procedencia se conserva como texto informativo (), nunca como una +/// clave que alguien pueda usar para escribir en otro sitio. El destino de una aplicación es +/// SIEMPRE el tenant de la sesión que la ejecuta. +/// +/// Lo que este tipo NO hace: traer contenido. El producto entrega el MECANISMO de +/// empaquetar; no publica paquetes propios con nuestras opiniones sobre qué debe exigir una +/// compuerta. Según `T-056` la validación de contenido es configuración del tenant y no código del +/// motor, así que un «paquete ISO 27001» de fábrica sería exactamente la capa que ese ADR rechaza. +/// El repositorio no contiene ni debe contener paquetes de ejemplo aplicables. +/// +public sealed record GovernancePackage +{ + /// Nombre legible del paquete. Identidad para una persona, nunca para una máquina. + public required string Name { get; init; } + + /// + /// Versión del paquete, decidida por quien lo exporta. No se valida como semver a propósito: + /// imponer un esquema de versionado a la configuración de un cliente es exactamente el tipo de + /// opinión que `T-056` deja fuera del motor. + /// + public required string Version { get; init; } + + public string Description { get; init; } = string.Empty; + + /// + /// De dónde salió, como TEXTO informativo. Nunca un identificador utilizable: ver la nota sobre + /// aislamiento en la documentación del tipo. + /// + public string ExportedFrom { get; init; } = string.Empty; + + public DateTime ExportedAtUtc { get; init; } + + public IReadOnlyList GatePolicies { get; init; } = new List(); + + public IReadOnlyList ArtifactFieldSchemas { get; init; } = + new List(); + + /// + /// Un paquete vacío no es un paquete. Aplicarlo no haría nada y el informe diría «0 cambios», + /// que es indistinguible de un error de exportación — la misma confusión entre «no había nada» + /// y «no se leyó nada» que este repositorio persigue en sus guardas. + /// + public bool IsEmpty => GatePolicies.Count == 0 && ArtifactFieldSchemas.Count == 0; + + public IReadOnlyList Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(Name)) errors.Add("GovernancePackage.NameRequired"); + if (string.IsNullOrWhiteSpace(Version)) errors.Add("GovernancePackage.VersionRequired"); + if (IsEmpty) errors.Add("GovernancePackage.Empty"); + + // Dos políticas para la misma fase harían que el resultado dependiera del orden de + // aplicación, que es una forma silenciosa de no ser determinista. + var fases = GatePolicies.Select(p => p.Phase).ToList(); + foreach (var dup in fases.GroupBy(f => f).Where(g => g.Count() > 1).Select(g => g.Key)) + { + errors.Add($"GovernancePackage.DuplicatePhase:{dup}"); + } + + var tipos = ArtifactFieldSchemas.Select(s => s.ArtifactType).ToList(); + foreach (var dup in tipos.GroupBy(t => t).Where(g => g.Count() > 1).Select(g => g.Key)) + { + errors.Add($"GovernancePackage.DuplicateArtifactType:{dup}"); + } + + foreach (var p in GatePolicies.Where(p => string.IsNullOrWhiteSpace(p.Phase))) + { + errors.Add("GovernancePackage.PolicyPhaseRequired"); + } + + foreach (var s in ArtifactFieldSchemas.Where(s => string.IsNullOrWhiteSpace(s.ArtifactType))) + { + errors.Add("GovernancePackage.SchemaArtifactTypeRequired"); + } + + return errors; + } +} + +/// Una política de compuerta dentro de un paquete, sin tenant y sin identidad de fila. +public sealed record PackagedGatePolicy +{ + public required string Phase { get; init; } + public required string Mode { get; init; } + public IReadOnlyList RequiredEvidence { get; init; } = new List(); + public string ApprovalStrategy { get; init; } = string.Empty; + public string ApprovalStages { get; init; } = string.Empty; + public IReadOnlyList Approvers { get; init; } = new List(); + public bool RequiresCoreVerdict { get; init; } + public bool Active { get; init; } = true; + public IReadOnlyList EvaluationCriteria { get; init; } = new List(); +} + +public sealed record PackagedEvidence(string Type, bool Mandatory); + +public sealed record PackagedApprover(string RoleOrIdentity, int Stage, int Quorum); + +public sealed record PackagedCriterion( + string Id, + string Label, + string ArtifactType, + string FieldPath, + string Operator, + IReadOnlyList Expected, + bool Mandatory, + string Severity); + +public sealed record PackagedArtifactFieldSchema +{ + public required string ArtifactType { get; init; } + public IReadOnlyList CustomFields { get; init; } = new List(); +} + +public sealed record PackagedCustomField( + string Name, + string Label, + string DataType, + IReadOnlyList EligibleValues, + bool Required); diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs index 31207e8c..0f4eafcc 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs @@ -111,6 +111,7 @@ public static WebApplication MapTrackerApiSurface(this WebApplication app) api.MapGatePolicyEndpoints(); api.MapGateSubmissionEndpoints(); api.MapArtifactFieldSchemaEndpoints(); + api.MapGovernancePackageEndpoints(); api.MapEvidenceRecordEndpoints(); api.MapAuditEntryEndpoints(); api.MapArchitectureReferenceEndpoints(); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs new file mode 100644 index 00000000..7ec3cb35 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs @@ -0,0 +1,167 @@ +using Tracker.Application.Governance.GovernancePackage; +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Presentation.Endpoints.Governance; + +using Package = Tracker.Domain.Governance.GovernancePackage.GovernancePackage; + +/// +/// GT-532 — empaquetar la gobernanza de un cliente: exportarla como documento portable y aplicarla +/// sobre un tenant. +/// +/// EL TENANT SALE SIEMPRE DE LA SESIÓN, en las dos operaciones. Ni la exportación lo +/// acepta como parámetro ni la aplicación lo acepta en el cuerpo. Un `tenantId` en cualquiera de +/// los dos sitios convertiría estos endpoints en una forma de leer o reescribir la gobernanza de +/// otro cliente, y el propio documento tampoco lleva su tenant de origen dentro, precisamente para +/// que ni siquiera por accidente pueda usarse como destino. +/// +/// Lo que el producto NO entrega aquí es contenido. Estos endpoints son el mecanismo; +/// no existen paquetes de fábrica con nuestra opinión sobre qué debe exigir una compuerta. Según +/// `T-056` la validación de contenido es configuración del tenant y no código del motor. +/// +public static class GovernancePackageEndpoints +{ + public static void MapGovernancePackageEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/governance-packages").WithTags("GateGovernance"); + + group.MapGet("/export", async ( + ITrackerUserContext user, + IMediator mediator, + string? name, + string? version, + string? description, + CancellationToken ct) => + { + var result = await mediator.Send(new ExportGovernancePackageQuery( + user.TenantId, + string.IsNullOrWhiteSpace(name) ? "governance" : name, + string.IsNullOrWhiteSpace(version) ? DateTime.UtcNow.ToString("yyyy.MM.dd") : version, + description ?? string.Empty, + // Procedencia como TEXTO. Nunca el identificador: ver la nota del tipo de dominio. + "tenant de la sesion"), ct); + + return Results.Ok(result); + }) + .RequireTrackerPermission(TrackerPermissions.GatePolicyRead) + .WithName("ExportGovernancePackage"); + + group.MapPost("/apply", async ( + GovernancePackageRequest request, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var package = new Package + { + Name = request.Name ?? string.Empty, + Version = request.Version ?? string.Empty, + Description = request.Description ?? string.Empty, + ExportedFrom = request.ExportedFrom ?? string.Empty, + ExportedAtUtc = request.ExportedAtUtc ?? default, + GatePolicies = (request.GatePolicies ?? new List()) + .Select(p => new PackagedGatePolicy + { + Phase = p.Phase ?? string.Empty, + Mode = p.Mode ?? string.Empty, + RequiredEvidence = (p.RequiredEvidence ?? new List()) + .Select(e => new PackagedEvidence(e.Type ?? string.Empty, e.Mandatory)).ToList(), + ApprovalStrategy = p.ApprovalStrategy ?? string.Empty, + ApprovalStages = p.ApprovalStages ?? string.Empty, + Approvers = (p.Approvers ?? new List()) + .Select(a => new PackagedApprover(a.RoleOrIdentity ?? string.Empty, a.Stage, a.Quorum)).ToList(), + RequiresCoreVerdict = p.RequiresCoreVerdict, + Active = p.Active, + EvaluationCriteria = (p.EvaluationCriteria ?? new List()) + .Select(c => new PackagedCriterion( + c.Id ?? string.Empty, c.Label ?? string.Empty, c.ArtifactType ?? string.Empty, + c.FieldPath ?? string.Empty, c.Operator ?? string.Empty, + c.Expected ?? new List(), c.Mandatory, c.Severity ?? string.Empty)).ToList(), + }).ToList(), + ArtifactFieldSchemas = (request.ArtifactFieldSchemas ?? new List()) + .Select(s => new PackagedArtifactFieldSchema + { + ArtifactType = s.ArtifactType ?? string.Empty, + CustomFields = (s.CustomFields ?? new List()) + .Select(f => new PackagedCustomField( + f.Name ?? string.Empty, f.Label ?? string.Empty, f.DataType ?? "string", + f.EligibleValues ?? new List(), f.Required)).ToList(), + }).ToList(), + }; + + var result = await mediator.Send( + new ApplyGovernancePackageCommand(user.TenantId, user.ActorId, package), ct); + return result.ToOk(); + }) + .RequireTrackerPermission(TrackerPermissions.GatePolicyWrite) + .WithName("ApplyGovernancePackage"); + } +} + +/// +/// Cuerpo de una aplicación. Deliberadamente SIN `tenantId`: el destino es el tenant de la sesión, +/// y aceptarlo aquí sería una forma de escribir en otro cliente. +/// +public sealed class GovernancePackageRequest +{ + public string? Name { get; init; } + public string? Version { get; init; } + public string? Description { get; init; } + public string? ExportedFrom { get; init; } + public DateTime? ExportedAtUtc { get; init; } + public List? GatePolicies { get; init; } + public List? ArtifactFieldSchemas { get; init; } +} + +public sealed class PackagedGatePolicyRequest +{ + public string? Phase { get; init; } + public string? Mode { get; init; } + public List? RequiredEvidence { get; init; } + public string? ApprovalStrategy { get; init; } + public string? ApprovalStages { get; init; } + public List? Approvers { get; init; } + public bool RequiresCoreVerdict { get; init; } + public bool Active { get; init; } = true; + public List? EvaluationCriteria { get; init; } +} + +public sealed class PackagedEvidenceRequest +{ + public string? Type { get; init; } + public bool Mandatory { get; init; } +} + +public sealed class PackagedApproverRequest +{ + public string? RoleOrIdentity { get; init; } + public int Stage { get; init; } + public int Quorum { get; init; } +} + +public sealed class PackagedCriterionRequest +{ + public string? Id { get; init; } + public string? Label { get; init; } + public string? ArtifactType { get; init; } + public string? FieldPath { get; init; } + public string? Operator { get; init; } + public List? Expected { get; init; } + public bool Mandatory { get; init; } + public string? Severity { get; init; } +} + +public sealed class PackagedArtifactFieldSchemaRequest +{ + public string? ArtifactType { get; init; } + public List? CustomFields { get; init; } +} + +public sealed class PackagedCustomFieldRequest +{ + public string? Name { get; init; } + public string? Label { get; init; } + public string? DataType { get; init; } + public List? EligibleValues { get; init; } + public bool Required { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs new file mode 100644 index 00000000..1dee939f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs @@ -0,0 +1,123 @@ +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// GT-532 — el documento de paquete de gobernanza. +/// +/// Lo que se prueba aquí no es que los campos se copien, sino las tres decisiones que hacen +/// que un paquete sea seguro de mover entre clientes: que un paquete vacío se rechace, que no +/// pueda contener dos respuestas para la misma fase, y que NO lleve dentro el tenant de origen. +/// +public class GovernancePackageTests +{ + private static GovernancePackage Paquete( + IReadOnlyList? politicas = null, + IReadOnlyList? esquemas = null, + string nombre = "Estándar corporativo", + string version = "2026.1") => new() + { + Name = nombre, + Version = version, + GatePolicies = politicas ?? new List + { + new() { Phase = "discovery", Mode = "advisory" }, + }, + ArtifactFieldSchemas = esquemas ?? new List(), + }; + + [Fact] + public void UnPaqueteCompletoEsValido() + { + Paquete().Validate().Should().BeEmpty(); + } + + [Fact] + public void UnPaqueteVacioSeRechaza() + { + // Aplicar un paquete vacío no haría nada y el informe diría «0 cambios», que es + // indistinguible de un fallo de exportación. La misma confusión entre «no había nada» y + // «no se leyó nada» que las guardas de este repositorio persiguen. + var vacio = Paquete(politicas: new List()); + vacio.IsEmpty.Should().BeTrue(); + vacio.Validate().Should().Contain("GovernancePackage.Empty"); + } + + [Fact] + public void ExigeNombreYVersion() + { + Paquete(nombre: " ").Validate().Should().Contain("GovernancePackage.NameRequired"); + Paquete(version: "").Validate().Should().Contain("GovernancePackage.VersionRequired"); + } + + [Fact] + public void NoAdmiteDosPoliticasParaLaMismaFase() + { + // Con dos, el resultado dependería del orden de aplicación: una forma silenciosa de no + // ser determinista, y de que dos tenants con el «mismo» paquete acaben distintos. + var dos = Paquete(politicas: new List + { + new() { Phase = "qa", Mode = "advisory" }, + new() { Phase = "qa", Mode = "blocking" }, + }); + dos.Validate().Should().Contain("GovernancePackage.DuplicatePhase:qa"); + } + + [Fact] + public void NoAdmiteDosEsquemasParaElMismoTipoDeArtefacto() + { + var dos = Paquete(esquemas: new List + { + new() { ArtifactType = "prd" }, + new() { ArtifactType = "prd" }, + }); + dos.Validate().Should().Contain("GovernancePackage.DuplicateArtifactType:prd"); + } + + [Fact] + public void RechazaUnaPoliticaSinFaseYUnEsquemaSinTipo() + { + Paquete(politicas: new List { new() { Phase = " ", Mode = "advisory" } }) + .Validate().Should().Contain("GovernancePackage.PolicyPhaseRequired"); + + Paquete(esquemas: new List { new() { ArtifactType = " " } }) + .Validate().Should().Contain("GovernancePackage.SchemaArtifactTypeRequired"); + } + + [Fact] + public void ElDocumentoNoExponeNingunIdentificadorDeTenant() + { + // La prueba que de verdad importa. Un paquete que arrastrara el tenant de origen al + // destino sería la fuga de aislamiento que el producto prohíbe, y es un error que se + // cometería anadiendo un campo «por comodidad» meses despues. Se comprueba por REFLEXION + // sobre el tipo, no sobre una instancia, para que anadir el campo rompa esta prueba. + var propiedades = typeof(GovernancePackage).GetProperties() + .Select(p => p.Name) + .ToList(); + + propiedades.Should().NotContain("TenantId"); + propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEquivalentTo(new List(), + "la procedencia viaja como texto en `ExportedFrom`, nunca como una clave que " + + "alguien pueda usar para escribir en otro tenant"); + + foreach (var tipo in new[] + { + typeof(PackagedGatePolicy), typeof(PackagedArtifactFieldSchema), + typeof(PackagedCriterion), typeof(PackagedCustomField), + }) + { + tipo.GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty($"{tipo.Name} tampoco puede llevar tenant dentro de un paquete"); + } + } + + [Fact] + public void LaProcedenciaEsTextoInformativoYSobrevive() + { + var p = Paquete() with { ExportedFrom = "acme (produccion)", ExportedAtUtc = new DateTime(2026, 8, 2) }; + p.Validate().Should().BeEmpty(); + p.ExportedFrom.Should().Be("acme (produccion)"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs new file mode 100644 index 00000000..e5c0cf6a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using Tracker.Application.Governance.GovernancePackage; +using Tracker.Presentation.Endpoints.Governance; + +namespace Tracker.Tests.Presentation.Governance; + +/// +/// GT-532 — la propiedad que hace seguro mover gobernanza entre clientes: **el destino nunca lo +/// elige el llamante**. +/// +/// Un paquete existe para llevarse de un tenant a otro, así que es exactamente el tipo de +/// función donde un `tenantId` «por comodidad» en el cuerpo o en la query se cuela sin que nadie +/// lo note, y donde el efecto de colarlo es reescribir la gobernanza de otro cliente. Estas +/// pruebas miran los TIPOS, no una llamada concreta: añadir el campo rompe la prueba aunque nadie +/// escriba una petición que lo use. +/// +public class GovernancePackageIsolationTests +{ + [Fact] + public void ElCuerpoDeUnaAplicacionNoAceptaTenant() + { + var propiedades = typeof(GovernancePackageRequest).GetProperties().Select(p => p.Name).ToList(); + + propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty( + "el destino es el tenant de la SESION; aceptarlo en el cuerpo convertiria este " + + "endpoint en una forma de reescribir la gobernanza de otro cliente"); + } + + [Fact] + public void NingunTipoDePeticionAnidadoAceptaTenant() + { + var tipos = new[] + { + typeof(PackagedGatePolicyRequest), typeof(PackagedEvidenceRequest), + typeof(PackagedApproverRequest), typeof(PackagedCriterionRequest), + typeof(PackagedArtifactFieldSchemaRequest), typeof(PackagedCustomFieldRequest), + }; + + foreach (var t in tipos) + { + t.GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty($"{t.Name} viaja dentro del paquete y tampoco puede nombrar un tenant"); + } + } + + [Fact] + public void ElComandoDeAplicacionRecibeElTenantComoParametroPropioYNoDesdeElPaquete() + { + // El tenant es un parametro del COMANDO, que la capa de presentacion rellena desde la + // sesion. Si algun dia alguien lo moviera dentro del documento, el paquete pasaria a + // llevar su propio destino y esta separacion desapareceria en silencio. + var ctor = typeof(ApplyGovernancePackageCommand).GetConstructors().Single(); + var parametros = ctor.GetParameters().Select(p => p.Name).ToList(); + + parametros.Should().Contain("TenantId"); + parametros.Should().Contain("Package"); + } + + [Fact] + public void LaExportacionTampocoAceptaUnTenantElegidoPorElLlamante() + { + // El handler recibe un TenantId, pero el ENDPOINT lo toma de `ITrackerUserContext`. Lo que + // se comprueba aqui es que la query no expone ningun otro camino: un segundo parametro de + // tenant seria un modo de leer la configuracion de otro cliente. + var ctor = typeof(ExportGovernancePackageQuery).GetConstructors().Single(); + ctor.GetParameters().Select(p => p.Name) + .Count(n => n!.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().Be(1); + } + + [Fact] + public void ElRepositorioNoTraeNingunPaqueteDeFabrica() + { + // T-056: el producto entrega el MECANISMO de empaquetar, no contenido con nuestra opinion + // sobre que debe exigir una compuerta. Un paquete de ejemplo APLICABLE en el arbol seria + // esa capa entrando por la puerta de atras, asi que se comprueba que no exista. + var raiz = RepoRoot(); + var sospechosos = Directory + .GetFiles(raiz, "*.json", SearchOption.AllDirectories) + .Where(f => !f.Contains("/node_modules/") && !f.Contains("/obj/") && !f.Contains("/bin/")) + .Where(f => + { + var nombre = Path.GetFileName(f).ToLowerInvariant(); + return nombre.Contains("governance-package") || nombre.Contains("governance_package"); + }) + .ToList(); + + sospechosos.Should().BeEmpty( + "el repositorio no publica paquetes de gobernanza; segun T-056 el contenido lo " + + "configura el tenant y no lo cablea el motor"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { + dir = dir.Parent; + } + dir.Should().NotBeNull(); + return dir!.FullName; + } +}