Skip to content

Commit 9fa5ee9

Browse files
Merge pull request #327 from LayoutParser/test/isolamento-cross-workspace-200
test(mapping-drafts): isolamento cross-workspace no MappingDraftsController (#200)
2 parents b5d9fb3 + 5bc1646 commit 9fa5ee9

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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

Comments
 (0)