535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
/**
|
|
* Property-Based Tests for Bot Posting Module
|
|
*
|
|
* Feature: bot-system
|
|
* - Property 38: Post Content Validation
|
|
*
|
|
* Tests that generated posts are validated against platform requirements
|
|
* (length, format) before publishing.
|
|
*
|
|
* **Validates: Requirements 11.5**
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import * as fc from 'fast-check';
|
|
import {
|
|
validatePostContent,
|
|
sanitizePostContent,
|
|
POST_MAX_LENGTH,
|
|
POST_MIN_LENGTH,
|
|
MAX_URLS_PER_POST,
|
|
} from './posting';
|
|
|
|
// ============================================
|
|
// GENERATORS
|
|
// ============================================
|
|
|
|
/**
|
|
* Generator for valid post content (within length limits).
|
|
*/
|
|
const validPostContentArb = fc.string({
|
|
minLength: POST_MIN_LENGTH,
|
|
maxLength: POST_MAX_LENGTH,
|
|
}).filter(s => s.trim().length >= POST_MIN_LENGTH);
|
|
|
|
/**
|
|
* Generator for post content that exceeds maximum length.
|
|
*/
|
|
const tooLongPostContentArb = fc.string({
|
|
minLength: POST_MAX_LENGTH + 1,
|
|
maxLength: POST_MAX_LENGTH + 500,
|
|
});
|
|
|
|
/**
|
|
* Generator for post content that is too short (empty or whitespace).
|
|
*/
|
|
const tooShortPostContentArb = fc.oneof(
|
|
fc.constant(''),
|
|
fc.constant(' '),
|
|
fc.constant('\n\n'),
|
|
fc.constant('\t\t')
|
|
);
|
|
|
|
/**
|
|
* Generator for post content with forbidden patterns.
|
|
*/
|
|
const forbiddenContentArb = fc.oneof(
|
|
fc.constant('This is spam content'),
|
|
fc.constant('Check out this scam'),
|
|
fc.constant('Phishing attempt here'),
|
|
fc.string({ minLength: 10, maxLength: 100 }).map(s => `${s} spam ${s}`),
|
|
fc.string({ minLength: 10, maxLength: 100 }).map(s => `${s} scam ${s}`),
|
|
fc.string({ minLength: 10, maxLength: 100 }).map(s => `${s} phishing ${s}`)
|
|
);
|
|
|
|
/**
|
|
* Generator for URLs.
|
|
*/
|
|
const urlArb = fc.webUrl();
|
|
|
|
/**
|
|
* Generator for post content with too many URLs.
|
|
*/
|
|
const tooManyUrlsContentArb = fc.array(urlArb, {
|
|
minLength: MAX_URLS_PER_POST + 1,
|
|
maxLength: MAX_URLS_PER_POST + 5,
|
|
}).map(urls => urls.join(' '));
|
|
|
|
/**
|
|
* Generator for post content with acceptable number of URLs.
|
|
*/
|
|
const acceptableUrlsContentArb = fc.tuple(
|
|
fc.string({ minLength: 10, maxLength: 100 }),
|
|
fc.array(urlArb, { minLength: 0, maxLength: MAX_URLS_PER_POST })
|
|
).map(([text, urls]) => `${text} ${urls.join(' ')}`);
|
|
|
|
/**
|
|
* Generator for content with null bytes and special characters.
|
|
*/
|
|
const unsafeContentArb = fc.string({ minLength: 10, maxLength: 100 }).map(s =>
|
|
`${s}\0null byte\0${s}`
|
|
);
|
|
|
|
/**
|
|
* Generator for content with excessive whitespace.
|
|
*/
|
|
const excessiveWhitespaceArb = fc.string({ minLength: 10, maxLength: 100 }).map(s =>
|
|
`${s}\n\n\n\n\n${s}\n\n\n\n`
|
|
);
|
|
|
|
// ============================================
|
|
// PROPERTY TESTS
|
|
// ============================================
|
|
|
|
describe('Feature: bot-system, Property 38: Post Content Validation', () => {
|
|
/**
|
|
* Property 38: Post Content Validation
|
|
*
|
|
* *For any* generated post, the content SHALL be validated against platform
|
|
* requirements (length, format) before publishing.
|
|
*
|
|
* **Validates: Requirements 11.5**
|
|
*/
|
|
|
|
it('validates that content within length limits is valid (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
validPostContentArb,
|
|
async (content) => {
|
|
const result = validatePostContent(content);
|
|
|
|
// Valid content should pass validation
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('rejects content exceeding maximum length (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
tooLongPostContentArb,
|
|
async (content) => {
|
|
// Only test if trimmed content exceeds max length
|
|
if (content.trim().length <= POST_MAX_LENGTH) {
|
|
return; // Skip this test case
|
|
}
|
|
|
|
const result = validatePostContent(content);
|
|
|
|
// Content exceeding max length should fail validation
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
// Should have specific error about length
|
|
const hasLengthError = result.errors.some(err =>
|
|
err.includes('must not exceed') || err.includes('characters')
|
|
);
|
|
expect(hasLengthError).toBe(true);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('rejects content below minimum length (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
tooShortPostContentArb,
|
|
async (content) => {
|
|
const result = validatePostContent(content);
|
|
|
|
// Content below min length should fail validation
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
// Should have error about content being required, minimum length, or empty/whitespace
|
|
const hasRelevantError = result.errors.some(err =>
|
|
err.includes('required') || err.includes('at least') ||
|
|
err.includes('character') || err.includes('empty') || err.includes('whitespace')
|
|
);
|
|
expect(hasRelevantError).toBe(true);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('rejects content with forbidden patterns (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
forbiddenContentArb,
|
|
async (content) => {
|
|
const result = validatePostContent(content);
|
|
|
|
// Content with forbidden patterns should fail validation
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
// Should have error about forbidden content
|
|
const hasForbiddenError = result.errors.some(err =>
|
|
err.includes('forbidden')
|
|
);
|
|
expect(hasForbiddenError).toBe(true);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('rejects content with too many URLs (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
tooManyUrlsContentArb,
|
|
async (content) => {
|
|
// Only test if content is within length limits
|
|
if (content.length > POST_MAX_LENGTH) {
|
|
return; // Skip this test case
|
|
}
|
|
|
|
const result = validatePostContent(content);
|
|
|
|
// Content with too many URLs should fail validation
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
// Should have error about URL count
|
|
const hasUrlError = result.errors.some(err =>
|
|
err.includes('URL') || err.includes('urls')
|
|
);
|
|
expect(hasUrlError).toBe(true);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('accepts content with acceptable number of URLs (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
acceptableUrlsContentArb,
|
|
async (content) => {
|
|
// Only test if content is within length limits
|
|
if (content.length > POST_MAX_LENGTH || content.trim().length < POST_MIN_LENGTH) {
|
|
return; // Skip this test case
|
|
}
|
|
|
|
const result = validatePostContent(content);
|
|
|
|
// Content with acceptable URLs should pass validation
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('validation is deterministic for same input (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.string({ minLength: 0, maxLength: POST_MAX_LENGTH + 100 }),
|
|
async (content) => {
|
|
const result1 = validatePostContent(content);
|
|
const result2 = validatePostContent(content);
|
|
|
|
// Same input should produce same validation result
|
|
expect(result1.valid).toBe(result2.valid);
|
|
expect(result1.errors).toEqual(result2.errors);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('validation handles null and undefined gracefully (Requirement 11.5)', async () => {
|
|
const nullResult = validatePostContent(null as any);
|
|
const undefinedResult = validatePostContent(undefined as any);
|
|
|
|
// Both should fail validation with appropriate error
|
|
expect(nullResult.valid).toBe(false);
|
|
expect(nullResult.errors.length).toBeGreaterThan(0);
|
|
|
|
expect(undefinedResult.valid).toBe(false);
|
|
expect(undefinedResult.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validation handles non-string types gracefully (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.oneof(
|
|
fc.integer(),
|
|
fc.boolean(),
|
|
fc.object(),
|
|
fc.array(fc.string())
|
|
),
|
|
async (nonString) => {
|
|
const result = validatePostContent(nonString as any);
|
|
|
|
// Non-string content should fail validation
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('validation trims whitespace before checking length (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
validPostContentArb,
|
|
fc.string({ minLength: 0, maxLength: 50 }).filter(s => /^\s+$/.test(s) || s === ''),
|
|
async (validContent, whitespace) => {
|
|
const contentWithWhitespace = `${whitespace}${validContent}${whitespace}`;
|
|
|
|
const result = validatePostContent(contentWithWhitespace);
|
|
|
|
// Should validate based on trimmed length
|
|
const trimmedLength = contentWithWhitespace.trim().length;
|
|
|
|
if (trimmedLength >= POST_MIN_LENGTH && trimmedLength <= POST_MAX_LENGTH) {
|
|
expect(result.valid).toBe(true);
|
|
} else {
|
|
expect(result.valid).toBe(false);
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('validation provides specific error messages (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.oneof(
|
|
tooLongPostContentArb.filter(s => s.trim().length > POST_MAX_LENGTH), // Only truly too long
|
|
tooShortPostContentArb,
|
|
forbiddenContentArb.filter(s => s.trim().length >= POST_MIN_LENGTH && s.trim().length <= POST_MAX_LENGTH) // Valid length but forbidden
|
|
),
|
|
async (invalidContent) => {
|
|
const result = validatePostContent(invalidContent);
|
|
|
|
// Invalid content should have specific error messages
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
// Each error should be a non-empty string
|
|
result.errors.forEach(error => {
|
|
expect(typeof error).toBe('string');
|
|
expect(error.length).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('sanitization removes null bytes (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
unsafeContentArb,
|
|
async (content) => {
|
|
const sanitized = sanitizePostContent(content);
|
|
|
|
// Sanitized content should not contain null bytes
|
|
expect(sanitized).not.toContain('\0');
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('sanitization normalizes line breaks (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.string({ minLength: 10, maxLength: 100 }).map(s =>
|
|
`${s}\r\n${s}\r\n${s}`
|
|
),
|
|
async (content) => {
|
|
const sanitized = sanitizePostContent(content);
|
|
|
|
// Sanitized content should not contain \r\n
|
|
expect(sanitized).not.toContain('\r\n');
|
|
|
|
// Should only have \n for line breaks
|
|
if (sanitized.includes('\n')) {
|
|
expect(sanitized.split('\n').length).toBeGreaterThan(1);
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('sanitization removes excessive whitespace (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
excessiveWhitespaceArb,
|
|
async (content) => {
|
|
const sanitized = sanitizePostContent(content);
|
|
|
|
// Sanitized content should not have more than 2 consecutive newlines
|
|
expect(sanitized).not.toMatch(/\n{3,}/);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('sanitization trims leading and trailing whitespace (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.string({ minLength: 10, maxLength: 100 }),
|
|
fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^\s+$/.test(s)),
|
|
async (content, whitespace) => {
|
|
const contentWithWhitespace = `${whitespace}${content}${whitespace}`;
|
|
const sanitized = sanitizePostContent(contentWithWhitespace);
|
|
|
|
// Sanitized content should be trimmed
|
|
expect(sanitized).toBe(sanitized.trim());
|
|
expect(sanitized).not.toMatch(/^\s/);
|
|
expect(sanitized).not.toMatch(/\s$/);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('sanitization is idempotent (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.string({ minLength: 0, maxLength: 200 }),
|
|
async (content) => {
|
|
const sanitized1 = sanitizePostContent(content);
|
|
const sanitized2 = sanitizePostContent(sanitized1);
|
|
|
|
// Sanitizing twice should produce the same result
|
|
expect(sanitized1).toBe(sanitized2);
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('sanitization preserves valid content structure (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
validPostContentArb,
|
|
async (content) => {
|
|
const sanitized = sanitizePostContent(content);
|
|
|
|
// Sanitized valid content should still be valid
|
|
// (assuming it doesn't have null bytes or excessive whitespace)
|
|
if (!content.includes('\0') && !content.match(/\n{3,}/)) {
|
|
expect(sanitized.trim()).toBe(content.trim());
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('validation constants are properly defined (Requirement 11.5)', async () => {
|
|
// Verify constants are reasonable
|
|
expect(POST_MAX_LENGTH).toBe(400);
|
|
expect(POST_MIN_LENGTH).toBe(1);
|
|
expect(MAX_URLS_PER_POST).toBe(5);
|
|
|
|
// Verify relationships
|
|
expect(POST_MAX_LENGTH).toBeGreaterThan(POST_MIN_LENGTH);
|
|
expect(MAX_URLS_PER_POST).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validation rejects content at boundary conditions (Requirement 11.5)', async () => {
|
|
// Test exact boundary: POST_MAX_LENGTH
|
|
const exactMaxContent = 'a'.repeat(POST_MAX_LENGTH);
|
|
const exactMaxResult = validatePostContent(exactMaxContent);
|
|
expect(exactMaxResult.valid).toBe(true);
|
|
|
|
// Test one over boundary: POST_MAX_LENGTH + 1
|
|
const overMaxContent = 'a'.repeat(POST_MAX_LENGTH + 1);
|
|
const overMaxResult = validatePostContent(overMaxContent);
|
|
expect(overMaxResult.valid).toBe(false);
|
|
|
|
// Test exact minimum: POST_MIN_LENGTH
|
|
const exactMinContent = 'a'.repeat(POST_MIN_LENGTH);
|
|
const exactMinResult = validatePostContent(exactMinContent);
|
|
expect(exactMinResult.valid).toBe(true);
|
|
|
|
// Test below minimum: empty string
|
|
const belowMinContent = '';
|
|
const belowMinResult = validatePostContent(belowMinContent);
|
|
expect(belowMinResult.valid).toBe(false);
|
|
});
|
|
|
|
it('validation handles mixed invalid conditions (Requirement 11.5)', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.tuple(
|
|
fc.boolean(), // too long?
|
|
fc.boolean(), // has forbidden content?
|
|
fc.boolean() // too many URLs?
|
|
),
|
|
async ([tooLong, forbidden, tooManyUrls]) => {
|
|
let content = 'Test content ';
|
|
|
|
if (tooLong) {
|
|
content = 'a'.repeat(POST_MAX_LENGTH + 10);
|
|
}
|
|
|
|
if (forbidden) {
|
|
content += ' spam ';
|
|
}
|
|
|
|
if (tooManyUrls) {
|
|
const urls = Array(MAX_URLS_PER_POST + 2).fill('https://example.com');
|
|
content += urls.join(' ');
|
|
}
|
|
|
|
const result = validatePostContent(content);
|
|
|
|
// If any condition is invalid, validation should fail
|
|
if (tooLong || forbidden || tooManyUrls) {
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
);
|
|
});
|
|
|
|
it('validation error count matches number of violations (Requirement 11.5)', async () => {
|
|
// Content that is too long AND has forbidden content
|
|
const multiViolationContent = 'spam '.repeat(100); // Too long and has "spam"
|
|
const result = validatePostContent(multiViolationContent);
|
|
|
|
expect(result.valid).toBe(false);
|
|
// Should have at least 2 errors (length and forbidden content)
|
|
expect(result.errors.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|