Implement MVP: Anonymous feedback submission (User Story 1)
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>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests package"""
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Pytest configuration and fixtures"""
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from app import create_app
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app('testing')
|
||||
|
||||
# Create temporary data directory
|
||||
with app.app_context():
|
||||
os.makedirs(app.config['DATA_DIR'], exist_ok=True)
|
||||
|
||||
yield app
|
||||
|
||||
# Cleanup temporary directory
|
||||
with app.app_context():
|
||||
if os.path.exists(app.config['DATA_DIR']):
|
||||
shutil.rmtree(app.config['DATA_DIR'])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""Create test CLI runner"""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(app):
|
||||
"""Create administrator user for testing"""
|
||||
with app.app_context():
|
||||
user = User.create(
|
||||
username='admin',
|
||||
email='admin@example.com',
|
||||
password='admin123',
|
||||
role='administrator'
|
||||
)
|
||||
yield user
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def product_owner_user(app):
|
||||
"""Create product owner user for testing"""
|
||||
with app.app_context():
|
||||
user = User.create(
|
||||
username='owner',
|
||||
email='owner@example.com',
|
||||
password='owner123',
|
||||
role='product_owner',
|
||||
product_ids=['prod_0001']
|
||||
)
|
||||
yield user
|
||||
# Cleanup
|
||||
user.delete()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_admin_client(client, admin_user):
|
||||
"""Create authenticated admin client"""
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': 'admin',
|
||||
'password': 'admin123'
|
||||
}, follow_redirects=True)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_owner_client(client, product_owner_user):
|
||||
"""Create authenticated product owner client"""
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': 'owner',
|
||||
'password': 'owner123'
|
||||
}, follow_redirects=True)
|
||||
yield client
|
||||
@@ -0,0 +1 @@
|
||||
"""Contract tests package"""
|
||||
@@ -0,0 +1,209 @@
|
||||
"""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
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests package"""
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Integration test for complete feedback submission flow"""
|
||||
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.integration
|
||||
def test_complete_feedback_submission_flow(client, app, test_product):
|
||||
"""T039: Integration test for complete feedback submission flow
|
||||
|
||||
Test the entire user journey:
|
||||
1. User visits submission form
|
||||
2. User fills in feedback text
|
||||
3. User attaches files
|
||||
4. User submits form
|
||||
5. System validates input
|
||||
6. System saves feedback to filesystem
|
||||
7. System displays confirmation
|
||||
8. Feedback is retrievable from storage
|
||||
"""
|
||||
# Step 1: Visit submission form
|
||||
response = client.get('/submit/test-product')
|
||||
assert response.status_code == 200
|
||||
assert b'<form' in response.data
|
||||
|
||||
# Step 2-4: Submit feedback with text and files
|
||||
feedback_text = 'I found a bug in the login page. When I enter my password, it does not accept special characters.'
|
||||
|
||||
data = {
|
||||
'feedback_text': feedback_text,
|
||||
'files': [
|
||||
(io.BytesIO(b'PNG fake image data'), 'screenshot.png'),
|
||||
(io.BytesIO(b'Error log contents\nLine 2\nLine 3'), 'error.log')
|
||||
]
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True)
|
||||
|
||||
# Step 7: Verify success confirmation
|
||||
assert response.status_code == 200
|
||||
assert b'success' in response.data.lower() or b'thank' in response.data.lower()
|
||||
|
||||
# Step 8: Verify feedback was saved to filesystem
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products', 'test-product', 'feedback')
|
||||
|
||||
# Check that feedback directory was created
|
||||
assert os.path.exists(products_dir)
|
||||
|
||||
# Find the created feedback directory (should be UUID-named)
|
||||
feedback_dirs = [d for d in os.listdir(products_dir)
|
||||
if os.path.isdir(os.path.join(products_dir, d))]
|
||||
|
||||
assert len(feedback_dirs) > 0, "No feedback directory was created"
|
||||
|
||||
feedback_dir = os.path.join(products_dir, feedback_dirs[0])
|
||||
|
||||
# Verify metadata.yaml exists
|
||||
metadata_file = os.path.join(feedback_dir, 'metadata.yaml')
|
||||
assert os.path.exists(metadata_file)
|
||||
|
||||
# Verify metadata content
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
assert metadata['feedback_id'] == feedback_dirs[0]
|
||||
assert metadata['product_id'] == 'test-product'
|
||||
assert metadata['status'] == 'new'
|
||||
assert 'submitted_at' in metadata
|
||||
assert metadata.get('has_attachments') == True
|
||||
assert metadata.get('attachment_count') == 2
|
||||
|
||||
# Verify content.txt exists and contains the feedback
|
||||
content_file = os.path.join(feedback_dir, 'content.txt')
|
||||
assert os.path.exists(content_file)
|
||||
|
||||
with open(content_file, 'r') as f:
|
||||
saved_content = f.read()
|
||||
|
||||
assert feedback_text in saved_content
|
||||
|
||||
# Verify attachments directory and files exist
|
||||
attachments_dir = os.path.join(feedback_dir, 'attachments')
|
||||
assert os.path.exists(attachments_dir)
|
||||
|
||||
attachments = os.listdir(attachments_dir)
|
||||
assert len(attachments) == 2
|
||||
|
||||
# Verify specific attachment files
|
||||
attachment_names = [a for a in attachments]
|
||||
assert 'screenshot.png' in attachment_names
|
||||
assert 'error.log' in attachment_names
|
||||
|
||||
# Verify no IP address is stored (FR-055 compliance)
|
||||
assert 'ip_address' not in metadata
|
||||
assert 'submitter_ip' not in metadata
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_feedback_submission_without_attachments(client, app, test_product):
|
||||
"""Integration test for feedback submission with text only (no files)"""
|
||||
feedback_text = 'Simple text feedback without attachments.'
|
||||
|
||||
data = {
|
||||
'feedback_text': feedback_text
|
||||
}
|
||||
|
||||
response = client.post('/submit/test-product',
|
||||
data=data,
|
||||
follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify feedback was saved
|
||||
with app.app_context():
|
||||
data_dir = app.config['DATA_DIR']
|
||||
products_dir = os.path.join(data_dir, 'products', 'test-product', 'feedback')
|
||||
|
||||
feedback_dirs = [d for d in os.listdir(products_dir)
|
||||
if os.path.isdir(os.path.join(products_dir, d))]
|
||||
|
||||
# Find the most recent feedback
|
||||
feedback_dir = os.path.join(products_dir, feedback_dirs[-1])
|
||||
|
||||
# Verify metadata shows no attachments
|
||||
metadata_file = os.path.join(feedback_dir, 'metadata.yaml')
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
|
||||
assert metadata.get('has_attachments') == False
|
||||
assert metadata.get('attachment_count') == 0
|
||||
|
||||
# Verify attachments directory doesn't exist or is empty
|
||||
attachments_dir = os.path.join(feedback_dir, 'attachments')
|
||||
if os.path.exists(attachments_dir):
|
||||
assert len(os.listdir(attachments_dir)) == 0
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests package"""
|
||||
Reference in New Issue
Block a user