Skip to content

Commit ce8c5be

Browse files
authored
Merge pull request #818 from Chris0Jeky/test/property-based-adversarial-input
TST-50: Property-based and adversarial input tests
2 parents 599759e + 746bbc8 commit ce8c5be

7 files changed

Lines changed: 1845 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using System.Text;
4+
using FluentAssertions;
5+
using Taskdeck.Api.Tests.Support;
6+
using Taskdeck.Application.DTOs;
7+
using Xunit;
8+
9+
namespace Taskdeck.Api.Tests;
10+
11+
/// <summary>
12+
/// Extended adversarial tests for capture/inbox endpoint.
13+
/// Exercises binary data, null bytes, very long strings, nested JSON,
14+
/// and random binary content — verifying NO 500 responses.
15+
/// </summary>
16+
public class CaptureAdversarialTests : IClassFixture<TestWebApplicationFactory>
17+
{
18+
private readonly TestWebApplicationFactory _factory;
19+
private readonly HttpClient _client;
20+
private bool _isAuthenticated;
21+
22+
public CaptureAdversarialTests(TestWebApplicationFactory factory)
23+
{
24+
_factory = factory;
25+
_client = factory.CreateClient();
26+
}
27+
28+
private async Task EnsureAuthenticatedAsync()
29+
{
30+
if (_isAuthenticated) return;
31+
await ApiTestHarness.AuthenticateAsync(_client, "capture-adversarial");
32+
_isAuthenticated = true;
33+
}
34+
35+
// ─────────────────────── Very long strings ───────────────────────
36+
37+
[Theory]
38+
[InlineData(0)]
39+
[InlineData(1)]
40+
[InlineData(1000)]
41+
[InlineData(20_000)] // at limit
42+
[InlineData(20_001)] // just over limit
43+
[InlineData(100_000)] // far over limit
44+
public async Task CaptureItem_WithVariousTextLengths_NeverReturns500(int length)
45+
{
46+
await EnsureAuthenticatedAsync();
47+
48+
var text = length == 0 ? "" : new string('c', length);
49+
var response = await _client.PostAsJsonAsync("/api/capture/items",
50+
new CreateCaptureItemDto(null, text));
51+
52+
((int)response.StatusCode).Should().BeLessThan(500,
53+
$"Capture returned 500 for text of {length} chars");
54+
}
55+
56+
// ─────────────────────── Null bytes and control characters ───────────────────────
57+
58+
[Theory]
59+
[InlineData("\u0000")]
60+
[InlineData("text\u0000with\u0000null\u0000bytes")]
61+
[InlineData("\u0001\u0002\u0003\u0004\u0005\u0006\u0007")]
62+
[InlineData("\u0008\u000B\u000C\u000E\u000F\u0010")]
63+
[InlineData("\x1B[31mcolored\x1B[0m")]
64+
[InlineData("\r\n\r\n\r\n")]
65+
[InlineData("\t\t\t\t\t")]
66+
[InlineData("before\u0000after")]
67+
public async Task CaptureItem_WithControlChars_NeverReturns500(string text)
68+
{
69+
await EnsureAuthenticatedAsync();
70+
71+
var response = await _client.PostAsJsonAsync("/api/capture/items",
72+
new CreateCaptureItemDto(null, text));
73+
74+
((int)response.StatusCode).Should().BeLessThan(500,
75+
$"Capture returned 500 for text with control chars");
76+
}
77+
78+
// ─────────────────────── Unicode edge cases ───────────────────────
79+
80+
[Theory]
81+
[InlineData("\uFEFF")] // BOM
82+
[InlineData("\uFFFD")] // replacement character
83+
[InlineData("\u200B")] // zero-width space
84+
[InlineData("\u200E")] // LTR mark
85+
[InlineData("\u202E")] // RTL override
86+
[InlineData("\u0301")] // combining accent
87+
[InlineData("e\u0301")] // decomposed e-acute
88+
[InlineData("\u00E9")] // precomposed e-acute
89+
[InlineData("👨‍👩‍👧‍👦")] // family emoji
90+
[InlineData("𝕋𝕖𝕤𝕥")] // math bold
91+
[InlineData("田中太郎")] // CJK
92+
[InlineData("مرحبا")] // Arabic RTL
93+
[InlineData("\u0E01\u0E38")] // Thai combining
94+
[InlineData("\uDBFF\uDFFF")] // max surrogate pair
95+
public async Task CaptureItem_WithUnicodeEdgeCases_NeverReturns500(string text)
96+
{
97+
await EnsureAuthenticatedAsync();
98+
99+
var response = await _client.PostAsJsonAsync("/api/capture/items",
100+
new CreateCaptureItemDto(null, text));
101+
102+
((int)response.StatusCode).Should().BeLessThan(500,
103+
$"Capture returned 500 for unicode edge case");
104+
}
105+
106+
// ─────────────────────── Nested JSON as text content ───────────────────────
107+
108+
[Theory]
109+
[InlineData("{\"nested\": true}")]
110+
[InlineData("[1, 2, 3]")]
111+
[InlineData("{\"__proto__\": {\"admin\": true}}")]
112+
[InlineData("{\"constructor\": {\"prototype\": {\"isAdmin\": true}}}")]
113+
[InlineData("{\"action\": \"delete\", \"target\": \"all_boards\"}")]
114+
[InlineData("{{7*7}}")]
115+
[InlineData("${7*7}")]
116+
[InlineData("#{7*7}")]
117+
public async Task CaptureItem_WithNestedJsonAndTemplates_NeverReturns500(string text)
118+
{
119+
await EnsureAuthenticatedAsync();
120+
121+
var response = await _client.PostAsJsonAsync("/api/capture/items",
122+
new CreateCaptureItemDto(null, text));
123+
124+
((int)response.StatusCode).Should().BeLessThan(500,
125+
$"Capture returned 500 for nested JSON/template content");
126+
127+
if (response.IsSuccessStatusCode)
128+
{
129+
var item = await response.Content.ReadFromJsonAsync<CaptureItemDto>();
130+
item.Should().NotBeNull();
131+
item!.RawText.Should().Contain(text,
132+
"nested JSON should be stored as literal text, not interpreted");
133+
}
134+
}
135+
136+
// ─────────────────────── XSS/injection payloads ───────────────────────
137+
138+
[Theory]
139+
[InlineData("<script>alert(document.cookie)</script>")]
140+
[InlineData("<img src=x onerror=alert(1)>")]
141+
[InlineData("<svg onload=alert(1)>")]
142+
[InlineData("'; DROP TABLE capture_items; --")]
143+
[InlineData("\" OR 1=1 --")]
144+
[InlineData("Robert'); DROP TABLE students;--")]
145+
[InlineData("javascript:alert(1)")]
146+
[InlineData("data:text/html,<script>alert(1)</script>")]
147+
public async Task CaptureItem_WithInjectionPayloads_StoredVerbatim(string text)
148+
{
149+
await EnsureAuthenticatedAsync();
150+
151+
var response = await _client.PostAsJsonAsync("/api/capture/items",
152+
new CreateCaptureItemDto(null, text));
153+
154+
((int)response.StatusCode).Should().BeLessThan(500,
155+
$"Capture returned 500 for injection payload");
156+
157+
if (response.IsSuccessStatusCode)
158+
{
159+
var item = await response.Content.ReadFromJsonAsync<CaptureItemDto>();
160+
item.Should().NotBeNull();
161+
// Injection payloads should be stored verbatim, not sanitized
162+
item!.RawText.Should().Contain(text);
163+
}
164+
}
165+
166+
// ─────────────────────── Optional field adversarial ───────────────────────
167+
168+
[Theory]
169+
[InlineData("<script>", null, null)]
170+
[InlineData("test", "<script>alert(1)</script>", null)]
171+
[InlineData("test", null, "<script>")]
172+
[InlineData("test", "'; DROP TABLE capture_items; --", "'; DROP TABLE capture_items; --")]
173+
public async Task CaptureItem_WithAdversarialOptionalFields_NeverReturns500(
174+
string text, string? titleHint, string? externalRef)
175+
{
176+
await EnsureAuthenticatedAsync();
177+
178+
var response = await _client.PostAsJsonAsync("/api/capture/items",
179+
new CreateCaptureItemDto(null, text, TitleHint: titleHint, ExternalRef: externalRef));
180+
181+
((int)response.StatusCode).Should().BeLessThan(500,
182+
"Capture returned 500 for adversarial optional fields");
183+
}
184+
185+
// ─────────────────────── Malformed JSON bodies ───────────────────────
186+
187+
[Theory]
188+
[InlineData("{")]
189+
[InlineData("[")]
190+
[InlineData("null")]
191+
[InlineData("\"just a string\"")]
192+
[InlineData("12345")]
193+
[InlineData("{\"text\": null}")]
194+
[InlineData("{\"boardId\": \"not-a-guid\", \"text\": \"test\"}")]
195+
[InlineData("{\"text\": 12345}")]
196+
public async Task CaptureItem_WithMalformedJson_NeverReturns500(string body)
197+
{
198+
await EnsureAuthenticatedAsync();
199+
200+
var content = new StringContent(body, Encoding.UTF8, "application/json");
201+
var response = await _client.PostAsync("/api/capture/items", content);
202+
203+
((int)response.StatusCode).Should().BeLessThan(500,
204+
$"Capture returned 500 for malformed JSON: {body}");
205+
}
206+
207+
// ─────────────────────── Board ID adversarial ───────────────────────
208+
209+
[Fact]
210+
public async Task CaptureItem_WithEmptyGuidBoardId_NeverReturns500()
211+
{
212+
await EnsureAuthenticatedAsync();
213+
214+
var response = await _client.PostAsJsonAsync("/api/capture/items",
215+
new CreateCaptureItemDto(Guid.Empty, "test text"));
216+
217+
((int)response.StatusCode).Should().BeLessThan(500,
218+
"Capture returned 500 for Guid.Empty board ID");
219+
}
220+
221+
[Fact]
222+
public async Task CaptureItem_WithNonexistentBoardId_NeverReturns500()
223+
{
224+
await EnsureAuthenticatedAsync();
225+
226+
var response = await _client.PostAsJsonAsync("/api/capture/items",
227+
new CreateCaptureItemDto(Guid.NewGuid(), "test text"));
228+
229+
((int)response.StatusCode).Should().BeLessThan(500,
230+
"Capture returned 500 for nonexistent board ID");
231+
}
232+
}

0 commit comments

Comments
 (0)