Complete implementation of Phase 1-3 (64 tasks): - Phase 1: Project setup with Flask, pytest, configuration - Phase 2: Core infrastructure (auth, models, services, testing) - Phase 3: Anonymous feedback submission with file uploads Features: - Anonymous feedback submission (text and/or up to 3 file attachments) - Multi-language support (any language accepted) - File validation (type, size) and virus scanning (ClamAV) - Product management with active/archived status - File-based storage with YAML metadata - User authentication system (Flask-Login) - CSRF protection and rate limiting - Test coverage: 10 passing tests (contract + integration) Security: - No IP address logging (FR-055 compliance) - File type whitelist and size limits (10MB max) - Virus scanning with graceful degradation - Filename sanitization and secure storage Test Results: - 8 contract tests passed - 2 integration tests passed - End-to-end workflow verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
210 lines
6.3 KiB
Python
210 lines
6.3 KiB
Python
"""Contract tests for submission routes"""
|
|
import pytest
|
|
import io
|
|
import os
|
|
import yaml
|
|
|
|
|
|
@pytest.fixture
|
|
def test_product(app):
|
|
"""Create a test product"""
|
|
with app.app_context():
|
|
# Create test product directory and config
|
|
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
|
|
os.makedirs(product_dir, exist_ok=True)
|
|
|
|
# Create product config
|
|
config_file = os.path.join(product_dir, 'config.yaml')
|
|
config_data = {
|
|
'product_id': 'test-product',
|
|
'name': 'Test Product',
|
|
'submission_url_slug': 'test-product',
|
|
'owner_language': 'en',
|
|
'assigned_owner_ids': ['usr_0001'],
|
|
'status': 'active'
|
|
}
|
|
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
yield 'test-product'
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_get_submission_form(client, test_product):
|
|
"""T030: Contract test for GET /submit/{product_slug}
|
|
|
|
Expected: 200 OK with HTML form containing textarea and file inputs
|
|
"""
|
|
response = client.get('/submit/test-product')
|
|
|
|
assert response.status_code == 200
|
|
assert b'<form' in response.data
|
|
assert b'textarea' in response.data or b'<textarea' in response.data
|
|
assert b'type="file"' in response.data
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_post_submission_text_only(client, test_product):
|
|
"""T031: Contract test for POST /submit/{product_slug} with text only
|
|
|
|
Expected: 200/302 success with confirmation message
|
|
"""
|
|
data = {
|
|
'feedback_text': 'This is my feedback about the product.'
|
|
}
|
|
|
|
response = client.post('/submit/test-product', data=data, follow_redirects=True)
|
|
|
|
assert response.status_code == 200
|
|
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_post_submission_files_only(client, test_product):
|
|
"""T032: Contract test for POST /submit/{product_slug} with files only
|
|
|
|
Expected: 200/302 success with confirmation message
|
|
"""
|
|
data = {
|
|
'files': [
|
|
(io.BytesIO(b'test file content'), 'test.txt')
|
|
]
|
|
}
|
|
|
|
response = client.post('/submit/test-product',
|
|
data=data,
|
|
content_type='multipart/form-data',
|
|
follow_redirects=True)
|
|
|
|
assert response.status_code == 200
|
|
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_post_submission_text_and_files(client, test_product):
|
|
"""T033: Contract test for POST /submit/{product_slug} with text and files
|
|
|
|
Expected: 200/302 success with confirmation message
|
|
"""
|
|
data = {
|
|
'feedback_text': 'Here is my feedback with attachments.',
|
|
'files': [
|
|
(io.BytesIO(b'screenshot content'), 'screenshot.png'),
|
|
(io.BytesIO(b'log file content'), 'error.log')
|
|
]
|
|
}
|
|
|
|
response = client.post('/submit/test-product',
|
|
data=data,
|
|
content_type='multipart/form-data',
|
|
follow_redirects=True)
|
|
|
|
assert response.status_code == 200
|
|
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_empty_submission_rejected(client, test_product):
|
|
"""T034: Contract test for empty submission rejection (400)
|
|
|
|
Expected: 400 Bad Request - must provide either text or files
|
|
"""
|
|
data = {
|
|
'feedback_text': ''
|
|
}
|
|
|
|
response = client.post('/submit/test-product', data=data)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_too_many_files_rejected(client, test_product):
|
|
"""T035: Contract test for >3 files rejection (400)
|
|
|
|
Expected: 400 Bad Request - maximum 3 files allowed
|
|
"""
|
|
data = {
|
|
'files': [
|
|
(io.BytesIO(b'file1'), 'file1.txt'),
|
|
(io.BytesIO(b'file2'), 'file2.txt'),
|
|
(io.BytesIO(b'file3'), 'file3.txt'),
|
|
(io.BytesIO(b'file4'), 'file4.txt')
|
|
]
|
|
}
|
|
|
|
response = client.post('/submit/test-product',
|
|
data=data,
|
|
content_type='multipart/form-data')
|
|
|
|
assert response.status_code == 400
|
|
assert b'maximum' in response.data.lower() or b'3' in response.data
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_large_file_rejected(client, test_product):
|
|
"""T036: Contract test for >10MB file rejection (413)
|
|
|
|
Expected: 413 Request Entity Too Large
|
|
"""
|
|
# Create a file larger than 10MB
|
|
large_content = b'x' * (11 * 1024 * 1024) # 11MB
|
|
|
|
data = {
|
|
'files': [
|
|
(io.BytesIO(large_content), 'large.txt')
|
|
]
|
|
}
|
|
|
|
response = client.post('/submit/test-product',
|
|
data=data,
|
|
content_type='multipart/form-data')
|
|
|
|
# Flask will reject this with 413 due to MAX_CONTENT_LENGTH
|
|
assert response.status_code == 413
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_unsupported_file_type_rejected(client, test_product):
|
|
"""T037: Contract test for unsupported file type rejection (400)
|
|
|
|
Expected: 400 Bad Request - file type not allowed
|
|
"""
|
|
data = {
|
|
'files': [
|
|
(io.BytesIO(b'#!/bin/bash\necho malicious'), 'script.sh')
|
|
]
|
|
}
|
|
|
|
response = client.post('/submit/test-product',
|
|
data=data,
|
|
content_type='multipart/form-data')
|
|
|
|
assert response.status_code == 400
|
|
assert b'not allowed' in response.data.lower() or b'type' in response.data.lower()
|
|
|
|
|
|
@pytest.mark.contract
|
|
def test_rate_limiting(client, test_product, app):
|
|
"""T038: Contract test for rate limiting (429 after 10 submissions)
|
|
|
|
Expected: 429 Too Many Requests after exceeding rate limit
|
|
"""
|
|
# Skip if rate limiting is disabled
|
|
if not app.config.get('RATELIMIT_ENABLED'):
|
|
pytest.skip('Rate limiting disabled in test config')
|
|
|
|
# Make 10 successful submissions (the limit)
|
|
for i in range(10):
|
|
data = {'feedback_text': f'Feedback {i}'}
|
|
response = client.post('/submit/test-product', data=data)
|
|
# Should succeed (200 or 302)
|
|
assert response.status_code in [200, 302]
|
|
|
|
# 11th submission should be rate limited
|
|
data = {'feedback_text': 'This should be rate limited'}
|
|
response = client.post('/submit/test-product', data=data)
|
|
|
|
assert response.status_code == 429
|