Skip to content

Commit 6a26c29

Browse files
feat: ✨ AllowedIncludesAttribute to whitelist allowed include paths
1 parent 88502bb commit 6a26c29

15 files changed

Lines changed: 1424 additions & 3 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,6 @@ docs/markdown-cheat-sheet.md
412412
# DocFX
413413
_site
414414
api
415+
416+
# Claude
417+
.claude
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
using JsonApiToolkit.Attributes;
2+
using JsonApiToolkit.Models.Errors;
3+
using JsonApiToolkit.Models.Querying;
4+
using JsonApiToolkit.Parsing;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.Mvc.Abstractions;
8+
using Microsoft.AspNetCore.Mvc.Filters;
9+
using Microsoft.AspNetCore.Routing;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Primitives;
13+
using Moq;
14+
15+
namespace JsonApiToolkit.Tests.Attributes;
16+
17+
public class AllowedIncludesAttributeTests
18+
{
19+
private ActionExecutingContext CreateContext(
20+
string? includeQueryParam = null,
21+
string? actionName = null
22+
)
23+
{
24+
var httpContext = new DefaultHttpContext();
25+
var services = new ServiceCollection();
26+
services.AddSingleton<ILogger<AllowedIncludesAttribute>>(
27+
Mock.Of<ILogger<AllowedIncludesAttribute>>()
28+
);
29+
httpContext.RequestServices = services.BuildServiceProvider();
30+
31+
if (includeQueryParam != null)
32+
{
33+
httpContext.Request.QueryString = new QueryString($"?include={includeQueryParam}");
34+
var queryCollection = new Dictionary<string, StringValues>
35+
{
36+
{ "include", includeQueryParam },
37+
};
38+
httpContext.Request.Query = new QueryCollection(queryCollection);
39+
}
40+
41+
var actionContext = new ActionContext(
42+
httpContext,
43+
new RouteData(),
44+
new ActionDescriptor { DisplayName = actionName ?? "TestAction" }
45+
);
46+
47+
var context = new ActionExecutingContext(
48+
actionContext,
49+
new List<IFilterMetadata>(),
50+
new Dictionary<string, object?>(),
51+
controller: new object()
52+
);
53+
54+
return context;
55+
}
56+
57+
[Fact]
58+
public void Constructor_WithNullArray_TreatsAsEmpty()
59+
{
60+
var attribute = new AllowedIncludesAttribute(null!);
61+
Assert.Empty(attribute.AllowedIncludes);
62+
}
63+
64+
[Fact]
65+
public void Constructor_WithEmptyArray_StoresEmpty()
66+
{
67+
var attribute = new AllowedIncludesAttribute();
68+
Assert.Empty(attribute.AllowedIncludes);
69+
}
70+
71+
[Fact]
72+
public void Constructor_WithIncludes_StoresIncludes()
73+
{
74+
var attribute = new AllowedIncludesAttribute("author", "posts");
75+
Assert.Equal(2, attribute.AllowedIncludes.Length);
76+
Assert.Contains("author", attribute.AllowedIncludes);
77+
Assert.Contains("posts", attribute.AllowedIncludes);
78+
}
79+
80+
[Fact]
81+
public void OnActionExecuting_NoIncludeParam_AllowsRequest()
82+
{
83+
var attribute = new AllowedIncludesAttribute("author");
84+
var context = CreateContext();
85+
86+
attribute.OnActionExecuting(context);
87+
88+
Assert.Null(context.Result);
89+
}
90+
91+
[Fact]
92+
public void OnActionExecuting_EmptyIncludeParam_AllowsRequest()
93+
{
94+
var attribute = new AllowedIncludesAttribute("author");
95+
var context = CreateContext("");
96+
97+
attribute.OnActionExecuting(context);
98+
99+
Assert.Null(context.Result);
100+
}
101+
102+
[Fact]
103+
public void OnActionExecuting_AllowedInclude_AllowsRequest()
104+
{
105+
var attribute = new AllowedIncludesAttribute("author", "posts");
106+
var context = CreateContext("author");
107+
108+
attribute.OnActionExecuting(context);
109+
110+
Assert.Null(context.Result);
111+
}
112+
113+
[Fact]
114+
public void OnActionExecuting_MultipleAllowedIncludes_AllowsRequest()
115+
{
116+
var attribute = new AllowedIncludesAttribute("author", "posts", "comments");
117+
var context = CreateContext("author,posts");
118+
119+
attribute.OnActionExecuting(context);
120+
121+
Assert.Null(context.Result);
122+
}
123+
124+
[Fact]
125+
public void OnActionExecuting_ForbiddenInclude_ReturnsForbidden()
126+
{
127+
var attribute = new AllowedIncludesAttribute("author");
128+
var context = CreateContext("posts");
129+
130+
attribute.OnActionExecuting(context);
131+
132+
Assert.NotNull(context.Result);
133+
var objectResult = Assert.IsType<ObjectResult>(context.Result);
134+
Assert.Equal(403, objectResult.StatusCode);
135+
136+
var errorResponse = Assert.IsType<JsonApiErrorResponse>(objectResult.Value);
137+
Assert.Single(errorResponse.Errors);
138+
Assert.Equal("403", errorResponse.Errors[0].Status);
139+
Assert.Contains("posts", errorResponse.Errors[0].Detail);
140+
}
141+
142+
[Fact]
143+
public void OnActionExecuting_EmptyAllowedArray_ForbidsAllIncludes()
144+
{
145+
var attribute = new AllowedIncludesAttribute();
146+
var context = CreateContext("author");
147+
148+
attribute.OnActionExecuting(context);
149+
150+
Assert.NotNull(context.Result);
151+
var objectResult = Assert.IsType<ObjectResult>(context.Result);
152+
Assert.Equal(403, objectResult.StatusCode);
153+
}
154+
155+
[Fact]
156+
public void OnActionExecuting_CaseInsensitive_AllowsRequest()
157+
{
158+
var attribute = new AllowedIncludesAttribute("Author");
159+
var context = CreateContext("author");
160+
161+
attribute.OnActionExecuting(context);
162+
163+
Assert.Null(context.Result);
164+
}
165+
166+
[Fact]
167+
public void OnActionExecuting_CaseInsensitiveReverse_AllowsRequest()
168+
{
169+
var attribute = new AllowedIncludesAttribute("author");
170+
var context = CreateContext("Author");
171+
172+
attribute.OnActionExecuting(context);
173+
174+
Assert.Null(context.Result);
175+
}
176+
177+
[Fact]
178+
public void OnActionExecuting_PartialPath_AllowsRequest()
179+
{
180+
var attribute = new AllowedIncludesAttribute("author.posts");
181+
var context = CreateContext("author");
182+
183+
attribute.OnActionExecuting(context);
184+
185+
Assert.Null(context.Result);
186+
}
187+
188+
[Fact]
189+
public void OnActionExecuting_FullPath_AllowsRequest()
190+
{
191+
var attribute = new AllowedIncludesAttribute("author.posts");
192+
var context = CreateContext("author.posts");
193+
194+
attribute.OnActionExecuting(context);
195+
196+
Assert.Null(context.Result);
197+
}
198+
199+
[Fact]
200+
public void OnActionExecuting_ExtendedPath_ForbidsRequest()
201+
{
202+
var attribute = new AllowedIncludesAttribute("author.posts");
203+
var context = CreateContext("author.posts.comments");
204+
205+
attribute.OnActionExecuting(context);
206+
207+
Assert.NotNull(context.Result);
208+
var objectResult = Assert.IsType<ObjectResult>(context.Result);
209+
Assert.Equal(403, objectResult.StatusCode);
210+
}
211+
212+
[Fact]
213+
public void OnActionExecuting_JsonApiCreatedAction_SkipsValidation()
214+
{
215+
var attribute = new AllowedIncludesAttribute("author");
216+
var context = CreateContext("forbidden", "JsonApiCreated");
217+
218+
attribute.OnActionExecuting(context);
219+
220+
Assert.Null(context.Result);
221+
}
222+
223+
[Fact]
224+
public void OnActionExecuting_MultipleForbidden_ReturnsAllInError()
225+
{
226+
var attribute = new AllowedIncludesAttribute("author");
227+
var context = CreateContext("posts,comments,tags");
228+
229+
attribute.OnActionExecuting(context);
230+
231+
Assert.NotNull(context.Result);
232+
var objectResult = Assert.IsType<ObjectResult>(context.Result);
233+
var errorResponse = Assert.IsType<JsonApiErrorResponse>(objectResult.Value);
234+
235+
var errorWithMeta = errorResponse.Errors[0] as JsonApiErrorWithMeta;
236+
Assert.NotNull(errorWithMeta);
237+
var meta = errorWithMeta.Meta;
238+
Assert.NotNull(meta);
239+
240+
var forbiddenIncludes = meta["forbiddenIncludes"] as List<string>;
241+
Assert.NotNull(forbiddenIncludes);
242+
Assert.Equal(3, forbiddenIncludes.Count);
243+
Assert.Contains("posts", forbiddenIncludes);
244+
Assert.Contains("comments", forbiddenIncludes);
245+
Assert.Contains("tags", forbiddenIncludes);
246+
}
247+
248+
[Fact]
249+
public void OnActionExecuting_ErrorMetadata_ContainsCorrectInfo()
250+
{
251+
var attribute = new AllowedIncludesAttribute("author", "posts");
252+
var context = CreateContext("author,forbidden");
253+
254+
attribute.OnActionExecuting(context);
255+
256+
Assert.NotNull(context.Result);
257+
var objectResult = Assert.IsType<ObjectResult>(context.Result);
258+
var errorResponse = Assert.IsType<JsonApiErrorResponse>(objectResult.Value);
259+
260+
var errorWithMeta = errorResponse.Errors[0] as JsonApiErrorWithMeta;
261+
Assert.NotNull(errorWithMeta);
262+
var meta = errorWithMeta.Meta;
263+
Assert.NotNull(meta);
264+
265+
var requestedIncludes = meta["requestedIncludes"] as List<string>;
266+
Assert.NotNull(requestedIncludes);
267+
Assert.Equal(2, requestedIncludes.Count);
268+
Assert.Contains("author", requestedIncludes);
269+
Assert.Contains("forbidden", requestedIncludes);
270+
271+
var forbiddenIncludes = meta["forbiddenIncludes"] as List<string>;
272+
Assert.NotNull(forbiddenIncludes);
273+
Assert.Single(forbiddenIncludes);
274+
Assert.Contains("forbidden", forbiddenIncludes);
275+
276+
var allowedIncludes = meta["allowedIncludes"] as string[];
277+
Assert.NotNull(allowedIncludes);
278+
Assert.Equal(2, allowedIncludes.Length);
279+
Assert.Contains("author", allowedIncludes);
280+
Assert.Contains("posts", allowedIncludes);
281+
}
282+
}

0 commit comments

Comments
 (0)