diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureAdversarialTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureAdversarialTests.cs new file mode 100644 index 000000000..fa21999b1 --- /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 produce parse errors or non-object results', () => { + const malformedParams = [ + { 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 { input, shouldThrow, expectedValue } of malformedParams) { + if (shouldThrow) { + expect(() => JSON.parse(input)).toThrow() + } else { + expect(JSON.parse(input)).toBe(expectedValue) + } + } + }) +}) + +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 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 + ] + + 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 json = JSON.stringify({ text: longText }) + const parsed = JSON.parse(json) + + // Functional correctness: text survives round-trip + expect(parsed.text).toBe(longText) + }) +})