Skip to content

Commit a52b309

Browse files
committed
fix: address adversarial review findings in property-based tests
- Fix SendChatMessage_WithAdversarialContent_NeverReturns500 to assert session creation is not 500 before early return (was silently passing when session endpoint returned server errors) - Change EmptyGuidUserId_AlwaysThrows from [Property] to [Fact] in KnowledgeDocumentPropertyTests and NotificationPropertyTests (no generated input, was running identical assertion 200 times) - Extract AdversarialStringGen to shared TestGenerators class in Domain.Tests and FuzzTestGenerators in Application.Tests, replacing 7 duplicate copies with the comprehensive variant set from EntityAdversarialInputTests (adds lone surrogates, replacement char, CRLF, ANSI escape, template injection, SSRF vectors) - Fix misleading comment on DangerousUrl_StoredVerbatim to reflect that domain constructor calls Trim()
1 parent 709f201 commit a52b309

10 files changed

Lines changed: 181 additions & 161 deletions

backend/tests/Taskdeck.Api.Tests/RawJsonAdversarialApiTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ public async Task SendChatMessage_WithAdversarialContent_NeverReturns500(string
287287
var sessionResponse = await _client.PostAsJsonAsync("/api/llm/chat/sessions",
288288
new CreateChatSessionDto("Test Session", boardId));
289289

290-
if (!sessionResponse.IsSuccessStatusCode) return; // Skip if session creation fails
290+
((int)sessionResponse.StatusCode).Should().BeLessThan(500,
291+
$"Chat session creation returned 500 for content: {messageContent}");
292+
293+
if (!sessionResponse.IsSuccessStatusCode) return; // Skip if session creation returns 4xx
291294

292295
var session = await sessionResponse.Content.ReadFromJsonAsync<ChatSessionDto>();
293296

backend/tests/Taskdeck.Application.Tests/Fuzz/ChatDtoSerializationFuzzTests.cs

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,6 @@ public class ChatDtoSerializationFuzzTests
2525
WriteIndented = false
2626
};
2727

28-
private static Gen<string> AdversarialStringGen() => Gen.OneOf(
29-
Gen.Constant("\u0000"),
30-
Gen.Constant("\uFEFF"),
31-
Gen.Constant("\u200B"),
32-
Gen.Constant("<script>alert('xss')</script>"),
33-
Gen.Constant("'; DROP TABLE chat; --"),
34-
Gen.Constant("\"quoted\"string\""),
35-
Gen.Constant("back\\slash"),
36-
Gen.Constant("new\nline\ttab"),
37-
Gen.Constant("emoji 👨‍👩‍👧‍👦"),
38-
Gen.Constant("田中太郎"),
39-
Gen.Constant("مرحبا"),
40-
Gen.Constant("{\"nested\": true}"),
41-
Gen.Constant(""),
42-
ArbMap.Default.ArbFor<string>().Generator.Where(s => s != null)
43-
);
44-
45-
private static Gen<string?> NullableStringGen() => Gen.OneOf(
46-
Gen.Constant((string?)null),
47-
AdversarialStringGen().Select(s => (string?)s)
48-
);
49-
5028
private static Gen<ChatMessageRole> RoleGen() =>
5129
Gen.Elements(ChatMessageRole.User, ChatMessageRole.Assistant, ChatMessageRole.System);
5230

@@ -59,7 +37,7 @@ private static Gen<ChatSessionStatus> StatusGen() =>
5937
public Property ChatSessionDto_RoundTrip_PreservesAllFields()
6038
{
6139
return Prop.ForAll(
62-
Arb.From(AdversarialStringGen()),
40+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
6341
Arb.From(StatusGen()),
6442
(title, status) =>
6543
{
@@ -91,9 +69,9 @@ public Property ChatSessionDto_RoundTrip_PreservesAllFields()
9169
public Property ChatMessageDto_RoundTrip_PreservesAllFields()
9270
{
9371
return Prop.ForAll(
94-
Arb.From(AdversarialStringGen()),
72+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
9573
Arb.From(RoleGen()),
96-
Arb.From(NullableStringGen()),
74+
Arb.From(FuzzTestGenerators.NullableStringGen()),
9775
(content, role, degradedReason) =>
9876
{
9977
var dto = new ChatMessageDto(
@@ -125,7 +103,7 @@ public Property ChatMessageDto_RoundTrip_PreservesAllFields()
125103
public Property CreateChatSessionDto_RoundTrip_Identity()
126104
{
127105
return Prop.ForAll(
128-
Arb.From(AdversarialStringGen()),
106+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
129107
title =>
130108
{
131109
var dto = new CreateChatSessionDto(title, Guid.NewGuid());
@@ -145,7 +123,7 @@ public Property CreateChatSessionDto_RoundTrip_Identity()
145123
public Property SendChatMessageDto_RoundTrip_Identity()
146124
{
147125
return Prop.ForAll(
148-
Arb.From(AdversarialStringGen()),
126+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
149127
ArbMap.Default.ArbFor<bool>(),
150128
(content, requestProposal) =>
151129
{
@@ -166,8 +144,8 @@ public Property SendChatMessageDto_RoundTrip_Identity()
166144
public Property ChatSessionDto_WithMessages_RoundTrip()
167145
{
168146
return Prop.ForAll(
169-
Arb.From(AdversarialStringGen()),
170-
Arb.From(AdversarialStringGen()),
147+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
148+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
171149
(title, msgContent) =>
172150
{
173151
var messages = new List<ChatMessageDto>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using FsCheck;
2+
using FsCheck.Fluent;
3+
4+
namespace Taskdeck.Application.Tests.Fuzz;
5+
6+
/// <summary>
7+
/// Shared FsCheck generators for DTO serialization fuzz tests.
8+
/// Centralises adversarial string generation so all fuzz tests
9+
/// exercise the same comprehensive input space.
10+
/// </summary>
11+
internal static class FuzzTestGenerators
12+
{
13+
/// <summary>
14+
/// Generates adversarial strings covering: Unicode edge cases (null byte, BOM,
15+
/// replacement char, surrogates, zero-width, combining, CJK, Arabic, emoji),
16+
/// control characters (bell, backspace, ANSI escape, CRLF), XSS/injection payloads,
17+
/// JSON-sensitive characters (quotes, backslashes), length boundaries (empty,
18+
/// whitespace), explicit null, and FsCheck random strings.
19+
/// </summary>
20+
public static Gen<string> AdversarialStringGen() => Gen.OneOf(
21+
// Unicode edge cases
22+
Gen.Constant("\u0000"), // null byte
23+
Gen.Constant("\uFEFF"), // BOM
24+
Gen.Constant("\uFFFD"), // replacement character
25+
Gen.Constant("\u200B"), // zero-width space
26+
Gen.Constant("\u202E"), // right-to-left override
27+
Gen.Constant("\u0301"), // combining accent
28+
Gen.Constant("\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466"), // family emoji
29+
Gen.Constant("\u7530\u4E2D\u592A\u90CE"), // CJK
30+
Gen.Constant("\u0645\u0631\u062D\u0628\u0627"), // Arabic RTL
31+
32+
// JSON-sensitive characters
33+
Gen.Constant("\"quoted\"string\""),
34+
Gen.Constant("back\\slash"),
35+
Gen.Constant("new\nline\ttab"),
36+
Gen.Constant("null\x00byte"),
37+
38+
// XSS/injection payloads
39+
Gen.Constant("<script>alert('xss')</script>"),
40+
Gen.Constant("'; DROP TABLE boards; --"),
41+
Gen.Constant("{\"nested\": true}"),
42+
Gen.Constant("{{constructor.constructor('return this')()}}"),
43+
Gen.Constant("${7*7}"),
44+
45+
// Length boundary strings
46+
Gen.Constant(""),
47+
Gen.Constant(" "),
48+
49+
// Explicit null
50+
Gen.Constant((string)null!),
51+
52+
// Arbitrary from FsCheck
53+
ArbMap.Default.ArbFor<string>().Generator.Where(s => s != null)
54+
);
55+
56+
/// <summary>
57+
/// Wraps <see cref="AdversarialStringGen"/> as nullable for optional-field testing.
58+
/// </summary>
59+
public static Gen<string?> NullableStringGen() => Gen.OneOf(
60+
Gen.Constant((string?)null),
61+
AdversarialStringGen().Select(s => (string?)s)
62+
);
63+
}

backend/tests/Taskdeck.Application.Tests/Fuzz/NotificationDtoSerializationFuzzTests.cs

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,6 @@ public class NotificationDtoSerializationFuzzTests
2424
WriteIndented = false
2525
};
2626

27-
private static Gen<string> AdversarialStringGen() => Gen.OneOf(
28-
Gen.Constant("\u0000"),
29-
Gen.Constant("\uFEFF"),
30-
Gen.Constant("\u200B"),
31-
Gen.Constant("<script>alert('xss')</script>"),
32-
Gen.Constant("'; DROP TABLE notifications; --"),
33-
Gen.Constant("\"quoted\""),
34-
Gen.Constant("back\\slash"),
35-
Gen.Constant("new\nline"),
36-
Gen.Constant("emoji 👨‍👩‍👧‍👦"),
37-
Gen.Constant("田中太郎"),
38-
Gen.Constant("{\"nested\": true}"),
39-
Gen.Constant(""),
40-
ArbMap.Default.ArbFor<string>().Generator.Where(s => s != null)
41-
);
42-
43-
private static Gen<string?> NullableStringGen() => Gen.OneOf(
44-
Gen.Constant((string?)null),
45-
AdversarialStringGen().Select(s => (string?)s)
46-
);
47-
4827
private static Gen<NotificationType> TypeGen() =>
4928
Gen.Elements(
5029
NotificationType.Mention,
@@ -62,7 +41,7 @@ private static Gen<NotificationCadence> CadenceGen() =>
6241
public Property NotificationDto_RoundTrip_PreservesTitle()
6342
{
6443
return Prop.ForAll(
65-
Arb.From(AdversarialStringGen()),
44+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
6645
Arb.From(TypeGen()),
6746
Arb.From(CadenceGen()),
6847
(title, type, cadence) =>
@@ -98,7 +77,7 @@ public Property NotificationDto_RoundTrip_PreservesTitle()
9877
public Property NotificationDto_RoundTrip_PreservesMessage()
9978
{
10079
return Prop.ForAll(
101-
Arb.From(AdversarialStringGen()),
80+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
10281
message =>
10382
{
10483
var dto = new NotificationDto(
@@ -131,9 +110,9 @@ public Property NotificationDto_RoundTrip_PreservesMessage()
131110
public Property CreateNotificationRequestDto_RoundTrip_Identity()
132111
{
133112
return Prop.ForAll(
134-
Arb.From(AdversarialStringGen()),
135-
Arb.From(AdversarialStringGen()),
136-
Arb.From(NullableStringGen()),
113+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
114+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
115+
Arb.From(FuzzTestGenerators.NullableStringGen()),
137116
(title, message, sourceEntityType) =>
138117
{
139118
var dto = new CreateNotificationRequestDto(
@@ -163,7 +142,7 @@ public Property CreateNotificationRequestDto_RoundTrip_Identity()
163142
public Property NotificationDto_WithNullableFields_RoundTrips()
164143
{
165144
return Prop.ForAll(
166-
Arb.From(NullableStringGen()),
145+
Arb.From(FuzzTestGenerators.NullableStringGen()),
167146
ArbMap.Default.ArbFor<bool>(),
168147
(sourceEntityType, isRead) =>
169148
{

backend/tests/Taskdeck.Domain.Tests/PropertyBased/ChatMessagePropertyTests.cs

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,6 @@ public class ChatMessagePropertyTests
2424

2525
// ─────────────────────── Generators ───────────────────────
2626

27-
private static Gen<string> AdversarialStringGen() => Gen.OneOf(
28-
Gen.Constant("\u0000"),
29-
Gen.Constant("\uFEFF"),
30-
Gen.Constant("\u200B"),
31-
Gen.Constant("\u202E"),
32-
Gen.Constant("<script>alert('xss')</script>"),
33-
Gen.Constant("'; DROP TABLE messages; --"),
34-
Gen.Constant("👨‍👩‍👧‍👦"),
35-
Gen.Constant("田中太郎"),
36-
Gen.Constant("{\"nested\": true}"),
37-
Gen.Constant("\x01\x02\x03"),
38-
Gen.Constant(""),
39-
Gen.Constant(" "),
40-
Gen.Constant((string)null!),
41-
ArbMap.Default.ArbFor<string>().Generator.Where(s => s != null)
42-
);
43-
4427
private static Gen<string> ValidContentGen() =>
4528
Gen.Choose(1, 500)
4629
.SelectMany(len =>
@@ -108,7 +91,7 @@ public Property EmptySessionId_AlwaysThrows()
10891
public Property Constructor_NeverThrowsUnhandled_OnAdversarialContent()
10992
{
11093
return Prop.ForAll(
111-
Arb.From(AdversarialStringGen()),
94+
Arb.From(TestGenerators.AdversarialStringGen()),
11295
content =>
11396
{
11497
try
@@ -221,7 +204,7 @@ public Property SetProposalId_NonEmptyGuid_Succeeds()
221204
public Property Constructor_WithAdversarialDegradedReason_NeverThrowsUnhandled()
222205
{
223206
return Prop.ForAll(
224-
Arb.From(AdversarialStringGen()),
207+
Arb.From(TestGenerators.AdversarialStringGen()),
225208
reason =>
226209
{
227210
try

backend/tests/Taskdeck.Domain.Tests/PropertyBased/ChatSessionPropertyTests.cs

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,6 @@ public class ChatSessionPropertyTests
1919

2020
// ─────────────────────── Generators ───────────────────────
2121

22-
private static Gen<string> AdversarialStringGen() => Gen.OneOf(
23-
Gen.Constant("\u0000"),
24-
Gen.Constant("\uFEFF"),
25-
Gen.Constant("\u200B"),
26-
Gen.Constant("\u202E"),
27-
Gen.Constant("<script>alert('xss')</script>"),
28-
Gen.Constant("'; DROP TABLE sessions; --"),
29-
Gen.Constant("👨‍👩‍👧‍👦"),
30-
Gen.Constant("田中太郎"),
31-
Gen.Constant("\x01\x02\x03"),
32-
Gen.Constant("\x1B[31m"),
33-
Gen.Constant("{\"nested\": true}"),
34-
Gen.Constant(""),
35-
Gen.Constant(" "),
36-
Gen.Constant("\t"),
37-
Gen.Constant((string)null!),
38-
ArbMap.Default.ArbFor<string>().Generator.Where(s => s != null)
39-
);
40-
4122
private static Arbitrary<string> ValidTitleArb()
4223
{
4324
var gen = Gen.Choose(1, 200)
@@ -111,7 +92,7 @@ public Property EmptyGuidUserId_AlwaysThrows()
11192
public Property Constructor_NeverThrowsUnhandled_OnAdversarialTitle()
11293
{
11394
return Prop.ForAll(
114-
Arb.From(AdversarialStringGen()),
95+
Arb.From(TestGenerators.AdversarialStringGen()),
11596
title =>
11697
{
11798
try
@@ -137,7 +118,7 @@ public Property Constructor_NeverThrowsUnhandled_OnAdversarialTitle()
137118
public Property UpdateTitle_NeverThrowsUnhandled_OnAdversarialTitle()
138119
{
139120
return Prop.ForAll(
140-
Arb.From(AdversarialStringGen()),
121+
Arb.From(TestGenerators.AdversarialStringGen()),
141122
newTitle =>
142123
{
143124
var session = new ChatSession(Guid.NewGuid(), "OriginalTitle");

backend/tests/Taskdeck.Domain.Tests/PropertyBased/KnowledgeDocumentPropertyTests.cs

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,6 @@ public class KnowledgeDocumentPropertyTests
1919

2020
// ─────────────────────── Generators ───────────────────────
2121

22-
private static Gen<string> AdversarialStringGen() => Gen.OneOf(
23-
Gen.Constant("\u0000"),
24-
Gen.Constant("\uFEFF"),
25-
Gen.Constant("\u200B"),
26-
Gen.Constant("<script>alert('xss')</script>"),
27-
Gen.Constant("'; DROP TABLE knowledge; --"),
28-
Gen.Constant("👨‍👩‍👧‍👦"),
29-
Gen.Constant("田中太郎"),
30-
Gen.Constant("{\"nested\": true}"),
31-
Gen.Constant(""),
32-
Gen.Constant(" "),
33-
Gen.Constant((string)null!),
34-
ArbMap.Default.ArbFor<string>().Generator.Where(s => s != null)
35-
);
36-
3722
private static Gen<string> ValidTitleGen() =>
3823
Gen.Choose(1, 200)
3924
.SelectMany(len =>
@@ -69,14 +54,13 @@ public Property ValidParams_AlwaysCreatesDocument()
6954
});
7055
}
7156

72-
[Property(MaxTest = MaxTests)]
73-
public Property EmptyGuidUserId_AlwaysThrows()
57+
[Fact]
58+
public void EmptyGuidUserId_AlwaysThrows()
7459
{
7560
var act = () => new KnowledgeDocument(
7661
Guid.Empty, "Title", "Content", KnowledgeSourceType.Manual);
7762
act.Should().Throw<DomainException>()
7863
.Where(e => e.ErrorCode == ErrorCodes.ValidationError);
79-
return true.ToProperty();
8064
}
8165

8266
// ─────────────────────── Title boundary values ───────────────────────
@@ -178,7 +162,7 @@ public void Tags_ExceedingLimit_Throws(int length)
178162
public Property Constructor_NeverThrowsUnhandled_OnAdversarialTitle()
179163
{
180164
return Prop.ForAll(
181-
Arb.From(AdversarialStringGen()),
165+
Arb.From(TestGenerators.AdversarialStringGen()),
182166
title =>
183167
{
184168
try
@@ -203,7 +187,7 @@ public Property Constructor_NeverThrowsUnhandled_OnAdversarialTitle()
203187
public Property Constructor_NeverThrowsUnhandled_OnAdversarialContent()
204188
{
205189
return Prop.ForAll(
206-
Arb.From(AdversarialStringGen()),
190+
Arb.From(TestGenerators.AdversarialStringGen()),
207191
content =>
208192
{
209193
try
@@ -264,8 +248,8 @@ public void Update_WhenArchived_Throws()
264248
public Property Update_WithAdversarialInputs_NeverThrowsUnhandled()
265249
{
266250
return Prop.ForAll(
267-
Arb.From(AdversarialStringGen()),
268-
Arb.From(AdversarialStringGen()),
251+
Arb.From(TestGenerators.AdversarialStringGen()),
252+
Arb.From(TestGenerators.AdversarialStringGen()),
269253
(title, content) =>
270254
{
271255
var doc = new KnowledgeDocument(

0 commit comments

Comments
 (0)