Spaces:
Running
Running
File size: 4,506 Bytes
4b445f6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | """
Tests for GitHub webhook HMAC-SHA256 signature validation.
These tests verify that:
1. Valid signatures are accepted
2. Invalid signatures are rejected (401)
3. Missing signature headers are rejected (422)
4. Wrong format signatures are rejected (401)
This is a security-critical component — if validation is broken, an attacker
could trigger fake reviews or waste our Groq API quota by sending fabricated
webhook payloads.
How the test works:
- We use FastAPI's TestClient which simulates HTTP requests without a real server
- We compute the correct HMAC signature ourselves using the test secret
- We verify the endpoint accepts valid signatures and rejects invalid ones
"""
import hashlib
import hmac
import json
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from app.github.webhook import validate_webhook_signature
# Create a minimal FastAPI app just for testing the webhook dependency
# This isolates the test from the rest of the application
test_app = FastAPI()
# We need to override the settings for testing — we don't want to use
# the real webhook secret from .env
TEST_SECRET = "test_webhook_secret_for_unit_tests"
@test_app.post("/test-webhook")
async def webhook_endpoint(body: bytes = Depends(validate_webhook_signature)):
"""A dummy endpoint that uses the webhook validation dependency."""
return {"status": "ok", "body_length": len(body)}
def _compute_signature(payload: bytes, secret: str) -> str:
"""Compute the HMAC-SHA256 signature the same way GitHub does."""
signature = hmac.new(
key=secret.encode("utf-8"),
msg=payload,
digestmod=hashlib.sha256,
).hexdigest()
return f"sha256={signature}"
@pytest.fixture
def client(monkeypatch):
"""
Create a test client with a known webhook secret.
monkeypatch temporarily overrides settings.github_webhook_secret
so our tests use a predictable secret instead of the real one.
"""
monkeypatch.setattr(
"app.github.webhook.settings.github_webhook_secret",
TEST_SECRET,
)
return TestClient(test_app)
class TestWebhookValidation:
def test_valid_signature_accepted(self, client):
"""A correctly signed payload should return 200."""
payload = json.dumps({"action": "opened"}).encode()
signature = _compute_signature(payload, TEST_SECRET)
response = client.post(
"/test-webhook",
content=payload,
headers={"X-Hub-Signature-256": signature},
)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_invalid_signature_rejected(self, client):
"""A payload signed with the wrong secret should return 401."""
payload = json.dumps({"action": "opened"}).encode()
wrong_signature = _compute_signature(payload, "wrong_secret")
response = client.post(
"/test-webhook",
content=payload,
headers={"X-Hub-Signature-256": wrong_signature},
)
assert response.status_code == 401
def test_tampered_payload_rejected(self, client):
"""A valid signature for a DIFFERENT payload should return 401."""
original_payload = json.dumps({"action": "opened"}).encode()
signature = _compute_signature(original_payload, TEST_SECRET)
# Send a different payload but with the original's signature
tampered_payload = json.dumps({"action": "hacked"}).encode()
response = client.post(
"/test-webhook",
content=tampered_payload,
headers={"X-Hub-Signature-256": signature},
)
assert response.status_code == 401
def test_missing_signature_rejected(self, client):
"""A request without the signature header should be rejected."""
payload = json.dumps({"action": "opened"}).encode()
response = client.post("/test-webhook", content=payload)
# FastAPI returns 422 (Unprocessable Entity) for missing required headers
assert response.status_code == 422
def test_malformed_signature_rejected(self, client):
"""A signature without the 'sha256=' prefix should be rejected."""
payload = json.dumps({"action": "opened"}).encode()
response = client.post(
"/test-webhook",
content=payload,
headers={"X-Hub-Signature-256": "not_a_valid_signature"},
)
assert response.status_code == 401
|