Skip to content

Commit 25e9475

Browse files
authored
Merge pull request #824 from Chris0Jeky/test/property-based-adversarial-input-tests
test: property-based and adversarial input tests (#717)
2 parents a15910d + a52b309 commit 25e9475

10 files changed

Lines changed: 2179 additions & 0 deletions

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

Lines changed: 362 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using System.Text.Json;
2+
using FluentAssertions;
3+
using FsCheck;
4+
using FsCheck.Fluent;
5+
using FsCheck.Xunit;
6+
using Taskdeck.Application.DTOs;
7+
using Taskdeck.Domain.Entities;
8+
using Xunit;
9+
10+
namespace Taskdeck.Application.Tests.Fuzz;
11+
12+
/// <summary>
13+
/// Property-based JSON serialization round-trip tests for Chat DTOs.
14+
/// Key property: serialize then deserialize produces identical object.
15+
/// Exercises adversarial string content in titles, messages, and metadata.
16+
/// </summary>
17+
public class ChatDtoSerializationFuzzTests
18+
{
19+
private const int MaxTests = 200;
20+
21+
private static readonly JsonSerializerOptions JsonOptions = new()
22+
{
23+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
24+
PropertyNameCaseInsensitive = true,
25+
WriteIndented = false
26+
};
27+
28+
private static Gen<ChatMessageRole> RoleGen() =>
29+
Gen.Elements(ChatMessageRole.User, ChatMessageRole.Assistant, ChatMessageRole.System);
30+
31+
private static Gen<ChatSessionStatus> StatusGen() =>
32+
Gen.Elements(ChatSessionStatus.Active, ChatSessionStatus.Archived);
33+
34+
// ─────────────────────── ChatSessionDto round-trip ───────────────────────
35+
36+
[Property(MaxTest = MaxTests)]
37+
public Property ChatSessionDto_RoundTrip_PreservesAllFields()
38+
{
39+
return Prop.ForAll(
40+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
41+
Arb.From(StatusGen()),
42+
(title, status) =>
43+
{
44+
var dto = new ChatSessionDto(
45+
Guid.NewGuid(),
46+
Guid.NewGuid(),
47+
Guid.NewGuid(),
48+
title,
49+
status,
50+
DateTimeOffset.UtcNow,
51+
DateTimeOffset.UtcNow,
52+
new List<ChatMessageDto>());
53+
54+
var json = JsonSerializer.Serialize(dto, JsonOptions);
55+
var deserialized = JsonSerializer.Deserialize<ChatSessionDto>(json, JsonOptions);
56+
57+
deserialized.Should().NotBeNull();
58+
deserialized!.Title.Should().Be(title);
59+
deserialized.Status.Should().Be(status);
60+
deserialized.Id.Should().Be(dto.Id);
61+
deserialized.UserId.Should().Be(dto.UserId);
62+
deserialized.BoardId.Should().Be(dto.BoardId);
63+
});
64+
}
65+
66+
// ─────────────────────── ChatMessageDto round-trip ───────────────────────
67+
68+
[Property(MaxTest = MaxTests)]
69+
public Property ChatMessageDto_RoundTrip_PreservesAllFields()
70+
{
71+
return Prop.ForAll(
72+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
73+
Arb.From(RoleGen()),
74+
Arb.From(FuzzTestGenerators.NullableStringGen()),
75+
(content, role, degradedReason) =>
76+
{
77+
var dto = new ChatMessageDto(
78+
Guid.NewGuid(),
79+
Guid.NewGuid(),
80+
role,
81+
content,
82+
"text",
83+
null,
84+
42,
85+
DateTimeOffset.UtcNow,
86+
degradedReason);
87+
88+
var json = JsonSerializer.Serialize(dto, JsonOptions);
89+
var deserialized = JsonSerializer.Deserialize<ChatMessageDto>(json, JsonOptions);
90+
91+
deserialized.Should().NotBeNull();
92+
deserialized!.Content.Should().Be(content);
93+
deserialized.Role.Should().Be(role);
94+
deserialized.DegradedReason.Should().Be(degradedReason);
95+
deserialized.MessageType.Should().Be("text");
96+
deserialized.TokenUsage.Should().Be(42);
97+
});
98+
}
99+
100+
// ─────────────────────── CreateChatSessionDto round-trip ───────────────────────
101+
102+
[Property(MaxTest = MaxTests)]
103+
public Property CreateChatSessionDto_RoundTrip_Identity()
104+
{
105+
return Prop.ForAll(
106+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
107+
title =>
108+
{
109+
var dto = new CreateChatSessionDto(title, Guid.NewGuid());
110+
111+
var json = JsonSerializer.Serialize(dto, JsonOptions);
112+
var deserialized = JsonSerializer.Deserialize<CreateChatSessionDto>(json, JsonOptions);
113+
114+
deserialized.Should().NotBeNull();
115+
deserialized!.Title.Should().Be(title);
116+
deserialized.BoardId.Should().Be(dto.BoardId);
117+
});
118+
}
119+
120+
// ─────────────────────── SendChatMessageDto round-trip ───────────────────────
121+
122+
[Property(MaxTest = MaxTests)]
123+
public Property SendChatMessageDto_RoundTrip_Identity()
124+
{
125+
return Prop.ForAll(
126+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
127+
ArbMap.Default.ArbFor<bool>(),
128+
(content, requestProposal) =>
129+
{
130+
var dto = new SendChatMessageDto(content, requestProposal);
131+
132+
var json = JsonSerializer.Serialize(dto, JsonOptions);
133+
var deserialized = JsonSerializer.Deserialize<SendChatMessageDto>(json, JsonOptions);
134+
135+
deserialized.Should().NotBeNull();
136+
deserialized!.Content.Should().Be(content);
137+
deserialized.RequestProposal.Should().Be(requestProposal);
138+
});
139+
}
140+
141+
// ─────────────────────── ChatSessionDto with messages list ───────────────────────
142+
143+
[Property(MaxTest = MaxTests)]
144+
public Property ChatSessionDto_WithMessages_RoundTrip()
145+
{
146+
return Prop.ForAll(
147+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
148+
Arb.From(FuzzTestGenerators.AdversarialStringGen()),
149+
(title, msgContent) =>
150+
{
151+
var messages = new List<ChatMessageDto>
152+
{
153+
new(Guid.NewGuid(), Guid.NewGuid(), ChatMessageRole.User,
154+
msgContent, "text", null, null, DateTimeOffset.UtcNow),
155+
new(Guid.NewGuid(), Guid.NewGuid(), ChatMessageRole.Assistant,
156+
title, "text", Guid.NewGuid(), 100, DateTimeOffset.UtcNow)
157+
};
158+
159+
var dto = new ChatSessionDto(
160+
Guid.NewGuid(), Guid.NewGuid(), null, title,
161+
ChatSessionStatus.Active, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
162+
messages);
163+
164+
var json = JsonSerializer.Serialize(dto, JsonOptions);
165+
var deserialized = JsonSerializer.Deserialize<ChatSessionDto>(json, JsonOptions);
166+
167+
deserialized.Should().NotBeNull();
168+
deserialized!.RecentMessages.Should().HaveCount(2);
169+
deserialized.RecentMessages[0].Content.Should().Be(msgContent);
170+
deserialized.RecentMessages[1].Content.Should().Be(title);
171+
});
172+
}
173+
174+
// ─────────────────────── Malformed JSON deserialization ───────────────────────
175+
176+
[Theory]
177+
[InlineData("{}")]
178+
[InlineData("{\"title\": null}")]
179+
[InlineData("{\"extra_field\": \"value\"}")]
180+
[InlineData("{\"title\": 12345}")]
181+
[InlineData("null")]
182+
public void CreateChatSessionDto_MalformedJson_HandledGracefully(string json)
183+
{
184+
try
185+
{
186+
var result = JsonSerializer.Deserialize<CreateChatSessionDto>(json, JsonOptions);
187+
// If it deserializes, that's fine — API layer validates
188+
}
189+
catch (JsonException)
190+
{
191+
// Expected for truly malformed JSON
192+
}
193+
}
194+
}
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+
}

0 commit comments

Comments
 (0)