"""Tests for burn-after-read and custom expiry features.""" import time import pytest from app import create_app from app.database import cleanup_expired_pastes class TestBurnAfterRead: """Test burn-after-read paste functionality.""" @pytest.fixture def app(self): """Create app for burn-after-read tests.""" return create_app("testing") @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_create_burn_paste(self, client): """Creating a burn-after-read paste should succeed.""" response = client.post( "/", data=b"secret message", headers={"X-Burn-After-Read": "true"}, ) assert response.status_code == 201 data = response.get_json() assert data["burn_after_read"] is True def test_burn_paste_deleted_after_raw_get(self, client): """Burn paste should be deleted after first GET /raw.""" # Create burn paste response = client.post( "/", data=b"one-time secret", headers={"X-Burn-After-Read": "true"}, ) paste_id = response.get_json()["id"] # First GET should succeed response = client.get(f"/{paste_id}/raw") assert response.status_code == 200 assert response.data == b"one-time secret" assert response.headers.get("X-Burn-After-Read") == "true" # Second GET should fail (paste deleted) response = client.get(f"/{paste_id}/raw") assert response.status_code == 404 def test_burn_paste_metadata_does_not_trigger_burn(self, client): """GET metadata should not delete burn paste.""" # Create burn paste response = client.post( "/", data=b"secret", headers={"X-Burn-After-Read": "true"}, ) paste_id = response.get_json()["id"] # Metadata GET should succeed and show burn flag response = client.get(f"/{paste_id}") assert response.status_code == 200 data = response.get_json() assert data["burn_after_read"] is True # Paste should still exist response = client.get(f"/{paste_id}") assert response.status_code == 200 # Raw GET should delete it response = client.get(f"/{paste_id}/raw") assert response.status_code == 200 # Now it's gone response = client.get(f"/{paste_id}") assert response.status_code == 404 def test_head_triggers_burn(self, client): """HEAD request SHOULD delete burn paste (security fix BURN-001). HEAD requests trigger burn-after-read deletion to prevent attackers from probing paste existence before retrieval. """ # Create burn paste response = client.post( "/", data=b"secret", headers={"X-Burn-After-Read": "true"}, ) paste_id = response.get_json()["id"] # HEAD should succeed and trigger burn response = client.head(f"/{paste_id}/raw") assert response.status_code == 200 assert response.headers.get("X-Burn-After-Read") == "true" # Paste should be deleted - subsequent access should fail response = client.get(f"/{paste_id}/raw") assert response.status_code == 404 def test_burn_header_variations(self, client): """Different true values for X-Burn-After-Read should work.""" for value in ["true", "TRUE", "1", "yes", "YES"]: response = client.post( "/", data=b"content", headers={"X-Burn-After-Read": value}, ) data = response.get_json() assert data.get("burn_after_read") is True, f"Failed for value: {value}" def test_burn_header_false_values(self, client): """False values should not enable burn-after-read.""" for value in ["false", "0", "no", ""]: response = client.post( "/", data=b"content", headers={"X-Burn-After-Read": value}, ) data = response.get_json() assert "burn_after_read" not in data, f"Should not be burn for: {value}" class TestCustomExpiry: """Test custom expiry functionality.""" @pytest.fixture def app(self): """Create app with short max expiry for testing.""" app = create_app("testing") app.config["MAX_EXPIRY_SECONDS"] = 3600 # 1 hour max app.config["PASTE_EXPIRY_SECONDS"] = 60 # 1 minute default return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_create_paste_with_custom_expiry(self, client): """Creating a paste with X-Expiry should set expires_at.""" response = client.post( "/", data=b"temporary content", headers={"X-Expiry": "300"}, # 5 minutes ) assert response.status_code == 201 data = response.get_json() assert "expires_at" in data # Should be approximately now + 300 now = int(time.time()) assert abs(data["expires_at"] - (now + 300)) < 5 def test_custom_expiry_capped_at_max(self, client): """Custom expiry should be capped at MAX_EXPIRY_SECONDS.""" response = client.post( "/", data=b"content", headers={"X-Expiry": "999999"}, # Way more than max ) assert response.status_code == 201 data = response.get_json() assert "expires_at" in data # Should be capped at 3600 seconds from now now = int(time.time()) assert abs(data["expires_at"] - (now + 3600)) < 5 def test_expiry_shown_in_metadata(self, client): """Custom expiry should appear in paste metadata.""" response = client.post( "/", data=b"content", headers={"X-Expiry": "600"}, ) paste_id = response.get_json()["id"] response = client.get(f"/{paste_id}") data = response.get_json() assert "expires_at" in data def test_invalid_expiry_uses_default(self, client): """Invalid X-Expiry values should use default expiry.""" for value in ["invalid", "-100", "0", ""]: response = client.post( "/", data=b"content", headers={"X-Expiry": value}, ) assert response.status_code == 201 data = response.get_json() # Default expiry is applied for anonymous users assert "expires_at" in data, f"Should have default expiry for: {value}" def test_paste_without_custom_expiry(self, client): """Paste without X-Expiry should use default expiry based on auth level.""" response = client.post("/", data=b"content") assert response.status_code == 201 data = response.get_json() # Default expiry is now applied for all users assert "expires_at" in data class TestExpiryCleanup: """Test cleanup of expired pastes.""" @pytest.fixture def app(self): """Create app with very short expiry for testing.""" app = create_app("testing") # Set tiered expiry to 1 second for all levels app.config["EXPIRY_ANON"] = 1 app.config["EXPIRY_UNTRUSTED"] = 1 app.config["EXPIRY_TRUSTED"] = 1 app.config["PASTE_EXPIRY_SECONDS"] = 1 # Legacy app.config["MAX_EXPIRY_SECONDS"] = 10 return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_cleanup_custom_expired_paste(self, app, client): """Paste with expired custom expiry should be cleaned up.""" # Create paste with 1 second expiry response = client.post( "/", data=b"expiring soon", headers={"X-Expiry": "1"}, ) paste_id = response.get_json()["id"] # Should exist immediately response = client.get(f"/{paste_id}") assert response.status_code == 200 # Wait for expiry time.sleep(2) # Run cleanup with app.app_context(): deleted = cleanup_expired_pastes() assert deleted >= 1 # Should be gone response = client.get(f"/{paste_id}") assert response.status_code == 404 def test_cleanup_respects_default_expiry(self, app, client): """Paste without custom expiry should use default expiry.""" # Create paste without custom expiry response = client.post("/", data=b"default expiry") paste_id = response.get_json()["id"] # Wait for default expiry (1 second in test config) time.sleep(2) # Run cleanup with app.app_context(): deleted = cleanup_expired_pastes() assert deleted >= 1 # Should be gone response = client.get(f"/{paste_id}") assert response.status_code == 404 def test_cleanup_keeps_unexpired_paste(self, app, client): """Paste with future custom expiry should not be cleaned up.""" # Create paste with long expiry response = client.post( "/", data=b"not expiring soon", headers={"X-Expiry": "10"}, # 10 seconds ) paste_id = response.get_json()["id"] # Run cleanup immediately with app.app_context(): cleanup_expired_pastes() # Should still exist response = client.get(f"/{paste_id}") assert response.status_code == 200 class TestCombinedOptions: """Test combinations of burn-after-read and custom expiry.""" @pytest.fixture def app(self): """Create app for combined tests.""" return create_app("testing") @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_burn_and_expiry_together(self, client): """Paste can have both burn-after-read and custom expiry.""" response = client.post( "/", data=b"secret with expiry", headers={ "X-Burn-After-Read": "true", "X-Expiry": "3600", }, ) assert response.status_code == 201 data = response.get_json() assert data["burn_after_read"] is True assert "expires_at" in data class TestPasswordProtection: """Test password-protected paste functionality.""" @pytest.fixture def app(self): """Create app for password tests.""" return create_app("testing") @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_create_password_protected_paste(self, client): """Creating a password-protected paste should succeed.""" response = client.post( "/", data=b"secret content", headers={"X-Paste-Password": "mypassword123"}, ) assert response.status_code == 201 data = response.get_json() assert data["password_protected"] is True def test_get_protected_paste_without_password(self, client): """Accessing protected paste without password should return 401.""" # Create protected paste response = client.post( "/", data=b"protected content", headers={"X-Paste-Password": "secret"}, ) paste_id = response.get_json()["id"] # Try to access without password response = client.get(f"/{paste_id}") assert response.status_code == 401 data = response.get_json() assert data["password_protected"] is True assert "Password required" in data["error"] def test_get_protected_paste_with_wrong_password(self, client): """Accessing protected paste with wrong password should return 403.""" # Create protected paste response = client.post( "/", data=b"protected content", headers={"X-Paste-Password": "correctpassword"}, ) paste_id = response.get_json()["id"] # Try with wrong password response = client.get( f"/{paste_id}", headers={"X-Paste-Password": "wrongpassword"}, ) assert response.status_code == 403 data = response.get_json() assert "Invalid password" in data["error"] def test_get_protected_paste_with_correct_password(self, client): """Accessing protected paste with correct password should succeed.""" password = "supersecret123" # Create protected paste response = client.post( "/", data=b"protected content", headers={"X-Paste-Password": password}, ) paste_id = response.get_json()["id"] # Access with correct password response = client.get( f"/{paste_id}", headers={"X-Paste-Password": password}, ) assert response.status_code == 200 data = response.get_json() assert data["password_protected"] is True def test_get_raw_protected_paste_without_password(self, client): """Getting raw content without password should return 401.""" response = client.post( "/", data=b"secret raw content", headers={"X-Paste-Password": "secret"}, ) paste_id = response.get_json()["id"] response = client.get(f"/{paste_id}/raw") assert response.status_code == 401 def test_get_raw_protected_paste_with_correct_password(self, client): """Getting raw content with correct password should succeed.""" password = "mypassword" response = client.post( "/", data=b"secret raw content", headers={"X-Paste-Password": password}, ) paste_id = response.get_json()["id"] response = client.get( f"/{paste_id}/raw", headers={"X-Paste-Password": password}, ) assert response.status_code == 200 assert response.data == b"secret raw content" def test_password_too_long_rejected(self, client): """Password longer than 1024 chars should be rejected.""" long_password = "x" * 1025 response = client.post( "/", data=b"content", headers={"X-Paste-Password": long_password}, ) assert response.status_code == 400 data = response.get_json() assert "too long" in data["error"] def test_unprotected_paste_accessible(self, client): """Unprotected paste should be accessible without password.""" response = client.post("/", data=b"public content") paste_id = response.get_json()["id"] response = client.get(f"/{paste_id}") assert response.status_code == 200 assert "password_protected" not in response.get_json() def test_password_with_special_chars(self, client): """Password with special characters should work.""" password = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?" response = client.post( "/", data=b"special content", headers={"X-Paste-Password": password}, ) paste_id = response.get_json()["id"] response = client.get( f"/{paste_id}", headers={"X-Paste-Password": password}, ) assert response.status_code == 200 def test_password_with_unicode(self, client): """Password with unicode characters should work.""" password = "пароль密码🔐" response = client.post( "/", data=b"unicode content", headers={"X-Paste-Password": password}, ) paste_id = response.get_json()["id"] response = client.get( f"/{paste_id}", headers={"X-Paste-Password": password}, ) assert response.status_code == 200 def test_password_combined_with_burn(self, client): """Password protection can be combined with burn-after-read.""" password = "secret" response = client.post( "/", data=b"protected burn content", headers={ "X-Paste-Password": password, "X-Burn-After-Read": "true", }, ) assert response.status_code == 201 data = response.get_json() assert data["password_protected"] is True assert data["burn_after_read"] is True paste_id = data["id"] # First access with password should succeed response = client.get( f"/{paste_id}/raw", headers={"X-Paste-Password": password}, ) assert response.status_code == 200 # Second access should fail (burned) response = client.get( f"/{paste_id}/raw", headers={"X-Paste-Password": password}, ) assert response.status_code == 404