forked from claw/flaskpaste
test: add concurrent submission tests for abuse prevention
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -405,3 +406,174 @@ class TestEntropyEnforcement:
|
||||
content_type="text/plain",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
class TestConcurrentSubmissions:
|
||||
"""Test concurrent identical submissions handling.
|
||||
|
||||
Note: SQLite with in-memory shared cache has limited concurrency support.
|
||||
These tests verify that:
|
||||
1. The dedup system doesn't allow MORE than threshold (security)
|
||||
2. Database integrity is maintained under concurrent load
|
||||
3. Sequential access properly enforces thresholds
|
||||
|
||||
Production deployments using file-based SQLite with WAL mode have better
|
||||
concurrent write handling.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def concurrent_app(self):
|
||||
"""Create app with strict dedup for concurrency testing."""
|
||||
app = create_app("testing")
|
||||
app.config["CONTENT_DEDUP_WINDOW"] = 3600
|
||||
app.config["CONTENT_DEDUP_MAX"] = 5
|
||||
return app
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="SQLite in-memory shared cache has severe concurrent write limitations. "
|
||||
"This test documents expected behavior with file-based SQLite + WAL mode."
|
||||
)
|
||||
def test_concurrent_identical_submissions_limited(self, concurrent_app):
|
||||
"""Concurrent identical submissions should not exceed threshold.
|
||||
|
||||
Under concurrent load with proper database configuration (file-based
|
||||
SQLite with WAL mode), the dedup system should properly limit
|
||||
concurrent identical submissions.
|
||||
|
||||
Note: This test is skipped because in-memory SQLite shared cache
|
||||
cannot handle concurrent writes.
|
||||
"""
|
||||
results = []
|
||||
content = b"concurrent test content xyz"
|
||||
num_threads = 10
|
||||
|
||||
def submit_paste():
|
||||
"""Submit paste."""
|
||||
with concurrent_app.test_client() as client:
|
||||
response = client.post("/", data=content)
|
||||
return response.status_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = [executor.submit(submit_paste) for _ in range(num_threads)]
|
||||
results = [f.result() for f in as_completed(futures)]
|
||||
|
||||
# Count outcomes
|
||||
successes = results.count(201)
|
||||
rejections = results.count(429)
|
||||
errors = results.count(500)
|
||||
|
||||
# Critical security property: never exceed threshold
|
||||
max_allowed = concurrent_app.config["CONTENT_DEDUP_MAX"]
|
||||
assert successes <= max_allowed, f"Exceeded threshold: {successes} > {max_allowed}"
|
||||
assert successes >= 1, "At least one submission should succeed"
|
||||
assert successes + rejections + errors == num_threads
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="SQLite in-memory shared cache has severe concurrent write limitations. "
|
||||
"This test documents expected behavior with file-based SQLite + WAL mode."
|
||||
)
|
||||
def test_concurrent_different_content_mostly_succeed(self, concurrent_app):
|
||||
"""Concurrent submissions of different content should mostly succeed.
|
||||
|
||||
With proper database configuration, unique content submissions should
|
||||
succeed without blocking each other.
|
||||
|
||||
Note: This test is skipped because in-memory SQLite shared cache
|
||||
cannot handle concurrent writes.
|
||||
"""
|
||||
results = []
|
||||
num_threads = 8
|
||||
|
||||
def submit_unique_paste(thread_id):
|
||||
"""Submit unique content per thread."""
|
||||
with concurrent_app.test_client() as client:
|
||||
content = f"unique content for thread {thread_id}".encode()
|
||||
response = client.post("/", data=content)
|
||||
return response.status_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = [executor.submit(submit_unique_paste, i) for i in range(num_threads)]
|
||||
results = [f.result() for f in as_completed(futures)]
|
||||
|
||||
# All unique content should succeed
|
||||
successes = results.count(201)
|
||||
assert successes == num_threads, f"Expected {num_threads} successes, got {successes}"
|
||||
|
||||
def test_sequential_check_content_hash_database_integrity(self, concurrent_app):
|
||||
"""Sequential check_content_hash calls should maintain database integrity."""
|
||||
content_hash = hashlib.sha256(b"sequential db test").hexdigest()
|
||||
max_allowed = concurrent_app.config["CONTENT_DEDUP_MAX"]
|
||||
results = []
|
||||
|
||||
with concurrent_app.app_context():
|
||||
# Sequential checks should work correctly
|
||||
for _ in range(max_allowed + 5):
|
||||
is_allowed, count = check_content_hash(content_hash)
|
||||
results.append((is_allowed, count))
|
||||
|
||||
# First max_allowed should succeed
|
||||
for i in range(max_allowed):
|
||||
assert results[i][0] is True, f"Check {i+1} should be allowed"
|
||||
assert results[i][1] == i + 1, f"Check {i+1} should have count {i+1}"
|
||||
|
||||
# Rest should be denied
|
||||
for i in range(max_allowed, len(results)):
|
||||
assert results[i][0] is False, f"Check {i+1} should be denied"
|
||||
assert results[i][1] == max_allowed, f"Count should stay at {max_allowed}"
|
||||
|
||||
# Verify final database state
|
||||
with concurrent_app.app_context():
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT count FROM content_hashes WHERE hash = ?", (content_hash,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["count"] == max_allowed
|
||||
|
||||
def test_rapid_sequential_submissions(self, concurrent_app):
|
||||
"""Rapid sequential submissions should properly enforce threshold."""
|
||||
content = b"rapid sequential content"
|
||||
results = []
|
||||
|
||||
with concurrent_app.test_client() as client:
|
||||
for _ in range(10):
|
||||
response = client.post("/", data=content)
|
||||
results.append(response.status_code)
|
||||
|
||||
successes = results.count(201)
|
||||
rejections = results.count(429)
|
||||
|
||||
assert successes == 5, f"Expected 5 successes, got {successes}"
|
||||
assert rejections == 5, f"Expected 5 rejections, got {rejections}"
|
||||
# First 5 should succeed, rest should fail
|
||||
assert results[:5] == [201] * 5
|
||||
assert results[5:] == [429] * 5
|
||||
|
||||
def test_staggered_concurrent_submissions(self, concurrent_app):
|
||||
"""Staggered concurrent submissions with slight delays."""
|
||||
results = []
|
||||
content = b"staggered concurrent content"
|
||||
num_submissions = 10
|
||||
|
||||
def submit_with_delay(delay_ms):
|
||||
"""Submit paste after small delay."""
|
||||
time.sleep(delay_ms / 1000)
|
||||
with concurrent_app.test_client() as client:
|
||||
response = client.post("/", data=content)
|
||||
return response.status_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_submissions) as executor:
|
||||
# Stagger submissions by 10ms each
|
||||
futures = [executor.submit(submit_with_delay, i * 10) for i in range(num_submissions)]
|
||||
results = [f.result() for f in as_completed(futures)]
|
||||
|
||||
successes = results.count(201)
|
||||
rejections = results.count(429)
|
||||
errors = results.count(500)
|
||||
|
||||
# With staggered timing, most should complete successfully
|
||||
max_allowed = concurrent_app.config["CONTENT_DEDUP_MAX"]
|
||||
assert successes <= max_allowed, f"Exceeded threshold: {successes} > {max_allowed}"
|
||||
assert successes >= 1, "At least one submission should succeed"
|
||||
# Verify reasonable behavior
|
||||
assert successes + rejections + errors == num_submissions
|
||||
|
||||
Reference in New Issue
Block a user