From 87dc20ef1dfa982c83680d3de1de40db6ff47b14 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:06:50 +0100 Subject: [PATCH 01/15] Add webhook URL adversarial tests Test RFC 3986 edge-case URLs, dangerous schemes (javascript:, data:, file:), SSRF vectors (localhost, metadata endpoints), URLs with credentials, malformed URLs, and Unicode in URLs against the webhook endpoint. All verify NO 500 responses. --- .../WebhookUrlAdversarialTests.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs diff --git a/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs b/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs new file mode 100644 index 00000000..4109e978 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs @@ -0,0 +1,172 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Xunit; + +namespace Taskdeck.Api.Tests; + +/// +/// Adversarial tests for webhook endpoint URL validation. +/// Verifies that dangerous, malformed, and RFC 3986 edge-case URLs +/// are rejected with 4xx — never 5xx. +/// +public class WebhookUrlAdversarialTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + private readonly HttpClient _client; + private bool _isAuthenticated; + private Guid? _boardId; + + public WebhookUrlAdversarialTests(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + private async Task EnsureAuthenticatedAsync() + { + if (_isAuthenticated) return; + await ApiTestHarness.AuthenticateAsync(_client, "webhook-adversarial"); + _isAuthenticated = true; + } + + private async Task EnsureBoardAsync() + { + if (_boardId.HasValue) return _boardId.Value; + await EnsureAuthenticatedAsync(); + var board = await ApiTestHarness.CreateBoardAsync(_client, "webhook-test"); + _boardId = board.Id; + return board.Id; + } + + // ─────────────────────── RFC 3986 edge-case URLs ─────────────────────── + + public static IEnumerable AdversarialWebhookUrls() + { + // Dangerous URL schemes + yield return new object[] { "javascript:alert(1)" }; + yield return new object[] { "data:text/html," }; + yield return new object[] { "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" }; + yield return new object[] { "vbscript:MsgBox(1)" }; + yield return new object[] { "file:///etc/passwd" }; + yield return new object[] { "ftp://evil.com/payload" }; + + // URLs with credentials (userinfo component) + yield return new object[] { "https://admin:password@evil.com/webhook" }; + yield return new object[] { "http://user:pass@127.0.0.1/callback" }; + + // Internal/localhost URLs (SSRF vectors) + yield return new object[] { "http://localhost/webhook" }; + yield return new object[] { "http://127.0.0.1/webhook" }; + yield return new object[] { "http://[::1]/webhook" }; + yield return new object[] { "http://0.0.0.0/webhook" }; + yield return new object[] { "http://169.254.169.254/latest/meta-data/" }; + yield return new object[] { "http://metadata.google.internal/" }; + + // Malformed URLs + yield return new object[] { "" }; + yield return new object[] { " " }; + yield return new object[] { "not-a-url" }; + yield return new object[] { "://missing-scheme" }; + yield return new object[] { "http://" }; + yield return new object[] { "http:///no-host" }; + + // URLs with injection payloads + yield return new object[] { "https://evil.com/webhook?q='; DROP TABLE webhooks; --" }; + yield return new object[] { "https://evil.com/webhook#" }; + yield return new object[] { "https://evil.com/\u0000null-byte" }; + yield return new object[] { "https://evil.com/\r\nHeader-Injection: true" }; + + // URLs with unicode + yield return new object[] { "https://evil.com/\u200Bhidden" }; + yield return new object[] { "https://evil.com/\u202Efdp.exe" }; + yield return new object[] { "https://xn--n3h.com/webhook" }; // punycode emoji domain + + // Extremely long URL + yield return new object[] { "https://example.com/" + new string('a', 10_000) }; + + // URL with port boundaries + yield return new object[] { "https://example.com:0/webhook" }; + yield return new object[] { "https://example.com:99999/webhook" }; + yield return new object[] { "https://example.com:-1/webhook" }; + } + + [Theory] + [MemberData(nameof(AdversarialWebhookUrls))] + public async Task CreateWebhook_WithAdversarialUrl_NeverReturns500(string endpointUrl) + { + var boardId = await EnsureBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{boardId}/webhooks", + new CreateOutboundWebhookSubscriptionDto(endpointUrl)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Webhook creation returned 500 for URL: {endpointUrl}"); + } + + [Theory] + [InlineData("https://example.com/webhook")] + [InlineData("https://hooks.slack.com/services/T00/B00/xxxx")] + [InlineData("https://example.com:8443/api/webhook")] + public async Task CreateWebhook_WithValidUrl_Succeeds(string endpointUrl) + { + var boardId = await EnsureBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{boardId}/webhooks", + new CreateOutboundWebhookSubscriptionDto(endpointUrl)); + + // Valid HTTPS URLs should either succeed or be accepted + ((int)response.StatusCode).Should().BeLessThan(500); + } + + // ─────────────────────── Event filter adversarial ─────────────────────── + + [Theory] + [InlineData("")] + [InlineData("'; DROP TABLE events; --")] + [InlineData("")] + [InlineData("\u0000")] + public async Task CreateWebhook_WithAdversarialEventFilter_NeverReturns500(string eventFilter) + { + var boardId = await EnsureBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{boardId}/webhooks", + new CreateOutboundWebhookSubscriptionDto( + "https://example.com/webhook", + new List { eventFilter })); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Webhook creation returned 500 for event filter: {eventFilter}"); + } + + // ─────────────────────── Null/missing body ─────────────────────── + + [Fact] + public async Task CreateWebhook_WithNullBody_NeverReturns500() + { + var boardId = await EnsureBoardAsync(); + + var content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"); + var response = await _client.PostAsync($"/api/boards/{boardId}/webhooks", content); + + ((int)response.StatusCode).Should().BeLessThan(500, + "Webhook creation returned 500 for null body"); + } + + [Fact] + public async Task CreateWebhook_WithEmptyBody_NeverReturns500() + { + var boardId = await EnsureBoardAsync(); + + var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); + var response = await _client.PostAsync($"/api/boards/{boardId}/webhooks", content); + + ((int)response.StatusCode).Should().BeLessThan(500, + "Webhook creation returned 500 for empty object body"); + } +} From 2f95bf630cc509edbf8752904cf9ea386702ef60 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:07:01 +0100 Subject: [PATCH 02/15] Add proposal operations adversarial tests Test malformed proposal JSON (wrong types, missing fields, extra unknown fields, enum out of range), adversarial summary strings, boundary-length summaries, and expiry minutes boundaries. Documents two known 500 bugs in skip-annotated tests: XSS in actionType and nested parameter JSON. --- .../ProposalOperationsAdversarialTests.cs | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 backend/tests/Taskdeck.Api.Tests/ProposalOperationsAdversarialTests.cs diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalOperationsAdversarialTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalOperationsAdversarialTests.cs new file mode 100644 index 00000000..15ebe6bc --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/ProposalOperationsAdversarialTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Api.Tests; + +/// +/// Adversarial tests for automation proposal creation with malformed operation payloads. +/// Verifies that malformed types, missing fields, extra unknown fields, and adversarial +/// parameter content never cause 500 errors. +/// +public class ProposalOperationsAdversarialTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + private readonly HttpClient _client; + private bool _isAuthenticated; + + public ProposalOperationsAdversarialTests(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + private async Task EnsureAuthenticatedAsync() + { + if (_isAuthenticated) return; + await ApiTestHarness.AuthenticateAsync(_client, "proposal-adversarial"); + _isAuthenticated = true; + } + + // ─────────────────────── Malformed proposal JSON ─────────────────────── + + public static IEnumerable MalformedProposalBodies() + { + // Missing required fields + yield return new object[] { "{}" }; + yield return new object[] { "{\"summary\": \"test\"}" }; + yield return new object[] { "{\"sourceType\": 0}" }; + + // Wrong types for fields + yield return new object[] { "{\"sourceType\": \"not-a-number\", \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\"}" }; + yield return new object[] { "{\"sourceType\": 0, \"summary\": 12345, \"riskLevel\": 0, \"correlationId\": \"abc\"}" }; + yield return new object[] { "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": \"high\", \"correlationId\": \"abc\"}" }; + yield return new object[] { "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": 12345}" }; + yield return new object[] { "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", \"expiryMinutes\": \"sixty\"}" }; + + // Extra unknown fields + yield return new object[] { "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", \"__proto__\": {\"admin\": true}}" }; + yield return new object[] { "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", \"constructor\": {\"prototype\": {}}}" }; + yield return new object[] { "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", \"extraField\": \"ignored\"}" }; + + // Operations with malformed data + yield return new object[] + { + "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": [{\"sequence\": \"not-int\", \"actionType\": \"create\", \"targetType\": \"card\", " + + "\"parameters\": \"{}\", \"idempotencyKey\": \"key1\"}]}" + }; + yield return new object[] + { + "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": [{\"sequence\": 0, \"actionType\": null, \"targetType\": \"card\", " + + "\"parameters\": \"{}\", \"idempotencyKey\": \"key1\"}]}" + }; + yield return new object[] + { + "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": \"not-an-array\"}" + }; + + // NOTE: Deeply nested JSON parameters and XSS in actionType cause 500s. + // These are documented as known bugs in separate skip-annotated tests below. + + // Null/empty operations + yield return new object[] + { + "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": null}" + }; + yield return new object[] + { + "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": []}" + }; + + // NOTE: XSS in actionType causes 500 — documented in a separate skip-annotated test below. + + // SQL injection in parameters + yield return new object[] + { + "{\"sourceType\": 0, \"summary\": \"'; DROP TABLE proposals; --\", \"riskLevel\": 0, " + + "\"correlationId\": \"abc\", \"operations\": [{\"sequence\": 0, \"actionType\": \"create\", " + + "\"targetType\": \"card\", \"parameters\": \"'; DROP TABLE cards; --\", \"idempotencyKey\": \"key1\"}]}" + }; + + // Enum out of range + yield return new object[] { "{\"sourceType\": 999, \"summary\": \"test\", \"riskLevel\": 999, \"correlationId\": \"abc\"}" }; + yield return new object[] { "{\"sourceType\": -1, \"summary\": \"test\", \"riskLevel\": -1, \"correlationId\": \"abc\"}" }; + } + + [Theory] + [MemberData(nameof(MalformedProposalBodies))] + public async Task CreateProposal_WithMalformedBody_NeverReturns500(string body) + { + await EnsureAuthenticatedAsync(); + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/api/automation/proposals", content); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Proposal creation returned 500 for body: {body}"); + } + + // ─────────────────────── Adversarial summary strings ─────────────────────── + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\u0000")] + [InlineData("")] + [InlineData("'; DROP TABLE proposals; --")] + public async Task CreateProposal_WithAdversarialSummary_NeverReturns500(string summary) + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync("/api/automation/proposals", + new CreateProposalDto( + ProposalSourceType.Manual, + Guid.NewGuid(), + summary, + RiskLevel.Low, + Guid.NewGuid().ToString())); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Proposal creation returned 500 for summary: [{summary.Length} chars]"); + } + + // ─────────────────────── Boundary length summary ─────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(500)] + [InlineData(501)] + [InlineData(10_000)] + [InlineData(100_000)] + public async Task CreateProposal_WithVariousSummaryLengths_NeverReturns500(int length) + { + await EnsureAuthenticatedAsync(); + + var summary = length == 0 ? "" : new string('s', length); + var response = await _client.PostAsJsonAsync("/api/automation/proposals", + new CreateProposalDto( + ProposalSourceType.Manual, + Guid.NewGuid(), + summary, + RiskLevel.Low, + Guid.NewGuid().ToString())); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Proposal creation returned 500 for summary of {length} chars"); + } + + // ─────────────────────── Expiry minutes boundary ─────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public async Task CreateProposal_WithBoundaryExpiryMinutes_NeverReturns500(int expiryMinutes) + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync("/api/automation/proposals", + new CreateProposalDto( + ProposalSourceType.Manual, + Guid.NewGuid(), + "Valid summary", + RiskLevel.Low, + Guid.NewGuid().ToString(), + ExpiryMinutes: expiryMinutes)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Proposal creation returned 500 for expiryMinutes={expiryMinutes}"); + } + + // ─────────────────────── Known 500 bugs (skip-annotated) ─────────────────────── + // These tests document real API bugs discovered by adversarial testing. + // When operations contain unexpected actionType values or nested parameter JSON, + // the proposal creation endpoint returns 500 instead of 4xx. + + [Fact(Skip = "Known bug: proposal endpoint returns 500 when operations contain XSS in actionType")] + public async Task KnownBug_XssInActionType_Causes500() + { + await EnsureAuthenticatedAsync(); + + var body = "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": [{\"sequence\": 0, \"actionType\": \"\", " + + "\"targetType\": \"card\", \"parameters\": \"{}\", \"idempotencyKey\": \"key1\"}]}"; + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/api/automation/proposals", content); + + // BUG: This returns 500 instead of 400/422. + // Expected: ((int)response.StatusCode).Should().BeLessThan(500); + ((int)response.StatusCode).Should().Be(500, + "Documenting known bug: XSS in actionType causes 500"); + } + + [Fact(Skip = "Known bug: proposal endpoint returns 500 when operations contain deeply nested parameter JSON")] + public async Task KnownBug_DeepNestedParameters_Causes500() + { + await EnsureAuthenticatedAsync(); + + var body = "{\"sourceType\": 0, \"summary\": \"test\", \"riskLevel\": 0, \"correlationId\": \"abc\", " + + "\"operations\": [{\"sequence\": 0, \"actionType\": \"create\", \"targetType\": \"card\", " + + "\"parameters\": \"{\\\"nested\\\": {\\\"deep\\\": {\\\"deeper\\\": true}}}\", \"idempotencyKey\": \"key1\"}]}"; + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/api/automation/proposals", content); + + // BUG: This returns 500 instead of 400/422 or 201. + // Expected: ((int)response.StatusCode).Should().BeLessThan(500); + ((int)response.StatusCode).Should().Be(500, + "Documenting known bug: nested parameter JSON causes 500"); + } +} From 0db16d014e2de8159c9665f8fc968e3724bc0d30 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:07:08 +0100 Subject: [PATCH 03/15] Add capture endpoint adversarial tests Exercise capture/inbox with boundary text lengths, null bytes, control characters, Unicode edge cases, nested JSON, XSS/injection payloads, adversarial optional fields, malformed JSON bodies, and empty/nonexistent board IDs. All verify stored-verbatim semantics and no 500s. --- .../CaptureAdversarialTests.cs | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 backend/tests/Taskdeck.Api.Tests/CaptureAdversarialTests.cs diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureAdversarialTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureAdversarialTests.cs new file mode 100644 index 00000000..fa21999b --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/CaptureAdversarialTests.cs @@ -0,0 +1,232 @@ +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; + +/// +/// Extended adversarial tests for capture/inbox endpoint. +/// Exercises binary data, null bytes, very long strings, nested JSON, +/// and random binary content — verifying NO 500 responses. +/// +public class CaptureAdversarialTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + private readonly HttpClient _client; + private bool _isAuthenticated; + + public CaptureAdversarialTests(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + private async Task EnsureAuthenticatedAsync() + { + if (_isAuthenticated) return; + await ApiTestHarness.AuthenticateAsync(_client, "capture-adversarial"); + _isAuthenticated = true; + } + + // ─────────────────────── Very long strings ─────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(1000)] + [InlineData(20_000)] // at limit + [InlineData(20_001)] // just over limit + [InlineData(100_000)] // far over limit + public async Task CaptureItem_WithVariousTextLengths_NeverReturns500(int length) + { + await EnsureAuthenticatedAsync(); + + var text = length == 0 ? "" : new string('c', length); + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, text)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Capture returned 500 for text of {length} chars"); + } + + // ─────────────────────── Null bytes and control characters ─────────────────────── + + [Theory] + [InlineData("\u0000")] + [InlineData("text\u0000with\u0000null\u0000bytes")] + [InlineData("\u0001\u0002\u0003\u0004\u0005\u0006\u0007")] + [InlineData("\u0008\u000B\u000C\u000E\u000F\u0010")] + [InlineData("\x1B[31mcolored\x1B[0m")] + [InlineData("\r\n\r\n\r\n")] + [InlineData("\t\t\t\t\t")] + [InlineData("before\u0000after")] + public async Task CaptureItem_WithControlChars_NeverReturns500(string text) + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, text)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Capture returned 500 for text with control chars"); + } + + // ─────────────────────── Unicode edge cases ─────────────────────── + + [Theory] + [InlineData("\uFEFF")] // BOM + [InlineData("\uFFFD")] // replacement character + [InlineData("\u200B")] // zero-width space + [InlineData("\u200E")] // LTR mark + [InlineData("\u202E")] // RTL override + [InlineData("\u0301")] // combining accent + [InlineData("e\u0301")] // decomposed e-acute + [InlineData("\u00E9")] // precomposed e-acute + [InlineData("👨‍👩‍👧‍👦")] // family emoji + [InlineData("𝕋𝕖𝕤𝕥")] // math bold + [InlineData("田中太郎")] // CJK + [InlineData("مرحبا")] // Arabic RTL + [InlineData("\u0E01\u0E38")] // Thai combining + [InlineData("\uDBFF\uDFFF")] // max surrogate pair + public async Task CaptureItem_WithUnicodeEdgeCases_NeverReturns500(string text) + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, text)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Capture returned 500 for unicode edge case"); + } + + // ─────────────────────── Nested JSON as text content ─────────────────────── + + [Theory] + [InlineData("{\"nested\": true}")] + [InlineData("[1, 2, 3]")] + [InlineData("{\"__proto__\": {\"admin\": true}}")] + [InlineData("{\"constructor\": {\"prototype\": {\"isAdmin\": true}}}")] + [InlineData("{\"action\": \"delete\", \"target\": \"all_boards\"}")] + [InlineData("{{7*7}}")] + [InlineData("${7*7}")] + [InlineData("#{7*7}")] + public async Task CaptureItem_WithNestedJsonAndTemplates_NeverReturns500(string text) + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, text)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Capture returned 500 for nested JSON/template content"); + + if (response.IsSuccessStatusCode) + { + var item = await response.Content.ReadFromJsonAsync(); + item.Should().NotBeNull(); + item!.RawText.Should().Contain(text, + "nested JSON should be stored as literal text, not interpreted"); + } + } + + // ─────────────────────── XSS/injection payloads ─────────────────────── + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("'; DROP TABLE capture_items; --")] + [InlineData("\" OR 1=1 --")] + [InlineData("Robert'); DROP TABLE students;--")] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html,")] + public async Task CaptureItem_WithInjectionPayloads_StoredVerbatim(string text) + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync("/api/capture/items", + new CreateCaptureItemDto(null, text)); + + ((int)response.StatusCode).Should().BeLessThan(500, + $"Capture returned 500 for injection payload"); + + if (response.IsSuccessStatusCode) + { + var item = await response.Content.ReadFromJsonAsync(); + item.Should().NotBeNull(); + // Injection payloads should be stored verbatim, not sanitized + item!.RawText.Should().Contain(text); + } + } + + // ─────────────────────── Optional field adversarial ─────────────────────── + + [Theory] + [InlineData("'), + fc.constant("'; DROP TABLE boards; --"), + fc.constant('" OR 1=1 --'), + fc.constant('\u0000'), + fc.constant('\uFEFF'), + fc.constant('\u200B'), + fc.constant('\u202E'), + fc.constant('\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'), + fc.constant('田中太郎'), + fc.constant('مرحبا'), + fc.constant('{\"nested\": true}'), + fc.constant('back\\slash'), + fc.constant('\x1B[31mRed\x1B[0m'), + fc.string(), +) + +describe('Adversarial Data: Proposal Operation Parameters', () => { + it('any string serialized as operation parameter survives JSON round-trip', () => { + fc.assert( + fc.property(adversarialString, (input) => { + const operation = { + sequence: 0, + actionType: 'create', + targetType: 'card', + parameters: JSON.stringify({ title: input }), + idempotencyKey: crypto.randomUUID(), + } + const json = JSON.stringify(operation) + const parsed = JSON.parse(json) + const innerParams = JSON.parse(parsed.parameters) + expect(innerParams.title).toBe(input) + }), + { numRuns: 500 }, + ) + }) + + it('proposal with adversarial summary should serialize safely', () => { + fc.assert( + fc.property(adversarialString, (summary) => { + const proposal = { + id: crypto.randomUUID(), + sourceType: 0, + summary, + riskLevel: 0, + status: 'PendingReview', + operations: [], + } + const json = JSON.stringify(proposal) + const parsed = JSON.parse(json) + expect(parsed.summary).toBe(summary) + }), + { numRuns: 500 }, + ) + }) + + it('malformed operation parameters string does not throw when parsed cautiously', () => { + const malformedParams = [ + '', + 'not json', + '{', + '[', + '{"unclosed": ', + 'null', + '12345', + 'data', + ] + + for (const param of malformedParams) { + expect(() => { + try { + JSON.parse(param) + } catch { + // Expected - the frontend should handle this gracefully + } + }).not.toThrow() + } + }) +}) + +describe('Adversarial Data: Capture Provenance Round-Trip', () => { + it('provenance with all optional fields survives JSON round-trip', () => { + const provenanceArb = fc.record({ + captureItemId: fc.uuid(), + triageRunId: fc.option(fc.uuid(), { nil: null }), + proposalId: fc.option(fc.uuid(), { nil: null }), + promptVersion: fc.option(fc.string({ maxLength: 50 }), { nil: null }), + provider: fc.option(fc.string({ maxLength: 50 }), { nil: null }), + model: fc.option(fc.string({ maxLength: 50 }), { nil: null }), + requestedByUserId: fc.option(fc.uuid(), { nil: null }), + correlationId: fc.option(fc.uuid(), { nil: null }), + sourceSurface: fc.option(fc.string({ maxLength: 50 }), { nil: null }), + boardId: fc.option(fc.uuid(), { nil: null }), + sessionId: fc.option(fc.uuid(), { nil: null }), + }) + + fc.assert( + fc.property(provenanceArb, (provenance) => { + const json = JSON.stringify(provenance) + const parsed = JSON.parse(json) + expect(parsed.captureItemId).toBe(provenance.captureItemId) + expect(parsed.triageRunId).toBe(provenance.triageRunId) + expect(parsed.proposalId).toBe(provenance.proposalId) + expect(parsed.promptVersion).toBe(provenance.promptVersion) + expect(parsed.provider).toBe(provenance.provider) + expect(parsed.model).toBe(provenance.model) + }), + { numRuns: 200 }, + ) + }) + + it('provenance with adversarial string fields round-trips', () => { + fc.assert( + fc.property(adversarialString, adversarialString, (promptVersion, provider) => { + const provenance = { + captureItemId: crypto.randomUUID(), + promptVersion, + provider, + } + const json = JSON.stringify(provenance) + const parsed = JSON.parse(json) + expect(parsed.promptVersion).toBe(promptVersion) + expect(parsed.provider).toBe(provider) + }), + { numRuns: 500 }, + ) + }) +}) + +describe('Adversarial Data: Webhook URL Display Safety', () => { + it('dangerous URLs should be displayable as text without execution', () => { + const dangerousUrls = [ + 'javascript:alert(1)', + 'data:text/html,', + 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==', + 'vbscript:MsgBox(1)', + 'file:///etc/passwd', + 'https://admin:password@evil.com/webhook', + 'http://169.254.169.254/latest/meta-data/', + ] + + for (const url of dangerousUrls) { + // When displayed as text content, the URL string should be preserved exactly + expect(typeof url).toBe('string') + // URL encoding should work without throwing + expect(() => encodeURIComponent(url)).not.toThrow() + // JSON round-trip should preserve the URL + const json = JSON.stringify({ url }) + const parsed = JSON.parse(json) + expect(parsed.url).toBe(url) + } + }) + + it('extremely long URLs should not cause OOM', () => { + const longUrl = 'https://example.com/' + 'a'.repeat(100_000) + expect(() => { + JSON.stringify({ url: longUrl }) + }).not.toThrow() + expect(longUrl.length).toBe(100_020) + }) +}) + +describe('Adversarial Data: Numeric Boundary Values', () => { + it('card position with extreme values should serialize correctly', () => { + const extremePositions = [ + 0, + 1, + -1, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + Number.MAX_VALUE, + Number.MIN_VALUE, + Infinity, + -Infinity, + NaN, + ] + + for (const pos of extremePositions) { + const card = { id: crypto.randomUUID(), position: pos } + const json = JSON.stringify(card) + const parsed = JSON.parse(json) + + if (Number.isFinite(pos)) { + expect(parsed.position).toBe(pos) + } else if (Number.isNaN(pos)) { + // JSON.stringify converts NaN to null + expect(parsed.position).toBeNull() + } else { + // JSON.stringify converts Infinity to null + expect(parsed.position).toBeNull() + } + } + }) + + it('wipLimit boundary values should be handled', () => { + const wipLimits = [0, 1, -1, null, undefined, Number.MAX_SAFE_INTEGER] + + for (const wipLimit of wipLimits) { + const col = { name: 'Col', wipLimit } + expect(() => JSON.stringify(col)).not.toThrow() + } + }) +}) + +describe('Adversarial Data: Capture Text Edge Cases', () => { + it('capture text with binary-like data should serialize', () => { + // Simulate various binary-like string patterns + const binaryPatterns = [ + '\x00\x01\x02\x03\x04\x05', + String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i)), + '\uFFFE\uFFFF', // non-characters + '\uD800', // lone surrogate (will be replaced in JSON) + ] + + for (const pattern of binaryPatterns) { + expect(() => { + try { + const json = JSON.stringify({ text: pattern }) + JSON.parse(json) + } catch { + // Some patterns may fail JSON serialization, which is expected + } + }).not.toThrow() + } + }) + + it('very long capture text (50K chars) should not freeze', () => { + const longText = 'x'.repeat(50_000) + const start = performance.now() + + const json = JSON.stringify({ text: longText }) + JSON.parse(json) + + const elapsed = performance.now() - start + // Should complete well under 1 second + expect(elapsed).toBeLessThan(1000) + }) +}) From 86e2b02c1ed60bee0f6434b95d8715f9f41fea62 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 10 Apr 2026 01:19:00 +0100 Subject: [PATCH 08/15] Fix self-review findings in adversarial tests Remove unused RandomGuidString() method from No500sMetaTest. Improve CaptureProvenanceArb generator to vary string fields (promptVersion, provider, model, sourceSurface) across runs instead of using fixed values. --- .../Taskdeck.Api.Tests/No500sMetaTest.cs | 5 --- .../CaptureProvenanceRoundTripFuzzTests.cs | 38 +++++++++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs b/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs index 77c360d4..ed5e265a 100644 --- a/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs +++ b/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs @@ -57,11 +57,6 @@ private static string RandomString(int minLen, int maxLen) return new string(chars); } - private static string RandomGuidString() - { - return Guid.NewGuid().ToString(); - } - // ─────────────────────── Board creation sweep ─────────────────────── [Fact] diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs index d8d131bc..24939cc4 100644 --- a/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs @@ -250,6 +250,11 @@ public void CaptureProvenance_WithDateTimeBoundaries_RoundTrips(string dateStr) private static Arbitrary CaptureProvenanceArb() { + var promptVersionGen = Gen.Elements("v1.0", "v2.1", "v0.99-beta", "prompt-edge-case"); + var providerGen = Gen.Elements("mock", "openai", "gemini", "anthropic"); + var modelGen = Gen.Elements("mock-model", "gpt-4", "gemini-pro", "claude-3"); + var surfaceGen = Gen.Elements("inbox", "chat", "api", "cli", "import"); + var gen = Gen.OneOf( Gen.Constant(true), Gen.Constant(false) @@ -257,23 +262,26 @@ private static Arbitrary CaptureProvenanceArb() { if (!hasOptionals) { - return Gen.Constant(new CaptureProvenanceV1(Guid.NewGuid())); + return Gen.Fresh(() => new CaptureProvenanceV1(Guid.NewGuid())); } - return Gen.Fresh(() => - new CaptureProvenanceV1( - Guid.NewGuid(), - TriageRunId: Guid.NewGuid(), - ProposalId: Guid.NewGuid(), - PromptVersion: "v1.0", - Provider: "mock", - Model: "mock-model", - RequestedByUserId: Guid.NewGuid(), - CorrelationId: Guid.NewGuid().ToString(), - SourceSurface: "inbox", - BoardId: Guid.NewGuid(), - SessionId: Guid.NewGuid(), - ConvertedAt: DateTimeOffset.UtcNow)); + return promptVersionGen.SelectMany(pv => + providerGen.SelectMany(provider => + modelGen.SelectMany(model => + surfaceGen.Select(surface => + new CaptureProvenanceV1( + Guid.NewGuid(), + TriageRunId: Guid.NewGuid(), + ProposalId: Guid.NewGuid(), + PromptVersion: pv, + Provider: provider, + Model: model, + RequestedByUserId: Guid.NewGuid(), + CorrelationId: Guid.NewGuid().ToString(), + SourceSurface: surface, + BoardId: Guid.NewGuid(), + SessionId: Guid.NewGuid(), + ConvertedAt: DateTimeOffset.UtcNow))))); }); return Arb.From(gen); From 783ed3dd54728da3ffc03505da40004e5bad7cc4 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:48:29 +0100 Subject: [PATCH 09/15] Fix CI-blocking ESLint errors and strengthen frontend adversarial tests - Remove unnecessary backslash escapes in JSON string constant (no-useless-escape) - Replace hard wall-clock threshold with functional round-trip assertion - Replace swallowed-exception tests with explicit throw/value assertions - Fix binary data test to verify actual round-trip instead of swallowing errors --- .../tests/property/adversarialData.spec.ts | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/property/adversarialData.spec.ts b/frontend/taskdeck-web/src/tests/property/adversarialData.spec.ts index 10bc8251..f3228ddf 100644 --- a/frontend/taskdeck-web/src/tests/property/adversarialData.spec.ts +++ b/frontend/taskdeck-web/src/tests/property/adversarialData.spec.ts @@ -23,7 +23,7 @@ const adversarialString = fc.oneof( fc.constant('\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'), fc.constant('田中太郎'), fc.constant('مرحبا'), - fc.constant('{\"nested\": true}'), + fc.constant('{"nested": true}'), fc.constant('back\\slash'), fc.constant('\x1B[31mRed\x1B[0m'), fc.string(), @@ -68,26 +68,24 @@ describe('Adversarial Data: Proposal Operation Parameters', () => { ) }) - it('malformed operation parameters string does not throw when parsed cautiously', () => { + it('malformed operation parameters produce parse errors or non-object results', () => { const malformedParams = [ - '', - 'not json', - '{', - '[', - '{"unclosed": ', - 'null', - '12345', - 'data', + { input: '', shouldThrow: true }, + { input: 'not json', shouldThrow: true }, + { input: '{', shouldThrow: true }, + { input: '[', shouldThrow: true }, + { input: '{"unclosed": ', shouldThrow: true }, + { input: 'null', shouldThrow: false, expectedValue: null }, + { input: '12345', shouldThrow: false, expectedValue: 12345 }, + { input: 'data', shouldThrow: true }, ] - for (const param of malformedParams) { - expect(() => { - try { - JSON.parse(param) - } catch { - // Expected - the frontend should handle this gracefully - } - }).not.toThrow() + for (const { input, shouldThrow, expectedValue } of malformedParams) { + if (shouldThrow) { + expect(() => JSON.parse(input)).toThrow() + } else { + expect(JSON.parse(input)).toBe(expectedValue) + } } }) }) @@ -217,36 +215,34 @@ describe('Adversarial Data: Numeric Boundary Values', () => { }) describe('Adversarial Data: Capture Text Edge Cases', () => { - it('capture text with binary-like data should serialize', () => { - // Simulate various binary-like string patterns - const binaryPatterns = [ + it('capture text with binary-like data should serialize or throw JsonError', () => { + // Patterns that survive JSON round-trip + const safePatterns = [ '\x00\x01\x02\x03\x04\x05', String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i)), '\uFFFE\uFFFF', // non-characters - '\uD800', // lone surrogate (will be replaced in JSON) ] - for (const pattern of binaryPatterns) { - expect(() => { - try { - const json = JSON.stringify({ text: pattern }) - JSON.parse(json) - } catch { - // Some patterns may fail JSON serialization, which is expected - } - }).not.toThrow() + for (const pattern of safePatterns) { + const json = JSON.stringify({ text: pattern }) + const parsed = JSON.parse(json) + expect(parsed.text).toBe(pattern) } + + // Lone surrogates are replaced by JSON.stringify, verify no crash + const loneSurrogate = '\uD800' + const json = JSON.stringify({ text: loneSurrogate }) + const parsed = JSON.parse(json) + expect(typeof parsed.text).toBe('string') }) it('very long capture text (50K chars) should not freeze', () => { const longText = 'x'.repeat(50_000) - const start = performance.now() const json = JSON.stringify({ text: longText }) - JSON.parse(json) + const parsed = JSON.parse(json) - const elapsed = performance.now() - start - // Should complete well under 1 second - expect(elapsed).toBeLessThan(1000) + // Functional correctness: text survives round-trip + expect(parsed.text).toBe(longText) }) }) From 62d1cb24c0cc6997f4ec1b82832b2465daaf77be Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:50:51 +0100 Subject: [PATCH 10/15] Fix No500sMetaTest: thread safety, null bytes, and 500 detection - Replace static Random with per-test CreateRng() for thread safety - Add null byte (\0) to adversarial character set - Make AuthenticatedEndpoints test actually fail on 500 responses - Remove unreachable stack-trace checks after BeLessThan assertion --- .../Taskdeck.Api.Tests/No500sMetaTest.cs | 88 +++++++++---------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs b/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs index ed5e265a..6ab2a2dc 100644 --- a/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs +++ b/backend/tests/Taskdeck.Api.Tests/No500sMetaTest.cs @@ -34,24 +34,27 @@ private async Task EnsureAuthenticatedAsync() // ─────────────────────── Random content generators ─────────────────────── - private static readonly Random Rng = new(42); // deterministic seed for reproducibility + // Use a per-test Random instance for deterministic reproducibility. + // Static Random is not thread-safe; per-method instantiation avoids races. + private static Random CreateRng() => new(42); - private static string RandomString(int minLen, int maxLen) + private static string RandomString(Random rng, int minLen, int maxLen) { - var length = Rng.Next(minLen, maxLen + 1); + var length = rng.Next(minLen, maxLen + 1); var chars = new char[length]; for (int i = 0; i < length; i++) { - // Mix of ASCII, unicode, and control chars - var category = Rng.Next(10); + // Mix of ASCII, unicode, control chars, and null bytes + var category = rng.Next(11); chars[i] = category switch { - 0 => (char)Rng.Next(1, 32), // control chars (skip null) - 1 => (char)Rng.Next(0x4E00, 0x4F00), // CJK - 2 => (char)Rng.Next(0x0600, 0x0700), // Arabic - 3 => (char)Rng.Next(0x0300, 0x0370), // combining diacriticals - 4 => (char)Rng.Next(0x2000, 0x2070), // general punctuation / special - _ => (char)Rng.Next(0x20, 0x7F), // printable ASCII + 0 => '\0', // null byte + 1 => (char)rng.Next(1, 32), // control chars (skip null) + 2 => (char)rng.Next(0x4E00, 0x4F00), // CJK + 3 => (char)rng.Next(0x0600, 0x0700), // Arabic + 4 => (char)rng.Next(0x0300, 0x0370), // combining diacriticals + 5 => (char)rng.Next(0x2000, 0x2070), // general punctuation / special + _ => (char)rng.Next(0x20, 0x7F), // printable ASCII }; } return new string(chars); @@ -63,25 +66,18 @@ private static string RandomString(int minLen, int maxLen) public async Task BoardCreation_100RandomPayloads_Never500() { await EnsureAuthenticatedAsync(); + var rng = CreateRng(); for (int i = 0; i < 100; i++) { - var name = RandomString(0, 200); - var description = Rng.Next(2) == 0 ? null : RandomString(0, 2000); + var name = RandomString(rng, 0, 200); + var description = rng.Next(2) == 0 ? null : RandomString(rng, 0, 2000); var response = await _client.PostAsJsonAsync("/api/boards", new CreateBoardDto(name, description)); ((int)response.StatusCode).Should().BeLessThan(500, $"Board creation returned 500 on iteration {i} for name [{name.Length} chars]"); - - // Verify no stack trace in response body - if ((int)response.StatusCode >= 500) - { - var body = await response.Content.ReadAsStringAsync(); - body.Should().NotContain("System.", "500 response should not contain stack traces"); - body.Should().NotContain("at Taskdeck.", "500 response should not contain stack traces"); - } } } @@ -91,23 +87,18 @@ public async Task BoardCreation_100RandomPayloads_Never500() public async Task CaptureCreation_100RandomPayloads_Never500() { await EnsureAuthenticatedAsync(); + var rng = CreateRng(); for (int i = 0; i < 100; i++) { - var text = RandomString(0, 25_000); - var boardId = Rng.Next(3) == 0 ? (Guid?)Guid.NewGuid() : null; + var text = RandomString(rng, 0, 25_000); + var boardId = rng.Next(3) == 0 ? (Guid?)Guid.NewGuid() : null; var response = await _client.PostAsJsonAsync("/api/capture/items", new CreateCaptureItemDto(boardId, text)); ((int)response.StatusCode).Should().BeLessThan(500, $"Capture creation returned 500 on iteration {i} for text [{text.Length} chars]"); - - if ((int)response.StatusCode >= 500) - { - var body = await response.Content.ReadAsStringAsync(); - body.Should().NotContain("System.", "500 response should not contain stack traces"); - } } } @@ -117,6 +108,7 @@ public async Task CaptureCreation_100RandomPayloads_Never500() public async Task CardCreation_RandomPayloads_Never500() { await EnsureAuthenticatedAsync(); + var rng = CreateRng(); // Create a board and column for card tests var boardResponse = await _client.PostAsJsonAsync("/api/boards", @@ -132,8 +124,8 @@ public async Task CardCreation_RandomPayloads_Never500() for (int i = 0; i < 50; i++) { - var title = RandomString(0, 300); - var description = Rng.Next(2) == 0 ? null : RandomString(0, 3000); + var title = RandomString(rng, 0, 300); + var description = rng.Next(2) == 0 ? null : RandomString(rng, 0, 3000); var response = await _client.PostAsJsonAsync( $"/api/boards/{board.Id}/cards", @@ -150,10 +142,11 @@ public async Task CardCreation_RandomPayloads_Never500() public async Task Search_50RandomQueries_Never500() { await EnsureAuthenticatedAsync(); + var rng = CreateRng(); for (int i = 0; i < 50; i++) { - var query = RandomString(0, 500); + var query = RandomString(rng, 0, 500); var response = await _client.GetAsync( $"/api/search?q={Uri.EscapeDataString(query)}"); @@ -175,15 +168,17 @@ public async Task ColumnCreation_RandomPayloads_Never500() boardResponse.StatusCode.Should().Be(HttpStatusCode.Created); var board = await boardResponse.Content.ReadFromJsonAsync(); + var rng = CreateRng(); + for (int i = 0; i < 50; i++) { - var name = RandomString(0, 100); - var wipLimit = Rng.Next(4) switch + var name = RandomString(rng, 0, 100); + var wipLimit = rng.Next(4) switch { 0 => (int?)null, 1 => 0, 2 => -1, - _ => Rng.Next(1, 1000) + _ => rng.Next(1, 1000) }; var response = await _client.PostAsJsonAsync( @@ -207,13 +202,15 @@ public async Task LabelCreation_RandomPayloads_Never500() boardResponse.StatusCode.Should().Be(HttpStatusCode.Created); var board = await boardResponse.Content.ReadFromJsonAsync(); + var rng = CreateRng(); + for (int i = 0; i < 50; i++) { - var name = RandomString(0, 50); - var color = Rng.Next(3) switch + var name = RandomString(rng, 0, 50); + var color = rng.Next(3) switch { - 0 => RandomString(0, 10), // random string, not a valid hex - 1 => $"#{Rng.Next(0xFFFFFF):X6}", // valid hex + 0 => RandomString(rng, 0, 10), // random string, not a valid hex + 1 => $"#{rng.Next(0xFFFFFF):X6}", // valid hex _ => "" }; @@ -234,7 +231,7 @@ public async Task LabelCreation_RandomPayloads_Never500() [InlineData("GET", "/api/capture/items")] [InlineData("GET", "/api/automation/proposals")] [InlineData("GET", "/api/notifications")] - public async Task AuthenticatedEndpoints_NeverReturn500WithStackTrace(string method, string path) + public async Task AuthenticatedEndpoints_NeverReturn500(string method, string path) { await EnsureAuthenticatedAsync(); @@ -249,13 +246,8 @@ public async Task AuthenticatedEndpoints_NeverReturn500WithStackTrace(string met new StringContent("{}", Encoding.UTF8, "application/json")); } - if ((int)response.StatusCode >= 500) - { - var body = await response.Content.ReadAsStringAsync(); - body.Should().NotContain("StackTrace", - $"{method} {path} returned 500 with stack trace exposed"); - body.Should().NotContain("at Taskdeck.", - $"{method} {path} returned 500 with internal exception details exposed"); - } + // Fail the test when any 500 is returned -- this is the core "no 500s" assertion. + ((int)response.StatusCode).Should().BeLessThan(500, + $"{method} {path} returned {(int)response.StatusCode}"); } } From ad408fcda1d28661998109f37b0c2b049a866514 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:51:11 +0100 Subject: [PATCH 11/15] Assert 201 Created for valid webhook URLs instead of just < 500 --- .../tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs b/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs index 4109e978..b34799e7 100644 --- a/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WebhookUrlAdversarialTests.cs @@ -119,8 +119,9 @@ public async Task CreateWebhook_WithValidUrl_Succeeds(string endpointUrl) $"/api/boards/{boardId}/webhooks", new CreateOutboundWebhookSubscriptionDto(endpointUrl)); - // Valid HTTPS URLs should either succeed or be accepted - ((int)response.StatusCode).Should().BeLessThan(500); + // Valid HTTPS URLs should be accepted (201 Created) + response.StatusCode.Should().Be(HttpStatusCode.Created, + $"Valid webhook URL '{endpointUrl}' should be accepted"); } // ─────────────────────── Event filter adversarial ─────────────────────── From 39180a582dcfead6c218e0c01110e3ccd24b9e5d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:51:27 +0100 Subject: [PATCH 12/15] Use fixed timestamp in CaptureProvenance FsCheck generator for determinism --- .../Fuzz/CaptureProvenanceRoundTripFuzzTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs index 24939cc4..fbbb9b92 100644 --- a/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs @@ -281,7 +281,8 @@ private static Arbitrary CaptureProvenanceArb() SourceSurface: surface, BoardId: Guid.NewGuid(), SessionId: Guid.NewGuid(), - ConvertedAt: DateTimeOffset.UtcNow))))); + // Use a fixed timestamp for deterministic reproduction with FsCheck seeds + ConvertedAt: new DateTimeOffset(2026, 4, 10, 12, 0, 0, TimeSpan.Zero))))); }); return Arb.From(gen); From e5966b5bbd8d16fb857b9f168178dfe8611cc3c2 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:51:55 +0100 Subject: [PATCH 13/15] Replace property-based Touch test with individual Facts - Eliminates Thread.Sleep(1) * 200 iterations performance cost - Uses BeAfter instead of BeOnOrAfter for strict timestamp advancement - Uses 16ms sleep to exceed Windows timer resolution --- .../PropertyBased/QueryBoundaryValueTests.cs | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs index b0734316..200154f9 100644 --- a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs @@ -338,51 +338,48 @@ public void Column_WipLimit_Null_Accepted() // ─────────────────────── Entity Touch property ─────────────────────── - [Property(MaxTest = MaxTests)] - public Property AnyValidEntity_Touch_AdvancesUpdatedAt() + // Verifies that entity mutations always set UpdatedAt. + // Uses individual [Fact] tests instead of a property-based test to avoid + // Thread.Sleep(1) * 200 iterations performance cost and timestamp resolution issues. + + [Fact] + public void Board_Update_SetsUpdatedAt() { - return Prop.ForAll( - Arb.From(Gen.Elements("board", "card", "column", "label")), - entityType => - { - DateTimeOffset initialUpdatedAt; - DateTimeOffset afterUpdatedAt; - - switch (entityType) - { - case "board": - var board = new Board("Test"); - initialUpdatedAt = board.UpdatedAt; - Thread.Sleep(1); - board.Update(name: "Updated"); - afterUpdatedAt = board.UpdatedAt; - break; - case "card": - var card = new Card(Guid.NewGuid(), Guid.NewGuid(), "Title"); - initialUpdatedAt = card.UpdatedAt; - Thread.Sleep(1); - card.Update(description: "Updated"); - afterUpdatedAt = card.UpdatedAt; - break; - case "column": - var col = new Column(Guid.NewGuid(), "Col", 0); - initialUpdatedAt = col.UpdatedAt; - Thread.Sleep(1); - col.SetPosition(1); - afterUpdatedAt = col.UpdatedAt; - break; - case "label": - var label = new Label(Guid.NewGuid(), "Label", "#FF0000"); - initialUpdatedAt = label.UpdatedAt; - Thread.Sleep(1); - label.Update(name: "Updated"); - afterUpdatedAt = label.UpdatedAt; - break; - default: - throw new InvalidOperationException($"Unknown entity type: {entityType}"); - } - - afterUpdatedAt.Should().BeOnOrAfter(initialUpdatedAt); - }); + var board = new Board("Test"); + var initialUpdatedAt = board.UpdatedAt; + // Ensure clock advances past timer resolution + Thread.Sleep(16); + board.Update(name: "Updated"); + board.UpdatedAt.Should().BeAfter(initialUpdatedAt); + } + + [Fact] + public void Card_Update_SetsUpdatedAt() + { + var card = new Card(Guid.NewGuid(), Guid.NewGuid(), "Title"); + var initialUpdatedAt = card.UpdatedAt; + Thread.Sleep(16); + card.Update(description: "Updated"); + card.UpdatedAt.Should().BeAfter(initialUpdatedAt); + } + + [Fact] + public void Column_SetPosition_SetsUpdatedAt() + { + var col = new Column(Guid.NewGuid(), "Col", 0); + var initialUpdatedAt = col.UpdatedAt; + Thread.Sleep(16); + col.SetPosition(1); + col.UpdatedAt.Should().BeAfter(initialUpdatedAt); + } + + [Fact] + public void Label_Update_SetsUpdatedAt() + { + var label = new Label(Guid.NewGuid(), "Label", "#FF0000"); + var initialUpdatedAt = label.UpdatedAt; + Thread.Sleep(16); + label.Update(name: "Updated"); + label.UpdatedAt.Should().BeAfter(initialUpdatedAt); } } From 795a52fc724e9678c1e35efe0f6ecba0493f8efb Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:53:51 +0100 Subject: [PATCH 14/15] Fix syntax error in CaptureProvenance generator (missing closing paren) --- .../Fuzz/CaptureProvenanceRoundTripFuzzTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs index fbbb9b92..9142338e 100644 --- a/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/CaptureProvenanceRoundTripFuzzTests.cs @@ -282,7 +282,7 @@ private static Arbitrary CaptureProvenanceArb() BoardId: Guid.NewGuid(), SessionId: Guid.NewGuid(), // Use a fixed timestamp for deterministic reproduction with FsCheck seeds - ConvertedAt: new DateTimeOffset(2026, 4, 10, 12, 0, 0, TimeSpan.Zero))))); + ConvertedAt: new DateTimeOffset(2026, 4, 10, 12, 0, 0, TimeSpan.Zero)))))); }); return Arb.From(gen); From 746bbc880f05e78adb28f998193064c75ebbd120 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 11 Apr 2026 23:54:59 +0100 Subject: [PATCH 15/15] Fix Card_DueDate test to verify null-as-no-op behavior correctly --- .../PropertyBased/QueryBoundaryValueTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs index 200154f9..5fa67ab7 100644 --- a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/QueryBoundaryValueTests.cs @@ -132,11 +132,14 @@ public void Card_DueDate_AcceptsDistantPast() } [Fact] - public void Card_DueDate_AcceptsNull() + public void Card_DueDate_NullDoesNotOverwrite() { + // Passing null for dueDate is a no-op (preserves existing value) var card = new Card(Guid.NewGuid(), Guid.NewGuid(), "Title"); - card.Update(dueDate: null); - card.DueDate.Should().BeNull(); + var existingDue = DateTimeOffset.UtcNow.AddDays(7); + card.Update(dueDate: existingDue); + card.Update(dueDate: null); // should not clear the due date + card.DueDate.Should().Be(existingDue); } // ─────────────────────── String: empty vs null vs whitespace vs max-length ───────────────────────