|
| 1 | +using LayoutParserApi.Controllers; |
| 2 | +using LayoutParserApi.Models.Entities.Fiscal; |
| 3 | +using LayoutParserApi.Services.Interfaces; |
| 4 | + |
| 5 | +using Microsoft.AspNetCore.Mvc; |
| 6 | +using Microsoft.Extensions.Logging.Abstractions; |
| 7 | + |
| 8 | +namespace LayoutParserApi.Tests.Controllers |
| 9 | +{ |
| 10 | + /// <summary> |
| 11 | + /// Issue #200 (LayoutParserReact) — prova de isolamento por workspace no server-side do |
| 12 | + /// <see cref="MappingDraftsController"/>. O enforcement real já existe em produção (membership |
| 13 | + /// check via <see cref="IIdentityWorkspaceService.GetWorkspaceForMemberAsync"/> + |
| 14 | + /// <c>draft.WorkspaceId != workspaceId</c> da rota); o que faltava era um teste que provasse |
| 15 | + /// isso explicitamente. Diferente de <see cref="TransformationExecutionControllerUserIsolationTests"/> |
| 16 | + /// (isolamento por usuário), aqui o cenário é: usuário autenticado, membro do workspace A, |
| 17 | + /// tenta acessar/mutar um recurso que pertence ao workspace B (do qual NÃO é membro) → espera |
| 18 | + /// 404 (o controller não distingue "não existe" de "não é seu" — mesmo padrão de |
| 19 | + /// <see cref="IIdentityWorkspaceService"/>), nunca 200 nem vazamento de conteúdo. |
| 20 | + /// |
| 21 | + /// <para>Os fakes abaixo reproduzem o contrato real da camada de dados: <see cref="FakeMappingDraftStore"/> |
| 22 | + /// só devolve um draft de <c>GetDraftIfMemberAsync</c>/<c>GetRuleIfMemberAsync</c> quando o |
| 23 | + /// <c>userId</c> pedido é de fato membro do workspace DONO do draft (mesma regra que |
| 24 | + /// <c>SqlMappingDraftStore</c> aplica via JOIN no banco) — não é um "sempre retorna" que |
| 25 | + /// mascararia a checagem do controller.</para> |
| 26 | + /// </summary> |
| 27 | + public class MappingDraftsControllerWorkspaceIsolationTests |
| 28 | + { |
| 29 | + // --- fakes --- |
| 30 | + |
| 31 | + private sealed class FakeCurrentUser : ICurrentUser |
| 32 | + { |
| 33 | + public string? Name { get; set; } |
| 34 | + public IReadOnlyList<string> Roles { get; set; } = Array.Empty<string>(); |
| 35 | + public bool IsAuthenticated => Name != null; |
| 36 | + public bool IsInRole(string role) => Roles.Contains(role, StringComparer.OrdinalIgnoreCase); |
| 37 | + public Guid? UserId { get; set; } |
| 38 | + } |
| 39 | + |
| 40 | + /// <summary>Membership em memória: workspaceId → conjunto de userIds membros.</summary> |
| 41 | + private sealed class FakeIdentityWorkspaceService : IIdentityWorkspaceService |
| 42 | + { |
| 43 | + private readonly Dictionary<Guid, HashSet<Guid>> _membership = new(); |
| 44 | + private readonly Dictionary<Guid, WorkspaceSummary> _workspaces = new(); |
| 45 | + |
| 46 | + public void AddMember(Guid workspaceId, Guid userId) |
| 47 | + { |
| 48 | + if (!_membership.TryGetValue(workspaceId, out var members)) |
| 49 | + _membership[workspaceId] = members = new HashSet<Guid>(); |
| 50 | + members.Add(userId); |
| 51 | + |
| 52 | + _workspaces[workspaceId] = new WorkspaceSummary(workspaceId, $"ws-{workspaceId}", "team", "member", DateTimeOffset.UtcNow); |
| 53 | + } |
| 54 | + |
| 55 | + public bool IsMember(Guid workspaceId, Guid userId) => |
| 56 | + _membership.TryGetValue(workspaceId, out var members) && members.Contains(userId); |
| 57 | + |
| 58 | + public Task<Guid?> ResolveOrCreateUserAsync(string provider, string? tenantOrIssuer, string subject, CancellationToken cancellationToken) |
| 59 | + => throw new NotSupportedException("Não exercitado por estes testes."); |
| 60 | + |
| 61 | + public Task<WorkspaceMeResult> GetOrCreateMyWorkspacesAsync(Guid userId, CancellationToken cancellationToken) |
| 62 | + => throw new NotSupportedException("Não exercitado por estes testes."); |
| 63 | + |
| 64 | + public Task<WorkspaceSummary?> GetWorkspaceForMemberAsync(Guid workspaceId, Guid userId, CancellationToken cancellationToken) |
| 65 | + => Task.FromResult(IsMember(workspaceId, userId) && _workspaces.TryGetValue(workspaceId, out var ws) ? ws : null); |
| 66 | + } |
| 67 | + |
| 68 | + /// <summary> |
| 69 | + /// Armazena drafts em memória, marcados com o workspace dono. Só devolve o draft/regra se o |
| 70 | + /// userId pedido for membro do workspace dono — mesma regra do store real (SQL JOIN), a peça |
| 71 | + /// que este teste precisa exercitar de verdade (não um stub "sempre true"). |
| 72 | + /// </summary> |
| 73 | + private sealed class FakeMappingDraftStore : IMappingDraftStore |
| 74 | + { |
| 75 | + private readonly FakeIdentityWorkspaceService _identity; |
| 76 | + private readonly Dictionary<Guid, MappingDraftDetail> _drafts = new(); |
| 77 | + |
| 78 | + public FakeMappingDraftStore(FakeIdentityWorkspaceService identity) => _identity = identity; |
| 79 | + |
| 80 | + public Task<bool> RevisionBelongsToPackageAsync(Guid packageId, Guid revisionId, CancellationToken cancellationToken) |
| 81 | + => Task.FromResult(true); |
| 82 | + |
| 83 | + public Task<IReadOnlyList<ArtifactFileRef>> GetArtifactFilesForRevisionAsync(Guid revisionId, CancellationToken cancellationToken) |
| 84 | + => Task.FromResult<IReadOnlyList<ArtifactFileRef>>(Array.Empty<ArtifactFileRef>()); |
| 85 | + |
| 86 | + public Task<MappingDraftDetail> CreateDraftAsync(Guid workspaceId, Guid packageId, Guid revisionId, Guid createdByUserId, string engine, CancellationToken cancellationToken) |
| 87 | + { |
| 88 | + var draft = new MappingDraftDetail( |
| 89 | + DraftId: Guid.NewGuid(), |
| 90 | + WorkspaceId: workspaceId, |
| 91 | + PackageId: packageId, |
| 92 | + RevisionId: revisionId, |
| 93 | + Engine: engine, |
| 94 | + CreatedAt: DateTimeOffset.UtcNow, |
| 95 | + Rules: new List<MappingDraftRuleDetail> |
| 96 | + { |
| 97 | + new MappingDraftRuleDetail( |
| 98 | + RuleId: Guid.NewGuid(), |
| 99 | + DraftId: Guid.Empty, // preenchido abaixo |
| 100 | + SourceRefs: new[] { "/src/campo" }, |
| 101 | + TargetRefs: new[] { "/dst/campo" }, |
| 102 | + Operation: "copy", |
| 103 | + ConditionsJson: "{}", |
| 104 | + TransformationsJson: "{}", |
| 105 | + Cardinality: "1:1", |
| 106 | + Evidence: Array.Empty<MappingDraftRuleEvidence>(), |
| 107 | + Confidence: "high", |
| 108 | + Status: "proposed", |
| 109 | + OpenQuestions: Array.Empty<string>(), |
| 110 | + CreatedAt: DateTimeOffset.UtcNow, |
| 111 | + ETag: Convert.ToBase64String(new byte[] { 1, 2, 3 })), |
| 112 | + }); |
| 113 | + |
| 114 | + // Corrige o DraftId embutido na regra (record imutável — reconstrói). |
| 115 | + draft = draft with |
| 116 | + { |
| 117 | + Rules = draft.Rules.Select(r => r with { DraftId = draft.DraftId }).ToList(), |
| 118 | + }; |
| 119 | + |
| 120 | + _drafts[draft.DraftId] = draft; |
| 121 | + return Task.FromResult(draft); |
| 122 | + } |
| 123 | + |
| 124 | + public Task<MappingDraftDetail?> GetDraftIfMemberAsync(Guid draftId, Guid userId, CancellationToken cancellationToken) |
| 125 | + { |
| 126 | + if (!_drafts.TryGetValue(draftId, out var draft)) |
| 127 | + return Task.FromResult<MappingDraftDetail?>(null); |
| 128 | + |
| 129 | + return Task.FromResult(_identity.IsMember(draft.WorkspaceId, userId) ? draft : null); |
| 130 | + } |
| 131 | + |
| 132 | + public Task<MappingDraftRuleDetail?> GetRuleIfMemberAsync(Guid draftId, Guid ruleId, Guid userId, CancellationToken cancellationToken) |
| 133 | + { |
| 134 | + if (!_drafts.TryGetValue(draftId, out var draft) || !_identity.IsMember(draft.WorkspaceId, userId)) |
| 135 | + return Task.FromResult<MappingDraftRuleDetail?>(null); |
| 136 | + |
| 137 | + return Task.FromResult(draft.Rules.FirstOrDefault(r => r.RuleId == ruleId)); |
| 138 | + } |
| 139 | + |
| 140 | + public Task InsertProposedRulesAsync(Guid draftId, Guid jobId, IReadOnlyList<MappingDraftRuleProposal> proposals, CancellationToken cancellationToken) |
| 141 | + => throw new NotSupportedException("Não exercitado por estes testes."); |
| 142 | + |
| 143 | + public Task<UpdateRuleOutcome> UpdateRuleStatusAsync( |
| 144 | + Guid draftId, Guid ruleId, Guid userId, byte[] expectedRowVersion, string newStatus, string? justification, |
| 145 | + IReadOnlyList<string>? editedSourceRefs, IReadOnlyList<string>? editedTargetRefs, string? editedOperation, |
| 146 | + CancellationToken cancellationToken) |
| 147 | + => throw new NotSupportedException("Não exercitado por estes testes — a checagem de workspace acontece antes deste ponto."); |
| 148 | + } |
| 149 | + |
| 150 | + private sealed class NoopMappingSuggestionService : IMappingSuggestionService |
| 151 | + { |
| 152 | + public Guid? LastEnqueuedDraftId { get; private set; } |
| 153 | + |
| 154 | + public Task<Guid> EnqueueAsync(Guid draftId, Guid workspaceId, Guid revisionId, string engine, CancellationToken cancellationToken) |
| 155 | + { |
| 156 | + LastEnqueuedDraftId = draftId; |
| 157 | + return Task.FromResult(Guid.NewGuid()); |
| 158 | + } |
| 159 | + |
| 160 | + public Task<SuggestionJobState?> GetStatusAsync(Guid jobId, CancellationToken cancellationToken) |
| 161 | + => Task.FromResult<SuggestionJobState?>(new SuggestionJobState { JobId = jobId, Status = SuggestionJobStatus.Queued }); |
| 162 | + |
| 163 | + public Task<bool> CancelAsync(Guid jobId, CancellationToken cancellationToken) |
| 164 | + => Task.FromResult(true); |
| 165 | + } |
| 166 | + |
| 167 | + private sealed class Fixture |
| 168 | + { |
| 169 | + public required MappingDraftsController Controller; |
| 170 | + public required FakeIdentityWorkspaceService Identity; |
| 171 | + public required FakeMappingDraftStore Store; |
| 172 | + public required NoopMappingSuggestionService Suggestions; |
| 173 | + public required FakeCurrentUser User; |
| 174 | + } |
| 175 | + |
| 176 | + private static Fixture Build() |
| 177 | + { |
| 178 | + var identity = new FakeIdentityWorkspaceService(); |
| 179 | + var store = new FakeMappingDraftStore(identity); |
| 180 | + var suggestions = new NoopMappingSuggestionService(); |
| 181 | + var user = new FakeCurrentUser(); |
| 182 | + |
| 183 | + var controller = new MappingDraftsController( |
| 184 | + store, |
| 185 | + suggestions, |
| 186 | + identity, |
| 187 | + user, |
| 188 | + NullLogger<MappingDraftsController>.Instance); |
| 189 | + |
| 190 | + return new Fixture { Controller = controller, Identity = identity, Store = store, Suggestions = suggestions, User = user }; |
| 191 | + } |
| 192 | + |
| 193 | + [Fact] |
| 194 | + public async Task Membro_do_workspace_cria_e_consulta_o_proprio_draft_com_sucesso() |
| 195 | + { |
| 196 | + var fx = Build(); |
| 197 | + var workspaceA = Guid.NewGuid(); |
| 198 | + var userAlice = Guid.NewGuid(); |
| 199 | + fx.Identity.AddMember(workspaceA, userAlice); |
| 200 | + fx.User.UserId = userAlice; |
| 201 | + |
| 202 | + var createResult = await fx.Controller.CreateDraft( |
| 203 | + workspaceA, Guid.NewGuid(), new CreateDraftRequest { Engine = "xslt", RevisionId = Guid.NewGuid() }, CancellationToken.None); |
| 204 | + var created = Assert.IsType<CreatedAtActionResult>(createResult); |
| 205 | + var draftId = (Guid)created.RouteValues!["draftId"]!; |
| 206 | + |
| 207 | + var getResult = await fx.Controller.GetDraft(workspaceA, draftId, CancellationToken.None); |
| 208 | + Assert.IsType<OkObjectResult>(getResult); |
| 209 | + } |
| 210 | + |
| 211 | + [Fact] |
| 212 | + public async Task Membro_de_outro_workspace_recebe_404_ao_tentar_ler_draft_alheio_sem_vazar_conteudo() |
| 213 | + { |
| 214 | + var fx = Build(); |
| 215 | + var workspaceA = Guid.NewGuid(); |
| 216 | + var workspaceB = Guid.NewGuid(); |
| 217 | + var userAlice = Guid.NewGuid(); |
| 218 | + var userBob = Guid.NewGuid(); |
| 219 | + fx.Identity.AddMember(workspaceA, userAlice); |
| 220 | + fx.Identity.AddMember(workspaceB, userBob); |
| 221 | + |
| 222 | + fx.User.UserId = userAlice; |
| 223 | + var createResult = await fx.Controller.CreateDraft( |
| 224 | + workspaceA, Guid.NewGuid(), new CreateDraftRequest { Engine = "tcl", RevisionId = Guid.NewGuid() }, CancellationToken.None); |
| 225 | + var created = Assert.IsType<CreatedAtActionResult>(createResult); |
| 226 | + var draftId = (Guid)created.RouteValues!["draftId"]!; |
| 227 | + |
| 228 | + // Bob não é membro do workspace A — tenta ler o draft de Alice usando o workspaceId real dele. |
| 229 | + fx.User.UserId = userBob; |
| 230 | + var getResult = await fx.Controller.GetDraft(workspaceA, draftId, CancellationToken.None); |
| 231 | + |
| 232 | + Assert.IsType<NotFoundResult>(getResult); |
| 233 | + Assert.IsNotType<OkObjectResult>(getResult); // nenhum conteúdo do draft de Alice vaza pra Bob |
| 234 | + } |
| 235 | + |
| 236 | + [Fact] |
| 237 | + public async Task WorkspaceId_da_rota_divergente_do_dono_real_retorna_404_mesmo_com_membership_valida() |
| 238 | + { |
| 239 | + // Cobre a segunda camada de defesa do controller (draft.WorkspaceId != workspaceId da rota): |
| 240 | + // Alice é membro dos workspaces A e C, mas o draft pertence a A — pedir pela rota de C |
| 241 | + // (workspace do qual ela TAMBÉM é membro) não deve enxergar o draft de A. |
| 242 | + var fx = Build(); |
| 243 | + var workspaceA = Guid.NewGuid(); |
| 244 | + var workspaceC = Guid.NewGuid(); |
| 245 | + var userAlice = Guid.NewGuid(); |
| 246 | + fx.Identity.AddMember(workspaceA, userAlice); |
| 247 | + fx.Identity.AddMember(workspaceC, userAlice); |
| 248 | + fx.User.UserId = userAlice; |
| 249 | + |
| 250 | + var createResult = await fx.Controller.CreateDraft( |
| 251 | + workspaceA, Guid.NewGuid(), new CreateDraftRequest { Engine = "xslt", RevisionId = Guid.NewGuid() }, CancellationToken.None); |
| 252 | + var created = Assert.IsType<CreatedAtActionResult>(createResult); |
| 253 | + var draftId = (Guid)created.RouteValues!["draftId"]!; |
| 254 | + |
| 255 | + var getResult = await fx.Controller.GetDraft(workspaceC, draftId, CancellationToken.None); |
| 256 | + Assert.IsType<NotFoundResult>(getResult); |
| 257 | + } |
| 258 | + |
| 259 | + [Fact] |
| 260 | + public async Task Membro_de_outro_workspace_nao_consegue_disparar_job_de_sugestao_em_draft_alheio() |
| 261 | + { |
| 262 | + var fx = Build(); |
| 263 | + var workspaceA = Guid.NewGuid(); |
| 264 | + var workspaceB = Guid.NewGuid(); |
| 265 | + var userAlice = Guid.NewGuid(); |
| 266 | + var userBob = Guid.NewGuid(); |
| 267 | + fx.Identity.AddMember(workspaceA, userAlice); |
| 268 | + fx.Identity.AddMember(workspaceB, userBob); |
| 269 | + |
| 270 | + fx.User.UserId = userAlice; |
| 271 | + var created = Assert.IsType<CreatedAtActionResult>(await fx.Controller.CreateDraft( |
| 272 | + workspaceA, Guid.NewGuid(), new CreateDraftRequest { Engine = "xslt", RevisionId = Guid.NewGuid() }, CancellationToken.None)); |
| 273 | + var draftId = (Guid)created.RouteValues!["draftId"]!; |
| 274 | + |
| 275 | + fx.User.UserId = userBob; |
| 276 | + var jobResult = await fx.Controller.CreateSuggestionJob(workspaceA, draftId, CancellationToken.None); |
| 277 | + |
| 278 | + Assert.IsType<NotFoundResult>(jobResult); |
| 279 | + Assert.Null(fx.Suggestions.LastEnqueuedDraftId); // o job nem chega a ser enfileirado |
| 280 | + } |
| 281 | + |
| 282 | + [Fact] |
| 283 | + public async Task Membro_de_outro_workspace_nao_consegue_ler_regra_isolada_de_draft_alheio() |
| 284 | + { |
| 285 | + var fx = Build(); |
| 286 | + var workspaceA = Guid.NewGuid(); |
| 287 | + var workspaceB = Guid.NewGuid(); |
| 288 | + var userAlice = Guid.NewGuid(); |
| 289 | + var userBob = Guid.NewGuid(); |
| 290 | + fx.Identity.AddMember(workspaceA, userAlice); |
| 291 | + fx.Identity.AddMember(workspaceB, userBob); |
| 292 | + |
| 293 | + fx.User.UserId = userAlice; |
| 294 | + var created = Assert.IsType<CreatedAtActionResult>(await fx.Controller.CreateDraft( |
| 295 | + workspaceA, Guid.NewGuid(), new CreateDraftRequest { Engine = "xslt", RevisionId = Guid.NewGuid() }, CancellationToken.None)); |
| 296 | + var draftId = (Guid)created.RouteValues!["draftId"]!; |
| 297 | + var okDraft = Assert.IsType<OkObjectResult>(await fx.Controller.GetDraft(workspaceA, draftId, CancellationToken.None)); |
| 298 | + |
| 299 | + // Extrai o ruleId da resposta anônima via reflection (mesmo padrão do teste de execução) — |
| 300 | + // evita reimplementar o shape do DTO aqui. |
| 301 | + var rules = (System.Collections.IEnumerable)okDraft.Value!.GetType().GetProperty("rules")!.GetValue(okDraft.Value)!; |
| 302 | + var firstRule = rules.Cast<object>().First(); |
| 303 | + var ruleId = (Guid)firstRule.GetType().GetProperty("ruleId")!.GetValue(firstRule)!; |
| 304 | + |
| 305 | + fx.User.UserId = userBob; |
| 306 | + |
| 307 | + // O controller exige o header If-Match ANTES de checar workspace (design §3) — precisa |
| 308 | + // estar presente para o teste de fato exercitar a checagem de isolamento, não a validação |
| 309 | + // de header. UpdateRuleStatusAsync do fake lança se for chamado: se o isolamento falhar e o |
| 310 | + // controller seguir adiante mesmo com Bob não sendo membro, o teste falha por exceção, não |
| 311 | + // silenciosamente. |
| 312 | + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); |
| 313 | + httpContext.Request.Headers["If-Match"] = "\"AQID\""; // base64("\x01\x02\x03") — mesmo ETag do fake |
| 314 | + fx.Controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; |
| 315 | + |
| 316 | + var updateResult = await fx.Controller.UpdateRule( |
| 317 | + workspaceA, draftId, ruleId, |
| 318 | + new UpdateRuleRequest { Status = "accepted" }, |
| 319 | + CancellationToken.None); |
| 320 | + |
| 321 | + Assert.IsType<NotFoundResult>(updateResult); |
| 322 | + } |
| 323 | + } |
| 324 | +} |
0 commit comments