forked from username/flaskpaste
Audit logging: - audit_log table with event tracking - app/audit.py module with log_event(), query_audit_log() - GET /audit endpoint (admin only) - configurable retention and cleanup Prometheus metrics: - app/metrics.py with custom counters - paste create/access/delete, rate limit, PoW, dedup metrics - instrumentation in API routes CLI clipboard integration: - fpaste create -C/--clipboard (read from clipboard) - fpaste create --copy-url (copy result URL) - fpaste get -c/--copy (copy content) - cross-platform: xclip, xsel, pbcopy, wl-copy Shell completions: - completions/ directory with bash/zsh/fish scripts - fpaste completion --shell command
180 lines
6.4 KiB
Python
180 lines
6.4 KiB
Python
"""Tests for Prometheus metrics module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app import create_app
|
|
from app.metrics import (
|
|
record_dedup,
|
|
record_paste_accessed,
|
|
record_paste_created,
|
|
record_paste_deleted,
|
|
record_pow,
|
|
record_rate_limit,
|
|
setup_custom_metrics,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from flask import Flask
|
|
from flask.testing import FlaskClient
|
|
|
|
|
|
@pytest.fixture
|
|
def app() -> Flask:
|
|
"""Create application configured for testing."""
|
|
app = create_app("testing")
|
|
app.config["POW_DIFFICULTY"] = 0
|
|
app.config["RATE_LIMIT_ENABLED"] = False
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app: Flask) -> FlaskClient:
|
|
"""Create test client."""
|
|
return app.test_client()
|
|
|
|
|
|
class TestMetricsFunctions:
|
|
"""Test that metrics functions execute without error."""
|
|
|
|
def test_record_paste_created_no_error(self) -> None:
|
|
"""record_paste_created should not raise when counters not initialized."""
|
|
# In testing mode, counters are None, should gracefully no-op
|
|
record_paste_created("anonymous", "success")
|
|
record_paste_created("authenticated", "success")
|
|
record_paste_created("anonymous", "failure")
|
|
|
|
def test_record_paste_accessed_no_error(self) -> None:
|
|
"""record_paste_accessed should not raise when counters not initialized."""
|
|
record_paste_accessed("anonymous", False)
|
|
record_paste_accessed("authenticated", True)
|
|
|
|
def test_record_paste_deleted_no_error(self) -> None:
|
|
"""record_paste_deleted should not raise when counters not initialized."""
|
|
record_paste_deleted("authenticated", "success")
|
|
record_paste_deleted("authenticated", "failure")
|
|
|
|
def test_record_rate_limit_no_error(self) -> None:
|
|
"""record_rate_limit should not raise when counters not initialized."""
|
|
record_rate_limit("allowed")
|
|
record_rate_limit("blocked")
|
|
|
|
def test_record_pow_no_error(self) -> None:
|
|
"""record_pow should not raise when counters not initialized."""
|
|
record_pow("success")
|
|
record_pow("failure")
|
|
|
|
def test_record_dedup_no_error(self) -> None:
|
|
"""record_dedup should not raise when counters not initialized."""
|
|
record_dedup("allowed")
|
|
record_dedup("blocked")
|
|
|
|
|
|
class TestMetricsSetup:
|
|
"""Test metrics initialization."""
|
|
|
|
def test_setup_skipped_in_testing(self, app: Flask) -> None:
|
|
"""setup_custom_metrics should skip initialization in testing mode."""
|
|
with app.app_context():
|
|
# Should not raise even though prometheus_client is available
|
|
setup_custom_metrics(app)
|
|
|
|
def test_setup_handles_missing_prometheus(self, app: Flask) -> None:
|
|
"""setup_custom_metrics should handle missing prometheus_client gracefully."""
|
|
app.config["TESTING"] = False
|
|
|
|
with app.app_context(), patch.dict("sys.modules", {"prometheus_client": None}):
|
|
# Should not raise, just log warning
|
|
setup_custom_metrics(app)
|
|
|
|
|
|
class TestMetricsIntegration:
|
|
"""Test metrics are recorded during API operations."""
|
|
|
|
def test_paste_create_records_metric(self, client: FlaskClient) -> None:
|
|
"""Paste creation should call record_paste_created."""
|
|
with patch("app.api.routes.record_paste_created") as mock_record:
|
|
response = client.post("/", data=b"test content")
|
|
assert response.status_code == 201
|
|
mock_record.assert_called_once_with("anonymous", "success")
|
|
|
|
def test_paste_access_records_metric(self, client: FlaskClient) -> None:
|
|
"""Paste access should call record_paste_accessed."""
|
|
# Create paste first
|
|
create_response = client.post("/", data=b"access test")
|
|
paste_id = create_response.get_json()["id"]
|
|
|
|
with patch("app.api.routes.record_paste_accessed") as mock_record:
|
|
response = client.get(f"/{paste_id}/raw")
|
|
assert response.status_code == 200
|
|
mock_record.assert_called_once_with("anonymous", False)
|
|
|
|
def test_burn_after_read_records_metric(self, client: FlaskClient) -> None:
|
|
"""Burn-after-read access should record burn=True."""
|
|
# Create burn-after-read paste
|
|
create_response = client.post(
|
|
"/",
|
|
data=b"burn test",
|
|
headers={"X-Burn-After-Read": "true"},
|
|
)
|
|
paste_id = create_response.get_json()["id"]
|
|
|
|
with patch("app.api.routes.record_paste_accessed") as mock_record:
|
|
response = client.get(f"/{paste_id}/raw")
|
|
assert response.status_code == 200
|
|
mock_record.assert_called_once_with("anonymous", True)
|
|
|
|
def test_dedup_allowed_records_metric(self, client: FlaskClient) -> None:
|
|
"""Successful paste creation should call record_dedup with 'allowed'."""
|
|
with patch("app.api.routes.record_dedup") as mock_record:
|
|
response = client.post("/", data=b"unique content for dedup test")
|
|
assert response.status_code == 201
|
|
mock_record.assert_called_once_with("allowed")
|
|
|
|
def test_pow_success_records_metric(self, app: Flask) -> None:
|
|
"""Successful PoW verification should call record_pow with 'success'."""
|
|
app.config["POW_DIFFICULTY"] = 8
|
|
client = app.test_client()
|
|
|
|
# Get a challenge
|
|
challenge_resp = client.get("/challenge")
|
|
challenge = challenge_resp.get_json()
|
|
|
|
# Solve it (brute force for testing)
|
|
import hashlib
|
|
|
|
nonce = challenge["nonce"]
|
|
difficulty = challenge["difficulty"]
|
|
token = challenge["token"]
|
|
|
|
solution = 0
|
|
while True:
|
|
work = f"{nonce}:{solution}".encode()
|
|
hash_bytes = hashlib.sha256(work).digest()
|
|
zero_bits = 0
|
|
for byte in hash_bytes:
|
|
if byte == 0:
|
|
zero_bits += 8
|
|
else:
|
|
zero_bits += 8 - byte.bit_length()
|
|
break
|
|
if zero_bits >= difficulty:
|
|
break
|
|
solution += 1
|
|
|
|
with patch("app.api.routes.record_pow") as mock_record:
|
|
response = client.post(
|
|
"/",
|
|
data=b"pow test content",
|
|
headers={
|
|
"X-PoW-Token": token,
|
|
"X-PoW-Solution": str(solution),
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
mock_record.assert_called_with("success")
|