diff --git a/backend/tests/Taskdeck.Api.Tests/RawJsonAdversarialApiTests.cs b/backend/tests/Taskdeck.Api.Tests/RawJsonAdversarialApiTests.cs new file mode 100644 index 00000000..1ca57c14 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/RawJsonAdversarialApiTests.cs @@ -0,0 +1,362 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Xunit; + +namespace Taskdeck.Api.Tests; + +/// +/// Adversarial tests that send raw JSON to API endpoints to exercise edge cases +/// that typed DTO serialization cannot reach: floating-point positions, integer overflow, +/// type mismatches, duplicate board names, and card description boundary values. +/// Key property: NO 500 Internal Server Error from any malformed input. +/// +public class RawJsonAdversarialApiTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + private readonly HttpClient _client; + private bool _isAuthenticated; + + public RawJsonAdversarialApiTests(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + private async Task EnsureAuthenticatedAsync() + { + if (_isAuthenticated) return; + await ApiTestHarness.AuthenticateAsync(_client, "raw-json-adversarial"); + _isAuthenticated = true; + } + + // ─────────────────────── Card position as float/string/boundary via raw JSON ─────────────────────── + + [Theory] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": 3.14}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": -1}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": 2147483647}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": -2147483648}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": 9999999999999}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": \"not-a-number\"}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": null}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": true}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"position\": [1,2,3]}")] + public async Task CreateCard_WithAdversarialPosition_NeverReturns500(string bodyTemplate) + { + await EnsureAuthenticatedAsync(); + + // Create board and column + var boardResponse = await _client.PostAsJsonAsync("/api/boards", + new CreateBoardDto($"pos-test-{Guid.NewGuid():N}", null)); + boardResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var board = await boardResponse.Content.ReadFromJsonAsync(); + + var colResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board!.Id}/columns", + new CreateColumnDto(board.Id, "TestCol", null, null)); + colResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var col = await colResponse.Content.ReadFromJsonAsync(); + + // Substitute actual IDs into the raw JSON + var body = bodyTemplate + .Replace("BOARD_ID", board.Id.ToString()) + .Replace("COL_ID", col!.Id.ToString()); + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync($"/api/boards/{board.Id}/cards", content); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Card creation returned 500 for position edge case: {bodyTemplate}"); + } + + // ─────────────────────── Card description adversarial content via API ─────────────────────── + + [Theory] + [InlineData("")] + [InlineData("\u0000\u0001\u0002\u0003")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("'; DROP TABLE cards; --")] + [InlineData("\uFEFF\u200B\u202E\u0301")] + [InlineData("{\"__proto__\":{\"isAdmin\":true}}")] + [InlineData("{{constructor.constructor('return this')()}}")] + [InlineData("${7*7}")] + [InlineData("#{7*7}")] + public async Task CreateCard_WithAdversarialDescription_NeverReturns500(string description) + { + await EnsureAuthenticatedAsync(); + + var boardResponse = await _client.PostAsJsonAsync("/api/boards", + new CreateBoardDto($"desc-test-{Guid.NewGuid():N}", null)); + boardResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var board = await boardResponse.Content.ReadFromJsonAsync(); + + var colResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board!.Id}/columns", + new CreateColumnDto(board.Id, "TestCol", null, null)); + colResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var col = await colResponse.Content.ReadFromJsonAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/cards", + new CreateCardDto(board.Id, col!.Id, "ValidTitle", description, null, null)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Card creation returned 500 for description: {description}"); + + if (response.IsSuccessStatusCode) + { + var card = await response.Content.ReadFromJsonAsync(); + card.Should().NotBeNull(); + card!.Description.Should().Be(description, + "adversarial description should be stored verbatim"); + } + } + + // ─────────────────────── Card description boundary lengths ─────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2000)] + [InlineData(2001)] + [InlineData(100_000)] + public async Task CreateCard_WithVariousDescriptionLengths_NeverReturns500(int length) + { + await EnsureAuthenticatedAsync(); + + var boardResponse = await _client.PostAsJsonAsync("/api/boards", + new CreateBoardDto($"desc-len-{Guid.NewGuid():N}", null)); + boardResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var board = await boardResponse.Content.ReadFromJsonAsync(); + + var colResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board!.Id}/columns", + new CreateColumnDto(board.Id, "TestCol", null, null)); + colResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var col = await colResponse.Content.ReadFromJsonAsync(); + + var description = length == 0 ? "" : new string('d', length); + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/cards", + new CreateCardDto(board.Id, col!.Id, "ValidTitle", description, null, null)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Card creation returned 500 for description of {length} chars"); + } + + // ─────────────────────── Duplicate board names ─────────────────────── + + [Fact] + public async Task CreateBoard_DuplicateNames_DoesNotReturn500() + { + await EnsureAuthenticatedAsync(); + + var name = $"duplicate-test-{Guid.NewGuid():N}"; + + var first = await _client.PostAsJsonAsync("/api/boards", new CreateBoardDto(name, null)); + first.StatusCode.Should().Be(HttpStatusCode.Created); + + // Second board with same name + var second = await _client.PostAsJsonAsync("/api/boards", new CreateBoardDto(name, null)); + ((int)second.StatusCode).Should().BeLessThan(500, + "Duplicate board name should not cause 500"); + } + + [Fact] + public async Task CreateBoard_DuplicateUnicodeNames_DoesNotReturn500() + { + await EnsureAuthenticatedAsync(); + + var unicodeNames = new[] + { + "田中太郎のボード", + "مرحبا بالعالم", + "👨‍👩‍👧‍👦 Family Board", + "Board\u200BName", // zero-width space + "e\u0301", // combining character + }; + + foreach (var name in unicodeNames) + { + var first = await _client.PostAsJsonAsync("/api/boards", new CreateBoardDto(name, null)); + ((int)first.StatusCode).Should().BeLessThan(500, + $"First board creation with unicode name '{name}' should not return 500"); + + var second = await _client.PostAsJsonAsync("/api/boards", new CreateBoardDto(name, null)); + ((int)second.StatusCode).Should().BeLessThan(500, + $"Duplicate unicode board name '{name}' should not return 500"); + } + } + + // ─────────────────────── Card creation with extra unknown fields ─────────────────────── + + [Theory] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"__proto__\": {\"admin\": true}}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"constructor\": {\"prototype\": {}}}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"unknownField1\": \"val1\", \"unknownField2\": 42}")] + [InlineData("{\"boardId\": \"BOARD_ID\", \"columnId\": \"COL_ID\", \"title\": \"test\", \"isAdmin\": true, \"role\": \"superuser\"}")] + public async Task CreateCard_WithExtraUnknownFields_NeverReturns500(string bodyTemplate) + { + await EnsureAuthenticatedAsync(); + + var boardResponse = await _client.PostAsJsonAsync("/api/boards", + new CreateBoardDto($"extra-fields-{Guid.NewGuid():N}", null)); + boardResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var board = await boardResponse.Content.ReadFromJsonAsync(); + + var colResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board!.Id}/columns", + new CreateColumnDto(board.Id, "TestCol", null, null)); + colResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var col = await colResponse.Content.ReadFromJsonAsync(); + + var body = bodyTemplate + .Replace("BOARD_ID", board.Id.ToString()) + .Replace("COL_ID", col!.Id.ToString()); + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync($"/api/boards/{board.Id}/cards", content); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Card creation with extra fields returned 500: {bodyTemplate}"); + } + + // ─────────────────────── Board creation with extra unknown fields ─────────────────────── + + [Theory] + [InlineData("{\"name\": \"test\", \"__proto__\": {\"admin\": true}}")] + [InlineData("{\"name\": \"test\", \"constructor\": {\"prototype\": {}}}")] + [InlineData("{\"name\": \"test\", \"extraField\": \"ignored\", \"anotherExtra\": [1,2,3]}")] + public async Task CreateBoard_WithExtraUnknownFields_NeverReturns500(string body) + { + await EnsureAuthenticatedAsync(); + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/api/boards", content); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Board creation with extra fields returned 500: {body}"); + } + + // ─────────────────────── Chat session creation with adversarial title ─────────────────────── + + [Theory] + [InlineData("")] + [InlineData("'; DROP TABLE chat_sessions; --")] + [InlineData("\u0000\uFEFF\u200B")] + [InlineData("👨‍👩‍👧‍👦")] + [InlineData("{\"nested\": true}")] + [InlineData("")] + [InlineData(" ")] + public async Task CreateChatSession_WithAdversarialTitle_NeverReturns500(string title) + { + await EnsureAuthenticatedAsync(); + + var boardId = await ApiTestHarness.CreateBoardWithColumnAsync(_client, "chat-adversarial"); + + var response = await _client.PostAsJsonAsync("/api/llm/chat/sessions", + new CreateChatSessionDto(title, boardId)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Chat session creation returned 500 for title: {title}"); + } + + // ─────────────────────── Chat message with adversarial content ─────────────────────── + + [Theory] + [InlineData("")] + [InlineData("'; DROP TABLE chat_messages; --")] + [InlineData("\u0000\u0001\u0002")] + [InlineData("👨‍👩‍👧‍👦 emoji message")] + [InlineData("{\"action\": \"delete\", \"target\": \"all_boards\"}")] + [InlineData("")] + [InlineData(" ")] + public async Task SendChatMessage_WithAdversarialContent_NeverReturns500(string messageContent) + { + await EnsureAuthenticatedAsync(); + + var boardId = await ApiTestHarness.CreateBoardWithColumnAsync(_client, "chat-msg-adversarial"); + + // Create session + var sessionResponse = await _client.PostAsJsonAsync("/api/llm/chat/sessions", + new CreateChatSessionDto("Test Session", boardId)); + + ((int)sessionResponse.StatusCode).Should().BeLessThan(500, + $"Chat session creation returned 500 for content: {messageContent}"); + + if (!sessionResponse.IsSuccessStatusCode) return; // Skip if session creation returns 4xx + + var session = await sessionResponse.Content.ReadFromJsonAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/llm/chat/sessions/{session!.Id}/messages", + new SendChatMessageDto(messageContent)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Chat message sending returned 500 for content: {messageContent}"); + } + + // ─────────────────────── Capture creation with binary-like content ─────────────────────── + + [Fact] + public async Task CaptureItem_WithAllControlChars_NeverReturns500() + { + await EnsureAuthenticatedAsync(); + + // Generate a string with all ASCII control characters (0-31) + var chars = new char[32]; + for (int i = 0; i < 32; i++) + { + chars[i] = (char)i; + } + var controlString = "prefix" + new string(chars) + "suffix"; + + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, controlString)); + + ((int)response.StatusCode).Should().BeLessThan(500, + "Capture with all control characters should not return 500"); + } + + [Fact] + public async Task CaptureItem_WithEveryUnicodeBlockSample_NeverReturns500() + { + await EnsureAuthenticatedAsync(); + + // Sample from major Unicode blocks + var unicodeSamples = new[] + { + "\u0041", // Basic Latin (A) + "\u00C0", // Latin Extended-A + "\u0100", // Latin Extended-B + "\u0370", // Greek + "\u0400", // Cyrillic + "\u0500", // Cyrillic Supplement + "\u0590", // Hebrew + "\u0600", // Arabic + "\u0900", // Devanagari + "\u0E00", // Thai + "\u1100", // Hangul Jamo + "\u3000", // CJK Symbols + "\u4E00", // CJK Unified + "\uAC00", // Hangul Syllables + "\uFE00", // Variation Selectors + "\uFF00", // Halfwidth/Fullwidth + "\uFFFD", // Replacement Character + "\U0001F600", // Emoticons (surrogate pair) + }; + + var text = string.Join(" ", unicodeSamples); + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, text)); + + ((int)response.StatusCode).Should().BeLessThan(500, + "Capture with Unicode block samples should not return 500"); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/ChatDtoSerializationFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/ChatDtoSerializationFuzzTests.cs new file mode 100644 index 00000000..e4696b21 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/ChatDtoSerializationFuzzTests.cs @@ -0,0 +1,194 @@ +using System.Text.Json; +using FluentAssertions; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Application.Tests.Fuzz; + +/// +/// Property-based JSON serialization round-trip tests for Chat DTOs. +/// Key property: serialize then deserialize produces identical object. +/// Exercises adversarial string content in titles, messages, and metadata. +/// +public class ChatDtoSerializationFuzzTests +{ + private const int MaxTests = 200; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + private static Gen RoleGen() => + Gen.Elements(ChatMessageRole.User, ChatMessageRole.Assistant, ChatMessageRole.System); + + private static Gen StatusGen() => + Gen.Elements(ChatSessionStatus.Active, ChatSessionStatus.Archived); + + // ─────────────────────── ChatSessionDto round-trip ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property ChatSessionDto_RoundTrip_PreservesAllFields() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + Arb.From(StatusGen()), + (title, status) => + { + var dto = new ChatSessionDto( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + title, + status, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + new List()); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be(title); + deserialized.Status.Should().Be(status); + deserialized.Id.Should().Be(dto.Id); + deserialized.UserId.Should().Be(dto.UserId); + deserialized.BoardId.Should().Be(dto.BoardId); + }); + } + + // ─────────────────────── ChatMessageDto round-trip ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property ChatMessageDto_RoundTrip_PreservesAllFields() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + Arb.From(RoleGen()), + Arb.From(FuzzTestGenerators.NullableStringGen()), + (content, role, degradedReason) => + { + var dto = new ChatMessageDto( + Guid.NewGuid(), + Guid.NewGuid(), + role, + content, + "text", + null, + 42, + DateTimeOffset.UtcNow, + degradedReason); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Content.Should().Be(content); + deserialized.Role.Should().Be(role); + deserialized.DegradedReason.Should().Be(degradedReason); + deserialized.MessageType.Should().Be("text"); + deserialized.TokenUsage.Should().Be(42); + }); + } + + // ─────────────────────── CreateChatSessionDto round-trip ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property CreateChatSessionDto_RoundTrip_Identity() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + title => + { + var dto = new CreateChatSessionDto(title, Guid.NewGuid()); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be(title); + deserialized.BoardId.Should().Be(dto.BoardId); + }); + } + + // ─────────────────────── SendChatMessageDto round-trip ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property SendChatMessageDto_RoundTrip_Identity() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + ArbMap.Default.ArbFor(), + (content, requestProposal) => + { + var dto = new SendChatMessageDto(content, requestProposal); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Content.Should().Be(content); + deserialized.RequestProposal.Should().Be(requestProposal); + }); + } + + // ─────────────────────── ChatSessionDto with messages list ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property ChatSessionDto_WithMessages_RoundTrip() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + (title, msgContent) => + { + var messages = new List + { + new(Guid.NewGuid(), Guid.NewGuid(), ChatMessageRole.User, + msgContent, "text", null, null, DateTimeOffset.UtcNow), + new(Guid.NewGuid(), Guid.NewGuid(), ChatMessageRole.Assistant, + title, "text", Guid.NewGuid(), 100, DateTimeOffset.UtcNow) + }; + + var dto = new ChatSessionDto( + Guid.NewGuid(), Guid.NewGuid(), null, title, + ChatSessionStatus.Active, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, + messages); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.RecentMessages.Should().HaveCount(2); + deserialized.RecentMessages[0].Content.Should().Be(msgContent); + deserialized.RecentMessages[1].Content.Should().Be(title); + }); + } + + // ─────────────────────── Malformed JSON deserialization ─────────────────────── + + [Theory] + [InlineData("{}")] + [InlineData("{\"title\": null}")] + [InlineData("{\"extra_field\": \"value\"}")] + [InlineData("{\"title\": 12345}")] + [InlineData("null")] + public void CreateChatSessionDto_MalformedJson_HandledGracefully(string json) + { + try + { + var result = JsonSerializer.Deserialize(json, JsonOptions); + // If it deserializes, that's fine — API layer validates + } + catch (JsonException) + { + // Expected for truly malformed JSON + } + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/FuzzTestGenerators.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/FuzzTestGenerators.cs new file mode 100644 index 00000000..d15f3509 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/FuzzTestGenerators.cs @@ -0,0 +1,63 @@ +using FsCheck; +using FsCheck.Fluent; + +namespace Taskdeck.Application.Tests.Fuzz; + +/// +/// Shared FsCheck generators for DTO serialization fuzz tests. +/// Centralises adversarial string generation so all fuzz tests +/// exercise the same comprehensive input space. +/// +internal static class FuzzTestGenerators +{ + /// + /// Generates adversarial strings covering: Unicode edge cases (null byte, BOM, + /// replacement char, surrogates, zero-width, combining, CJK, Arabic, emoji), + /// control characters (bell, backspace, ANSI escape, CRLF), XSS/injection payloads, + /// JSON-sensitive characters (quotes, backslashes), length boundaries (empty, + /// whitespace), explicit null, and FsCheck random strings. + /// + public static Gen AdversarialStringGen() => Gen.OneOf( + // Unicode edge cases + Gen.Constant("\u0000"), // null byte + Gen.Constant("\uFEFF"), // BOM + Gen.Constant("\uFFFD"), // replacement character + Gen.Constant("\u200B"), // zero-width space + Gen.Constant("\u202E"), // right-to-left override + Gen.Constant("\u0301"), // combining accent + Gen.Constant("\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466"), // family emoji + Gen.Constant("\u7530\u4E2D\u592A\u90CE"), // CJK + Gen.Constant("\u0645\u0631\u062D\u0628\u0627"), // Arabic RTL + + // JSON-sensitive characters + Gen.Constant("\"quoted\"string\""), + Gen.Constant("back\\slash"), + Gen.Constant("new\nline\ttab"), + Gen.Constant("null\x00byte"), + + // XSS/injection payloads + Gen.Constant(""), + Gen.Constant("'; DROP TABLE boards; --"), + Gen.Constant("{\"nested\": true}"), + Gen.Constant("{{constructor.constructor('return this')()}}"), + Gen.Constant("${7*7}"), + + // Length boundary strings + Gen.Constant(""), + Gen.Constant(" "), + + // Explicit null + Gen.Constant((string)null!), + + // Arbitrary from FsCheck + ArbMap.Default.ArbFor().Generator.Where(s => s != null) + ); + + /// + /// Wraps as nullable for optional-field testing. + /// + public static Gen NullableStringGen() => Gen.OneOf( + Gen.Constant((string?)null), + AdversarialStringGen().Select(s => (string?)s) + ); +} diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/NotificationDtoSerializationFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/NotificationDtoSerializationFuzzTests.cs new file mode 100644 index 00000000..0e59b0f8 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/NotificationDtoSerializationFuzzTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using FluentAssertions; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Application.Tests.Fuzz; + +/// +/// Property-based JSON serialization round-trip tests for Notification DTOs. +/// Key property: serialize then deserialize produces identical object for all input content. +/// +public class NotificationDtoSerializationFuzzTests +{ + private const int MaxTests = 200; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + private static Gen TypeGen() => + Gen.Elements( + NotificationType.Mention, + NotificationType.Assignment, + NotificationType.ProposalOutcome, + NotificationType.BoardChange, + NotificationType.System); + + private static Gen CadenceGen() => + Gen.Elements(NotificationCadence.Immediate, NotificationCadence.Digest); + + // ─────────────────────── NotificationDto round-trip ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property NotificationDto_RoundTrip_PreservesTitle() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + Arb.From(TypeGen()), + Arb.From(CadenceGen()), + (title, type, cadence) => + { + var dto = new NotificationDto( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + type, + cadence, + title, + "Valid message", + "Card", + Guid.NewGuid(), + false, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be(title); + deserialized.Type.Should().Be(type); + deserialized.Cadence.Should().Be(cadence); + deserialized.Id.Should().Be(dto.Id); + deserialized.IsRead.Should().BeFalse(); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NotificationDto_RoundTrip_PreservesMessage() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + message => + { + var dto = new NotificationDto( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + NotificationType.System, + NotificationCadence.Immediate, + "Valid title", + message, + "Card", + Guid.NewGuid(), + false, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Message.Should().Be(message); + deserialized.UserId.Should().Be(dto.UserId); + }); + } + + // ─────────────────────── CreateNotificationRequestDto round-trip ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property CreateNotificationRequestDto_RoundTrip_Identity() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + Arb.From(FuzzTestGenerators.AdversarialStringGen()), + Arb.From(FuzzTestGenerators.NullableStringGen()), + (title, message, sourceEntityType) => + { + var dto = new CreateNotificationRequestDto( + Guid.NewGuid(), + NotificationType.System, + title, + message, + Guid.NewGuid(), + sourceEntityType, + Guid.NewGuid(), + "dedup-key"); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be(title); + deserialized.Message.Should().Be(message); + deserialized.SourceEntityType.Should().Be(sourceEntityType); + deserialized.UserId.Should().Be(dto.UserId); + }); + } + + // ─────────────────────── NotificationDto with nullable fields ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property NotificationDto_WithNullableFields_RoundTrips() + { + return Prop.ForAll( + Arb.From(FuzzTestGenerators.NullableStringGen()), + ArbMap.Default.ArbFor(), + (sourceEntityType, isRead) => + { + var readAt = isRead ? DateTimeOffset.UtcNow : (DateTimeOffset?)null; + var dto = new NotificationDto( + Guid.NewGuid(), + Guid.NewGuid(), + null, // null boardId + NotificationType.System, + NotificationCadence.Immediate, + "Title", + "Message", + sourceEntityType, + null, // null sourceEntityId + isRead, + readAt, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); + + var json = JsonSerializer.Serialize(dto, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.BoardId.Should().BeNull(); + deserialized.SourceEntityType.Should().Be(sourceEntityType); + deserialized.SourceEntityId.Should().BeNull(); + deserialized.IsRead.Should().Be(isRead); + if (isRead) + deserialized.ReadAt.Should().NotBeNull(); + else + deserialized.ReadAt.Should().BeNull(); + }); + } + + // ─────────────────────── Malformed JSON ─────────────────────── + + [Theory] + [InlineData("{}")] + [InlineData("{\"title\": null, \"message\": null}")] + [InlineData("{\"unknownField\": 42}")] + [InlineData("{\"type\": 999}")] + [InlineData("null")] + public void NotificationDto_MalformedJson_HandledGracefully(string json) + { + try + { + var result = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + // Expected + } + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/ChatMessagePropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/ChatMessagePropertyTests.cs new file mode 100644 index 00000000..6787b419 --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/ChatMessagePropertyTests.cs @@ -0,0 +1,239 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for ChatMessage entity invariants. +/// Verifies construction with adversarial content, role enumeration, +/// message type validation, and token usage boundaries. +/// +public class ChatMessagePropertyTests +{ + private const int MaxTests = 200; + + private static readonly string[] ValidMessageTypes = + { + "text", "proposal-reference", "error", "status", "degraded", "clarification" + }; + + // ─────────────────────── Generators ─────────────────────── + + private static Gen ValidContentGen() => + Gen.Choose(1, 500) + .SelectMany(len => + Gen.ArrayOf(Gen.Elements('a', 'b', 'c', '1', '2', ' ', '.', '!'), len) + .Select(chars => new string(chars))) + .Where(s => !string.IsNullOrWhiteSpace(s)); + + private static Gen RoleGen() => + Gen.Elements(ChatMessageRole.User, ChatMessageRole.Assistant, ChatMessageRole.System); + + private static Gen ValidMessageTypeGen() => + Gen.Elements(ValidMessageTypes); + + // ─────────────────────── Construction properties ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property ValidParams_AlwaysCreatesMessage() + { + return Prop.ForAll( + Arb.From(ValidContentGen()), + Arb.From(RoleGen()), + Arb.From(ValidMessageTypeGen()), + (content, role, messageType) => + { + var sessionId = Guid.NewGuid(); + var msg = new ChatMessage(sessionId, role, content, messageType); + msg.SessionId.Should().Be(sessionId); + msg.Role.Should().Be(role); + msg.Content.Should().Be(content); + msg.MessageType.Should().Be(messageType); + msg.ProposalId.Should().BeNull(); + msg.TokenUsage.Should().BeNull(); + }); + } + + [Property(MaxTest = MaxTests)] + public Property EmptyOrWhitespaceContent_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n")), + content => + { + var act = () => new ChatMessage(Guid.NewGuid(), ChatMessageRole.User, content); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property EmptySessionId_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(ValidContentGen()), + content => + { + var act = () => new ChatMessage(Guid.Empty, ChatMessageRole.User, content); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + // ─────────────────────── Adversarial content handling ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property Constructor_NeverThrowsUnhandled_OnAdversarialContent() + { + return Prop.ForAll( + Arb.From(TestGenerators.AdversarialStringGen()), + content => + { + try + { + _ = new ChatMessage(Guid.NewGuid(), ChatMessageRole.User, content); + } + catch (DomainException) + { + // Expected for invalid content + } + catch (Exception ex) when (ex is NullReferenceException or ArgumentException + or FormatException or IndexOutOfRangeException or OverflowException) + { + throw new Exception( + $"ChatMessage constructor threw unexpected {ex.GetType().Name}: {ex.Message}"); + } + }); + } + + // ─────────────────────── MessageType validation ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property InvalidMessageType_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements( + "invalid", "TEXT", "Text", "execute", "delete", + ""), + Gen.Constant("{{constructor.constructor('return this')()}}"), + Gen.Constant("javascript:alert(1)"), + Gen.Constant("data:text/html,"), + Gen.Constant("${7*7}"), // template injection + Gen.Constant("#{7*7}"), // template injection + + // URI scheme attacks (relevant for webhook URLs, stored URLs) + Gen.Constant("file:///etc/passwd"), + Gen.Constant("http://169.254.169.254/"), // SSRF + + // Length boundary strings + Gen.Constant(""), // empty + Gen.Constant(" "), // single space + Gen.Constant(new string('\t', 50)), // many tabs + Gen.Constant(new string('\n', 50)), // many newlines + + // Explicit null + Gen.Constant((string)null!), + + // Arbitrary from FsCheck (filter nulls -- null is already covered above) + ArbMap.Default.ArbFor().Generator.Where(s => s != null) + ); + + /// + /// Wraps as nullable for optional-field testing. + /// + public static Gen NullableAdversarialStringGen() => Gen.OneOf( + Gen.Constant((string?)null), + AdversarialStringGen().Select(s => (string?)s) + ); +} diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/WebhookSubscriptionPropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/WebhookSubscriptionPropertyTests.cs new file mode 100644 index 00000000..1cff8bf0 --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/WebhookSubscriptionPropertyTests.cs @@ -0,0 +1,290 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for OutboundWebhookSubscription entity invariants. +/// Verifies endpoint URL validation, signing secret handling, event filter normalization, +/// revocation lifecycle, and adversarial input handling. +/// +public class WebhookSubscriptionPropertyTests +{ + private const int MaxTests = 200; + + // ─────────────────────── Generators ─────────────────────── + + private static Gen ValidUrlGen() => + Gen.Elements( + "https://example.com/webhook", + "https://hooks.slack.com/T00/B00/xxxx", + "https://example.com:8443/api/webhook", + "https://very-long-domain.example.com/path/to/webhook"); + + private static Gen ValidSecretGen() => + Gen.Choose(10, 100) + .SelectMany(len => + Gen.ArrayOf(Gen.Elements( + 'a', 'b', 'c', 'A', 'B', 'C', '0', '1', '2', '3', '4', '5'), len) + .Select(chars => new string(chars))); + + // ─────────────────────── Construction properties ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property ValidParams_AlwaysCreatesSubscription() + { + return Prop.ForAll( + Arb.From(ValidUrlGen()), + Arb.From(ValidSecretGen()), + (url, secret) => + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), url, secret); + sub.EndpointUrl.Should().Be(url); + sub.SigningSecret.Should().Be(secret); + sub.IsActive.Should().BeTrue(); + sub.EventFilters.Should().Be("*"); + sub.RevokedAt.Should().BeNull(); + }); + } + + [Fact] + public void EmptyBoardId_Throws() + { + var act = () => new OutboundWebhookSubscription( + Guid.Empty, Guid.NewGuid(), "https://example.com/webhook", "secret123"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Fact] + public void EmptyCreatedByUserId_Throws() + { + var act = () => new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.Empty, "https://example.com/webhook", "secret123"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + // ─────────────────────── EndpointUrl boundary ─────────────────────── + + [Theory] + [InlineData(501)] + [InlineData(1000)] + public void EndpointUrl_ExceedingLimit_Throws(int length) + { + var url = "https://" + new string('a', length - 8); + var act = () => new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), url, "secret123"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Theory] + [InlineData(1)] + [InlineData(500)] + public void EndpointUrl_WithinLimit_Succeeds(int length) + { + var url = new string('u', length); + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), url, "secret123"); + sub.EndpointUrl.Should().Be(url); + } + + // ─────────────────────── SigningSecret boundary ─────────────────────── + + [Theory] + [InlineData(201)] + [InlineData(500)] + public void SigningSecret_ExceedingLimit_Throws(int length) + { + var secret = new string('s', length); + var act = () => new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", secret); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + // ─────────────────────── Adversarial URL handling ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property Constructor_NeverThrowsUnhandled_OnAdversarialUrl() + { + return Prop.ForAll( + Arb.From(TestGenerators.AdversarialStringGen()), + url => + { + try + { + _ = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), url, "secret123"); + } + catch (DomainException) + { + // Expected for invalid URLs + } + catch (Exception ex) when (ex is NullReferenceException or ArgumentException + or FormatException or IndexOutOfRangeException or OverflowException + or UriFormatException) + { + throw new Exception( + $"WebhookSubscription constructor threw unexpected {ex.GetType().Name}: {ex.Message}"); + } + }); + } + + [Property(MaxTest = MaxTests)] + public Property Constructor_NeverThrowsUnhandled_OnAdversarialSecret() + { + return Prop.ForAll( + Arb.From(TestGenerators.AdversarialStringGen()), + secret => + { + try + { + _ = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", secret); + } + catch (DomainException) + { + // Expected for invalid secrets + } + catch (Exception ex) when (ex is NullReferenceException or ArgumentException + or FormatException or IndexOutOfRangeException or OverflowException) + { + throw new Exception( + $"WebhookSubscription constructor threw unexpected {ex.GetType().Name}: {ex.Message}"); + } + }); + } + + // ─────────────────────── Event filter handling ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property EventFilters_WithAdversarialStrings_NeverThrowUnhandled() + { + return Prop.ForAll( + Arb.From(TestGenerators.AdversarialStringGen()), + filter => + { + try + { + _ = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123", + new[] { filter }); + } + catch (DomainException) + { + // Expected for invalid filters + } + catch (Exception ex) when (ex is NullReferenceException or ArgumentException + or FormatException or IndexOutOfRangeException or OverflowException) + { + throw new Exception( + $"WebhookSubscription constructor threw unexpected {ex.GetType().Name} for filter: {ex.Message}"); + } + }); + } + + [Fact] + public void NullEventFilters_DefaultsToWildcard() + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123", + eventFilters: null); + sub.EventFilters.Should().Be("*"); + } + + [Fact] + public void EmptyEventFilters_DefaultsToWildcard() + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123", + eventFilters: Array.Empty()); + sub.EventFilters.Should().Be("*"); + } + + // ─────────────────────── MatchesEvent adversarial ─────────────────────── + + [Property(MaxTest = MaxTests)] + public Property MatchesEvent_NeverThrowsUnhandled_OnAdversarialEventType() + { + return Prop.ForAll( + Arb.From(TestGenerators.AdversarialStringGen()), + eventType => + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123"); + try + { + var result = sub.MatchesEvent(eventType); + // Wildcard should match non-empty strings + if (!string.IsNullOrWhiteSpace(eventType)) + { + result.Should().BeTrue("wildcard filter should match any non-empty event type"); + } + } + catch (Exception ex) when (ex is NullReferenceException or ArgumentException) + { + throw new Exception( + $"MatchesEvent threw unexpected {ex.GetType().Name}: {ex.Message}"); + } + }); + } + + // ─────────────────────── Revocation lifecycle ─────────────────────── + + [Fact] + public void Revoke_EmptyGuid_Throws() + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123"); + var act = () => sub.Revoke(Guid.Empty); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Fact] + public void Revoke_WhenAlreadyRevoked_Throws() + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123"); + sub.Revoke(Guid.NewGuid()); + sub.IsActive.Should().BeFalse(); + + var act = () => sub.Revoke(Guid.NewGuid()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.InvalidOperation); + } + + [Fact] + public void RotateSecret_WhenRevoked_Throws() + { + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), "https://example.com/webhook", "secret123"); + sub.Revoke(Guid.NewGuid()); + + var act = () => sub.RotateSecret("newSecret"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.InvalidOperation); + } + + // ─────────────────────── URL injection stored verbatim ─────────────────────── + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html,

hi

")] + [InlineData("https://evil.com/'; DROP TABLE --")] + public void DangerousUrl_StoredVerbatim(string url) + { + // The domain layer stores URLs after Trim(); no scheme validation at this layer + var sub = new OutboundWebhookSubscription( + Guid.NewGuid(), Guid.NewGuid(), url, "secret123"); + sub.EndpointUrl.Should().Be(url, "URLs should be stored verbatim at the domain level"); + } +}