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:
2025-10-16 15:14:51 +02:00
co-authored by Claude
parent 07e51d7468
commit b301def134
37 changed files with 2416 additions and 39 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests package"""
+89
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Contract tests package"""
+209
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Unit tests package"""